Compare commits
6 Commits
open-relea
...
open-relea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fea78afa85 | ||
|
|
6841e94ab0 | ||
|
|
4f04698a60 | ||
|
|
82a76b0cad | ||
|
|
62445a18f3 | ||
|
|
91ee5004a4 |
@@ -104,7 +104,6 @@ const CourseOutline = ({ courseId }) => {
|
|||||||
handleNewUnitSubmit,
|
handleNewUnitSubmit,
|
||||||
getUnitUrl,
|
getUnitUrl,
|
||||||
handleVideoSharingOptionChange,
|
handleVideoSharingOptionChange,
|
||||||
handleCopyToClipboardClick,
|
|
||||||
handlePasteClipboardClick,
|
handlePasteClipboardClick,
|
||||||
notificationDismissUrl,
|
notificationDismissUrl,
|
||||||
discussionsSettings,
|
discussionsSettings,
|
||||||
@@ -397,7 +396,6 @@ const CourseOutline = ({ courseId }) => {
|
|||||||
onDuplicateSubmit={handleDuplicateUnitSubmit}
|
onDuplicateSubmit={handleDuplicateUnitSubmit}
|
||||||
getTitleLink={getUnitUrl}
|
getTitleLink={getUnitUrl}
|
||||||
onOrderChange={updateUnitOrderByIndex}
|
onOrderChange={updateUnitOrderByIndex}
|
||||||
onCopyToClipboardClick={handleCopyToClipboardClick}
|
|
||||||
discussionsSettings={discussionsSettings}
|
discussionsSettings={discussionsSettings}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -2182,9 +2182,6 @@ describe('<CourseOutline />', () => {
|
|||||||
.onPost(getClipboardUrl(), {
|
.onPost(getClipboardUrl(), {
|
||||||
usage_key: unit.id,
|
usage_key: unit.id,
|
||||||
}).reply(200, clipboardUnit);
|
}).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
|
// find menu button and click on it to open menu
|
||||||
const menu = await within(unitElement).findByTestId('unit-card-header__menu-button');
|
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);
|
const copyButton = await within(unitElement).findByText(cardHeaderMessages.menuCopy.defaultMessage);
|
||||||
await act(async () => fireEvent.click(copyButton));
|
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');
|
[subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
|
||||||
// find clipboard content label
|
// find clipboard content label
|
||||||
const clipboardLabel = await within(subsectionElement).findByText(
|
const clipboardLabel = await within(subsectionElement).findByText(
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ export const getCourseReindexApiUrl = (reindexLink) => `${getApiBaseUrl()}${rein
|
|||||||
export const getXBlockBaseApiUrl = () => `${getApiBaseUrl()}/xblock/`;
|
export const getXBlockBaseApiUrl = () => `${getApiBaseUrl()}/xblock/`;
|
||||||
export const getCourseItemApiUrl = (itemId) => `${getXBlockBaseApiUrl()}${itemId}`;
|
export const getCourseItemApiUrl = (itemId) => `${getXBlockBaseApiUrl()}${itemId}`;
|
||||||
export const getXBlockApiUrl = (blockId) => `${getXBlockBaseApiUrl()}outline/${blockId}`;
|
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/`;
|
export const exportTags = (courseId) => `${getApiBaseUrl()}/api/content_tagging/v1/object_tags/${courseId}/export/`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { RequestStatus } from '../../data/constants';
|
import { RequestStatus } from '../../data/constants';
|
||||||
import { updateClipboardData } from '../../generic/data/slice';
|
|
||||||
import { NOTIFICATION_MESSAGES } from '../../constants';
|
import { NOTIFICATION_MESSAGES } from '../../constants';
|
||||||
import { API_ERROR_TYPES, COURSE_BLOCK_NAMES } from '../constants';
|
import { API_ERROR_TYPES, COURSE_BLOCK_NAMES } from '../constants';
|
||||||
import {
|
import {
|
||||||
@@ -88,7 +87,6 @@ export function fetchCourseOutlineIndexQuery(courseId) {
|
|||||||
},
|
},
|
||||||
} = outlineIndex;
|
} = outlineIndex;
|
||||||
dispatch(fetchOutlineIndexSuccess(outlineIndex));
|
dispatch(fetchOutlineIndexSuccess(outlineIndex));
|
||||||
dispatch(updateClipboardData(outlineIndex.initialUserClipboard));
|
|
||||||
dispatch(updateStatusBar({
|
dispatch(updateStatusBar({
|
||||||
courseReleaseDate,
|
courseReleaseDate,
|
||||||
highlightsEnabledForMessaging,
|
highlightsEnabledForMessaging,
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { useNavigate } from 'react-router-dom';
|
|||||||
import { useToggle } from '@openedx/paragon';
|
import { useToggle } from '@openedx/paragon';
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
|
|
||||||
import { copyToClipboard } from '../generic/data/thunks';
|
|
||||||
import { getSavingStatus as getGenericSavingStatus } from '../generic/data/selectors';
|
import { getSavingStatus as getGenericSavingStatus } from '../generic/data/selectors';
|
||||||
import { RequestStatus } from '../data/constants';
|
import { RequestStatus } from '../data/constants';
|
||||||
import { COURSE_BLOCK_NAMES } from './constants';
|
import { COURSE_BLOCK_NAMES } from './constants';
|
||||||
@@ -72,6 +71,7 @@ const useCourseOutline = ({ courseId }) => {
|
|||||||
mfeProctoredExamSettingsUrl,
|
mfeProctoredExamSettingsUrl,
|
||||||
advanceSettingsUrl,
|
advanceSettingsUrl,
|
||||||
} = useSelector(getOutlineIndexData);
|
} = useSelector(getOutlineIndexData);
|
||||||
|
|
||||||
const { outlineIndexLoadingStatus, reIndexLoadingStatus } = useSelector(getLoadingStatus);
|
const { outlineIndexLoadingStatus, reIndexLoadingStatus } = useSelector(getLoadingStatus);
|
||||||
const statusBarData = useSelector(getStatusBarData);
|
const statusBarData = useSelector(getStatusBarData);
|
||||||
const savingStatus = useSelector(getSavingStatus);
|
const savingStatus = useSelector(getSavingStatus);
|
||||||
@@ -95,10 +95,6 @@ const useCourseOutline = ({ courseId }) => {
|
|||||||
|
|
||||||
const isSavingStatusFailed = savingStatus === RequestStatus.FAILED || genericSavingStatus === RequestStatus.FAILED;
|
const isSavingStatusFailed = savingStatus === RequestStatus.FAILED || genericSavingStatus === RequestStatus.FAILED;
|
||||||
|
|
||||||
const handleCopyToClipboardClick = (usageKey) => {
|
|
||||||
dispatch(copyToClipboard(usageKey));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePasteClipboardClick = (parentLocator, sectionId) => {
|
const handlePasteClipboardClick = (parentLocator, sectionId) => {
|
||||||
dispatch(pasteClipboardContent(parentLocator, sectionId));
|
dispatch(pasteClipboardContent(parentLocator, sectionId));
|
||||||
};
|
};
|
||||||
@@ -339,7 +335,6 @@ const useCourseOutline = ({ courseId }) => {
|
|||||||
openUnitPage,
|
openUnitPage,
|
||||||
handleNewUnitSubmit,
|
handleNewUnitSubmit,
|
||||||
handleVideoSharingOptionChange,
|
handleVideoSharingOptionChange,
|
||||||
handleCopyToClipboardClick,
|
|
||||||
handlePasteClipboardClick,
|
handlePasteClipboardClick,
|
||||||
notificationDismissUrl,
|
notificationDismissUrl,
|
||||||
discussionsSettings,
|
discussionsSettings,
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { RequestStatus } from '../../data/constants';
|
|||||||
import CardHeader from '../card-header/CardHeader';
|
import CardHeader from '../card-header/CardHeader';
|
||||||
import SortableItem from '../../generic/drag-helper/SortableItem';
|
import SortableItem from '../../generic/drag-helper/SortableItem';
|
||||||
import { DragContext } from '../../generic/drag-helper/DragContextProvider';
|
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 TitleButton from '../card-header/TitleButton';
|
||||||
import XBlockStatus from '../xblock-status/XBlockStatus';
|
import XBlockStatus from '../xblock-status/XBlockStatus';
|
||||||
import { getItemStatus, getItemStatusBorder, scrollToElement } from '../utils';
|
import { getItemStatus, getItemStatusBorder, scrollToElement } from '../utils';
|
||||||
@@ -49,7 +49,7 @@ const SubsectionCard = ({
|
|||||||
const isScrolledToElement = locatorId === subsection.id;
|
const isScrolledToElement = locatorId === subsection.id;
|
||||||
const [isFormOpen, openForm, closeForm] = useToggle(false);
|
const [isFormOpen, openForm, closeForm] = useToggle(false);
|
||||||
const namePrefix = 'subsection';
|
const namePrefix = 'subsection';
|
||||||
const { sharedClipboardData, showPasteUnit } = useCopyToClipboard();
|
const { sharedClipboardData, showPasteUnit } = useClipboard();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
id,
|
id,
|
||||||
@@ -233,7 +233,7 @@ const SubsectionCard = ({
|
|||||||
>
|
>
|
||||||
{intl.formatMessage(messages.newUnitButton)}
|
{intl.formatMessage(messages.newUnitButton)}
|
||||||
</Button>
|
</Button>
|
||||||
{enableCopyPasteUnits && showPasteUnit && (
|
{enableCopyPasteUnits && showPasteUnit && sharedClipboardData && (
|
||||||
<PasteComponent
|
<PasteComponent
|
||||||
className="mt-4"
|
className="mt-4"
|
||||||
text={intl.formatMessage(messages.pasteButton)}
|
text={intl.formatMessage(messages.pasteButton)}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import SortableItem from '../../generic/drag-helper/SortableItem';
|
|||||||
import TitleLink from '../card-header/TitleLink';
|
import TitleLink from '../card-header/TitleLink';
|
||||||
import XBlockStatus from '../xblock-status/XBlockStatus';
|
import XBlockStatus from '../xblock-status/XBlockStatus';
|
||||||
import { getItemStatus, getItemStatusBorder, scrollToElement } from '../utils';
|
import { getItemStatus, getItemStatusBorder, scrollToElement } from '../utils';
|
||||||
|
import { useClipboard } from '../../generic/clipboard';
|
||||||
|
|
||||||
const UnitCard = ({
|
const UnitCard = ({
|
||||||
unit,
|
unit,
|
||||||
@@ -30,7 +31,6 @@ const UnitCard = ({
|
|||||||
onDuplicateSubmit,
|
onDuplicateSubmit,
|
||||||
getTitleLink,
|
getTitleLink,
|
||||||
onOrderChange,
|
onOrderChange,
|
||||||
onCopyToClipboardClick,
|
|
||||||
discussionsSettings,
|
discussionsSettings,
|
||||||
}) => {
|
}) => {
|
||||||
const currentRef = useRef(null);
|
const currentRef = useRef(null);
|
||||||
@@ -41,6 +41,8 @@ const UnitCard = ({
|
|||||||
const [isFormOpen, openForm, closeForm] = useToggle(false);
|
const [isFormOpen, openForm, closeForm] = useToggle(false);
|
||||||
const namePrefix = 'unit';
|
const namePrefix = 'unit';
|
||||||
|
|
||||||
|
const { copyToClipboard } = useClipboard();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
id,
|
id,
|
||||||
category,
|
category,
|
||||||
@@ -98,7 +100,7 @@ const UnitCard = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleCopyClick = () => {
|
const handleCopyClick = () => {
|
||||||
onCopyToClipboardClick(unit.id);
|
copyToClipboard(id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const titleComponent = (
|
const titleComponent = (
|
||||||
@@ -241,7 +243,6 @@ UnitCard.propTypes = {
|
|||||||
onOrderChange: PropTypes.func.isRequired,
|
onOrderChange: PropTypes.func.isRequired,
|
||||||
isSelfPaced: PropTypes.bool.isRequired,
|
isSelfPaced: PropTypes.bool.isRequired,
|
||||||
isCustomRelativeDatesActive: PropTypes.bool.isRequired,
|
isCustomRelativeDatesActive: PropTypes.bool.isRequired,
|
||||||
onCopyToClipboardClick: PropTypes.func.isRequired,
|
|
||||||
discussionsSettings: PropTypes.shape({
|
discussionsSettings: PropTypes.shape({
|
||||||
providerType: PropTypes.string,
|
providerType: PropTypes.string,
|
||||||
enableGradedUnits: PropTypes.bool,
|
enableGradedUnits: PropTypes.bool,
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import React from 'react';
|
|
||||||
import {
|
import {
|
||||||
act, render, fireEvent, within,
|
act, render, fireEvent, within,
|
||||||
} from '@testing-library/react';
|
} from '@testing-library/react';
|
||||||
@@ -48,6 +47,13 @@ const unit = {
|
|||||||
|
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
|
const clipboardBroadcastChannelMock = {
|
||||||
|
postMessage: jest.fn(),
|
||||||
|
close: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
global.BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
|
||||||
|
|
||||||
const renderComponent = (props) => render(
|
const renderComponent = (props) => render(
|
||||||
<AppProvider store={store}>
|
<AppProvider store={store}>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
@@ -62,7 +68,6 @@ const renderComponent = (props) => render(
|
|||||||
onOpenPublishModal={jest.fn()}
|
onOpenPublishModal={jest.fn()}
|
||||||
onOpenDeleteModal={jest.fn()}
|
onOpenDeleteModal={jest.fn()}
|
||||||
onOpenConfigureModal={jest.fn()}
|
onOpenConfigureModal={jest.fn()}
|
||||||
onCopyToClipboardClick={jest.fn()}
|
|
||||||
savingStatus=""
|
savingStatus=""
|
||||||
onEditSubmit={jest.fn()}
|
onEditSubmit={jest.fn()}
|
||||||
onDuplicateSubmit={jest.fn()}
|
onDuplicateSubmit={jest.fn()}
|
||||||
|
|||||||
@@ -185,7 +185,9 @@ const CourseUnit = ({ courseId }) => {
|
|||||||
{showPasteXBlock && canPasteComponent && (
|
{showPasteXBlock && canPasteComponent && (
|
||||||
<PasteComponent
|
<PasteComponent
|
||||||
clipboardData={sharedClipboardData}
|
clipboardData={sharedClipboardData}
|
||||||
onClick={handleCreateNewCourseXBlock}
|
onClick={
|
||||||
|
() => handleCreateNewCourseXBlock({ stagedContent: 'clipboard', parentLocator: blockId })
|
||||||
|
}
|
||||||
text={intl.formatMessage(messages.pasteButtonText)}
|
text={intl.formatMessage(messages.pasteButtonText)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import MockAdapter from 'axios-mock-adapter';
|
import MockAdapter from 'axios-mock-adapter';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
act, render, waitFor, fireEvent, within, screen,
|
act, render, waitFor, fireEvent, within, screen,
|
||||||
} from '@testing-library/react';
|
} from '@testing-library/react';
|
||||||
@@ -38,10 +39,7 @@ import {
|
|||||||
courseVerticalChildrenMock,
|
courseVerticalChildrenMock,
|
||||||
clipboardMockResponse,
|
clipboardMockResponse,
|
||||||
} from './__mocks__';
|
} from './__mocks__';
|
||||||
import {
|
import { clipboardUnit, clipboardXBlock } from '../__mocks__';
|
||||||
clipboardUnit,
|
|
||||||
clipboardXBlock,
|
|
||||||
} from '../__mocks__';
|
|
||||||
import { executeThunk } from '../utils';
|
import { executeThunk } from '../utils';
|
||||||
import deleteModalMessages from '../generic/delete-modal/messages';
|
import deleteModalMessages from '../generic/delete-modal/messages';
|
||||||
import pasteComponentMessages from '../generic/clipboard/paste-component/messages';
|
import pasteComponentMessages from '../generic/clipboard/paste-component/messages';
|
||||||
@@ -54,6 +52,7 @@ import { extractCourseUnitId } from './sidebar/utils';
|
|||||||
import CourseUnit from './CourseUnit';
|
import CourseUnit from './CourseUnit';
|
||||||
|
|
||||||
import configureModalMessages from '../generic/configure-modal/messages';
|
import configureModalMessages from '../generic/configure-modal/messages';
|
||||||
|
import { getClipboardUrl } from '../generic/data/api';
|
||||||
import courseXBlockMessages from './course-xblock/messages';
|
import courseXBlockMessages from './course-xblock/messages';
|
||||||
import addComponentMessages from './add-component/messages';
|
import addComponentMessages from './add-component/messages';
|
||||||
import { PUBLISH_TYPES, UNIT_VISIBILITY_STATES } from './constants';
|
import { PUBLISH_TYPES, UNIT_VISIBILITY_STATES } from './constants';
|
||||||
@@ -63,6 +62,7 @@ import { RequestStatus } from '../data/constants';
|
|||||||
|
|
||||||
let axiosMock;
|
let axiosMock;
|
||||||
let store;
|
let store;
|
||||||
|
let queryClient;
|
||||||
const courseId = '123';
|
const courseId = '123';
|
||||||
const blockId = '567890';
|
const blockId = '567890';
|
||||||
const unitDisplayName = courseUnitIndexMock.metadata.display_name;
|
const unitDisplayName = courseUnitIndexMock.metadata.display_name;
|
||||||
@@ -80,31 +80,6 @@ jest.mock('react-router-dom', () => ({
|
|||||||
useNavigate: () => mockedUsedNavigate,
|
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 = {
|
const clipboardBroadcastChannelMock = {
|
||||||
postMessage: jest.fn(),
|
postMessage: jest.fn(),
|
||||||
close: jest.fn(),
|
close: jest.fn(),
|
||||||
@@ -115,7 +90,9 @@ global.BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
|
|||||||
const RootWrapper = () => (
|
const RootWrapper = () => (
|
||||||
<AppProvider store={store}>
|
<AppProvider store={store}>
|
||||||
<IntlProvider locale="en">
|
<IntlProvider locale="en">
|
||||||
<CourseUnit courseId={courseId} />
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<CourseUnit courseId={courseId} />
|
||||||
|
</QueryClientProvider>
|
||||||
</IntlProvider>
|
</IntlProvider>
|
||||||
</AppProvider>
|
</AppProvider>
|
||||||
);
|
);
|
||||||
@@ -132,7 +109,17 @@ describe('<CourseUnit />', () => {
|
|||||||
});
|
});
|
||||||
global.localStorage.clear();
|
global.localStorage.clear();
|
||||||
store = initializeStore();
|
store = initializeStore();
|
||||||
|
queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||||
|
axiosMock
|
||||||
|
.onGet(getClipboardUrl())
|
||||||
|
.reply(200, clipboardUnit);
|
||||||
axiosMock
|
axiosMock
|
||||||
.onGet(getCourseUnitApiUrl(courseId))
|
.onGet(getCourseUnitApiUrl(courseId))
|
||||||
.reply(200, courseUnitIndexMock);
|
.reply(200, courseUnitIndexMock);
|
||||||
@@ -147,7 +134,7 @@ describe('<CourseUnit />', () => {
|
|||||||
await executeThunk(fetchCourseVerticalChildrenData(blockId), store.dispatch);
|
await executeThunk(fetchCourseVerticalChildrenData(blockId), store.dispatch);
|
||||||
axiosMock
|
axiosMock
|
||||||
.onGet(getContentTaxonomyTagsApiUrl(blockId))
|
.onGet(getContentTaxonomyTagsApiUrl(blockId))
|
||||||
.reply(200, {});
|
.reply(200, { taxonomies: [] });
|
||||||
axiosMock
|
axiosMock
|
||||||
.onGet(getContentTaxonomyTagsCountApiUrl(blockId))
|
.onGet(getContentTaxonomyTagsCountApiUrl(blockId))
|
||||||
.reply(200, 17);
|
.reply(200, 17);
|
||||||
@@ -1087,19 +1074,16 @@ describe('<CourseUnit />', () => {
|
|||||||
queryByTestId, getByRole, getAllByLabelText, getByText,
|
queryByTestId, getByRole, getAllByLabelText, getByText,
|
||||||
} = render(<RootWrapper />);
|
} = render(<RootWrapper />);
|
||||||
|
|
||||||
|
axiosMock
|
||||||
|
.onGet(getClipboardUrl())
|
||||||
|
.reply(200, clipboardXBlock);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
const [xblockActionBtn] = getAllByLabelText(courseXBlockMessages.blockActionsDropdownAlt.defaultMessage);
|
const [xblockActionBtn] = getAllByLabelText(courseXBlockMessages.blockActionsDropdownAlt.defaultMessage);
|
||||||
userEvent.click(xblockActionBtn);
|
userEvent.click(xblockActionBtn);
|
||||||
userEvent.click(getByRole('button', { name: courseXBlockMessages.blockLabelButtonCopyToClipboard.defaultMessage }));
|
userEvent.click(getByRole('button', { name: courseXBlockMessages.blockLabelButtonCopyToClipboard.defaultMessage }));
|
||||||
});
|
});
|
||||||
|
|
||||||
axiosMock
|
|
||||||
.onGet(getCourseSectionVerticalApiUrl(blockId))
|
|
||||||
.reply(200, {
|
|
||||||
...courseSectionVerticalMock,
|
|
||||||
user_clipboard: clipboardXBlock,
|
|
||||||
});
|
|
||||||
|
|
||||||
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
|
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
|
||||||
expect(getByRole('button', { name: messages.pasteButtonText.defaultMessage })).toBeInTheDocument();
|
expect(getByRole('button', { name: messages.pasteButtonText.defaultMessage })).toBeInTheDocument();
|
||||||
|
|
||||||
@@ -1141,11 +1125,8 @@ describe('<CourseUnit />', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
axiosMock
|
axiosMock
|
||||||
.onGet(getCourseSectionVerticalApiUrl(blockId))
|
.onGet(getClipboardUrl())
|
||||||
.reply(200, {
|
.reply(200, clipboardXBlock);
|
||||||
...courseSectionVerticalMock,
|
|
||||||
user_clipboard: clipboardXBlock,
|
|
||||||
});
|
|
||||||
|
|
||||||
axiosMock
|
axiosMock
|
||||||
.onGet(getCourseUnitApiUrl(courseId))
|
.onGet(getCourseUnitApiUrl(courseId))
|
||||||
@@ -1197,11 +1178,8 @@ describe('<CourseUnit />', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
axiosMock
|
axiosMock
|
||||||
.onGet(getCourseSectionVerticalApiUrl(blockId))
|
.onGet(getClipboardUrl())
|
||||||
.reply(200, {
|
.reply(200, clipboardXBlock);
|
||||||
...courseSectionVerticalMock,
|
|
||||||
user_clipboard: clipboardXBlock,
|
|
||||||
});
|
|
||||||
|
|
||||||
axiosMock
|
axiosMock
|
||||||
.onGet(getCourseUnitApiUrl(courseId))
|
.onGet(getCourseUnitApiUrl(courseId))
|
||||||
@@ -1228,13 +1206,6 @@ describe('<CourseUnit />', () => {
|
|||||||
enable_copy_paste_units: true,
|
enable_copy_paste_units: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
axiosMock
|
|
||||||
.onGet(getCourseSectionVerticalApiUrl(blockId))
|
|
||||||
.reply(200, {
|
|
||||||
...courseSectionVerticalMock,
|
|
||||||
user_clipboard: clipboardUnit,
|
|
||||||
});
|
|
||||||
|
|
||||||
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
|
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
|
||||||
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
|
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
|
||||||
|
|
||||||
@@ -1288,13 +1259,6 @@ describe('<CourseUnit />', () => {
|
|||||||
enable_copy_paste_units: true,
|
enable_copy_paste_units: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
axiosMock
|
|
||||||
.onGet(getCourseSectionVerticalApiUrl(blockId))
|
|
||||||
.reply(200, {
|
|
||||||
...courseSectionVerticalMock,
|
|
||||||
user_clipboard: clipboardUnit,
|
|
||||||
});
|
|
||||||
|
|
||||||
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
|
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
|
||||||
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
|
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
|
||||||
|
|
||||||
@@ -1349,13 +1313,6 @@ describe('<CourseUnit />', () => {
|
|||||||
enable_copy_paste_units: true,
|
enable_copy_paste_units: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
axiosMock
|
|
||||||
.onGet(getCourseSectionVerticalApiUrl(blockId))
|
|
||||||
.reply(200, {
|
|
||||||
...courseSectionVerticalMock,
|
|
||||||
user_clipboard: clipboardUnit,
|
|
||||||
});
|
|
||||||
|
|
||||||
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
|
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
|
||||||
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
|
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
|
||||||
|
|
||||||
@@ -1410,13 +1367,6 @@ describe('<CourseUnit />', () => {
|
|||||||
enable_copy_paste_units: true,
|
enable_copy_paste_units: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
axiosMock
|
|
||||||
.onGet(getCourseSectionVerticalApiUrl(blockId))
|
|
||||||
.reply(200, {
|
|
||||||
...courseSectionVerticalMock,
|
|
||||||
user_clipboard: clipboardUnit,
|
|
||||||
});
|
|
||||||
|
|
||||||
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
|
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
|
||||||
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
|
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import {
|
import {
|
||||||
ActionRow, Card, Dropdown, Icon, IconButton, useToggle,
|
ActionRow, Card, Dropdown, Icon, IconButton, useToggle,
|
||||||
} from '@openedx/paragon';
|
} from '@openedx/paragon';
|
||||||
@@ -15,7 +15,7 @@ import ConfigureModal from '../../generic/configure-modal/ConfigureModal';
|
|||||||
import SortableItem from '../../generic/drag-helper/SortableItem';
|
import SortableItem from '../../generic/drag-helper/SortableItem';
|
||||||
import { scrollToElement } from '../../course-outline/utils';
|
import { scrollToElement } from '../../course-outline/utils';
|
||||||
import { COURSE_BLOCK_NAMES } from '../../constants';
|
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 { COMPONENT_TYPES } from '../../generic/block-type-utils/constants';
|
||||||
import XBlockMessages from './xblock-messages/XBlockMessages';
|
import XBlockMessages from './xblock-messages/XBlockMessages';
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
@@ -28,7 +28,6 @@ const CourseXBlock = ({
|
|||||||
const courseXBlockElementRef = useRef(null);
|
const courseXBlockElementRef = useRef(null);
|
||||||
const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggle(false);
|
const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggle(false);
|
||||||
const [isConfigureModalOpen, openConfigureModal, closeConfigureModal] = useToggle(false);
|
const [isConfigureModalOpen, openConfigureModal, closeConfigureModal] = useToggle(false);
|
||||||
const dispatch = useDispatch();
|
|
||||||
const canEdit = useSelector(getCanEdit);
|
const canEdit = useSelector(getCanEdit);
|
||||||
const courseId = useSelector(getCourseId);
|
const courseId = useSelector(getCourseId);
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
@@ -48,6 +47,8 @@ const CourseXBlock = ({
|
|||||||
showCorrectness: 'always',
|
showCorrectness: 'always',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const { copyToClipboard } = useClipboard(canEdit);
|
||||||
|
|
||||||
const onDeleteSubmit = () => {
|
const onDeleteSubmit = () => {
|
||||||
unitXBlockActions.handleDelete(id);
|
unitXBlockActions.handleDelete(id);
|
||||||
closeDeleteModal();
|
closeDeleteModal();
|
||||||
@@ -120,7 +121,7 @@ const CourseXBlock = ({
|
|||||||
{intl.formatMessage(messages.blockLabelButtonMove)}
|
{intl.formatMessage(messages.blockLabelButtonMove)}
|
||||||
</Dropdown.Item>
|
</Dropdown.Item>
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<Dropdown.Item onClick={() => dispatch(copyToClipboard(id))}>
|
<Dropdown.Item onClick={() => copyToClipboard(id)}>
|
||||||
{intl.formatMessage(messages.blockLabelButtonCopyToClipboard)}
|
{intl.formatMessage(messages.blockLabelButtonCopyToClipboard)}
|
||||||
</Dropdown.Item>
|
</Dropdown.Item>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
render, waitFor, within,
|
render, waitFor, within,
|
||||||
} from '@testing-library/react';
|
} from '@testing-library/react';
|
||||||
@@ -24,6 +25,15 @@ import messages from './messages';
|
|||||||
|
|
||||||
let axiosMock;
|
let axiosMock;
|
||||||
let store;
|
let store;
|
||||||
|
let queryClient;
|
||||||
|
|
||||||
|
const clipboardBroadcastChannelMock = {
|
||||||
|
postMessage: jest.fn(),
|
||||||
|
close: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
global.BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
|
||||||
|
|
||||||
const courseId = '1234';
|
const courseId = '1234';
|
||||||
const blockId = '567890';
|
const blockId = '567890';
|
||||||
const handleDeleteMock = jest.fn();
|
const handleDeleteMock = jest.fn();
|
||||||
@@ -49,17 +59,19 @@ jest.mock('react-redux', () => ({
|
|||||||
const renderComponent = (props) => render(
|
const renderComponent = (props) => render(
|
||||||
<AppProvider store={store}>
|
<AppProvider store={store}>
|
||||||
<IntlProvider locale="en">
|
<IntlProvider locale="en">
|
||||||
<CourseXBlock
|
<QueryClientProvider client={queryClient}>
|
||||||
id={id}
|
<CourseXBlock
|
||||||
title={name}
|
id={id}
|
||||||
type={type}
|
title={name}
|
||||||
blockId={blockId}
|
type={type}
|
||||||
unitXBlockActions={unitXBlockActionsMock}
|
blockId={blockId}
|
||||||
userPartitionInfo={userPartitionInfoFormatted}
|
unitXBlockActions={unitXBlockActionsMock}
|
||||||
shouldScroll={false}
|
userPartitionInfo={userPartitionInfoFormatted}
|
||||||
handleConfigureSubmit={handleConfigureSubmitMock}
|
shouldScroll={false}
|
||||||
{...props}
|
handleConfigureSubmit={handleConfigureSubmitMock}
|
||||||
/>
|
{...props}
|
||||||
|
/>
|
||||||
|
</QueryClientProvider>
|
||||||
</IntlProvider>
|
</IntlProvider>
|
||||||
</AppProvider>,
|
</AppProvider>,
|
||||||
);
|
);
|
||||||
@@ -98,6 +110,7 @@ describe('<CourseXBlock />', () => {
|
|||||||
.onGet(getCourseSectionVerticalApiUrl(blockId))
|
.onGet(getCourseSectionVerticalApiUrl(blockId))
|
||||||
.reply(200, courseSectionVerticalMock);
|
.reply(200, courseSectionVerticalMock);
|
||||||
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
|
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
|
||||||
|
queryClient = new QueryClient();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('render CourseXBlock component correctly', async () => {
|
it('render CourseXBlock component correctly', async () => {
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { handleResponseErrors } from '../../generic/saving-error-alert';
|
|||||||
import { RequestStatus } from '../../data/constants';
|
import { RequestStatus } from '../../data/constants';
|
||||||
import { NOTIFICATION_MESSAGES } from '../../constants';
|
import { NOTIFICATION_MESSAGES } from '../../constants';
|
||||||
import { updateModel, updateModels } from '../../generic/model-store';
|
import { updateModel, updateModels } from '../../generic/model-store';
|
||||||
import { updateClipboardData } from '../../generic/data/slice';
|
|
||||||
import {
|
import {
|
||||||
getCourseUnitData,
|
getCourseUnitData,
|
||||||
editUnitDisplayName,
|
editUnitDisplayName,
|
||||||
@@ -75,7 +74,6 @@ export function fetchCourseSectionVerticalData(courseId, sequenceId) {
|
|||||||
}));
|
}));
|
||||||
dispatch(fetchStaticFileNoticesSuccess(JSON.parse(localStorage.getItem('staticFileNotices'))));
|
dispatch(fetchStaticFileNoticesSuccess(JSON.parse(localStorage.getItem('staticFileNotices'))));
|
||||||
localStorage.removeItem('staticFileNotices');
|
localStorage.removeItem('staticFileNotices');
|
||||||
dispatch(updateClipboardData(courseSectionVerticalData.userClipboard));
|
|
||||||
dispatch(fetchSequenceSuccess({ sequenceId }));
|
dispatch(fetchSequenceSuccess({ sequenceId }));
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -214,8 +212,6 @@ export function deleteUnitItemQuery(itemId, xblockId) {
|
|||||||
try {
|
try {
|
||||||
await deleteUnitItem(xblockId);
|
await deleteUnitItem(xblockId);
|
||||||
dispatch(deleteXBlock(xblockId));
|
dispatch(deleteXBlock(xblockId));
|
||||||
const { userClipboard } = await getCourseSectionVerticalData(itemId);
|
|
||||||
dispatch(updateClipboardData(userClipboard));
|
|
||||||
const courseUnit = await getCourseUnitData(itemId);
|
const courseUnit = await getCourseUnitData(itemId);
|
||||||
dispatch(fetchCourseItemSuccess(courseUnit));
|
dispatch(fetchCourseItemSuccess(courseUnit));
|
||||||
dispatch(hideProcessingNotification());
|
dispatch(hideProcessingNotification());
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useDispatch, useSelector } from 'react-redux';
|
|||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
|
||||||
import { RequestStatus } from '../data/constants';
|
import { RequestStatus } from '../data/constants';
|
||||||
|
import { useClipboard } from '../generic/clipboard';
|
||||||
import {
|
import {
|
||||||
createNewCourseXBlock,
|
createNewCourseXBlock,
|
||||||
fetchCourseUnitQuery,
|
fetchCourseUnitQuery,
|
||||||
@@ -28,8 +29,6 @@ import {
|
|||||||
import { changeEditTitleFormOpen, updateQueryPendingStatus } from './data/slice';
|
import { changeEditTitleFormOpen, updateQueryPendingStatus } from './data/slice';
|
||||||
import { PUBLISH_TYPES } from './constants';
|
import { PUBLISH_TYPES } from './constants';
|
||||||
|
|
||||||
import { useCopyToClipboard } from '../generic/clipboard';
|
|
||||||
|
|
||||||
// eslint-disable-next-line import/prefer-default-export
|
// eslint-disable-next-line import/prefer-default-export
|
||||||
export const useCourseUnit = ({ courseId, blockId }) => {
|
export const useCourseUnit = ({ courseId, blockId }) => {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
@@ -47,7 +46,7 @@ export const useCourseUnit = ({ courseId, blockId }) => {
|
|||||||
const isTitleEditFormOpen = useSelector(state => state.courseUnit.isTitleEditFormOpen);
|
const isTitleEditFormOpen = useSelector(state => state.courseUnit.isTitleEditFormOpen);
|
||||||
const canEdit = useSelector(getCanEdit);
|
const canEdit = useSelector(getCanEdit);
|
||||||
const { currentlyVisibleToStudents } = courseUnit;
|
const { currentlyVisibleToStudents } = courseUnit;
|
||||||
const { sharedClipboardData, showPasteXBlock, showPasteUnit } = useCopyToClipboard(canEdit);
|
const { sharedClipboardData, showPasteXBlock, showPasteUnit } = useClipboard(canEdit);
|
||||||
const { canPasteComponent } = courseVerticalChildren;
|
const { canPasteComponent } = courseVerticalChildren;
|
||||||
|
|
||||||
const unitTitle = courseUnit.metadata?.displayName || '';
|
const unitTitle = courseUnit.metadata?.displayName || '';
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import { Button } from '@openedx/paragon';
|
import { Button } from '@openedx/paragon';
|
||||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
import { Divider } from '../../../../generic/divider';
|
import { Divider } from '../../../../generic/divider';
|
||||||
import { getCanEdit, getCourseUnitData } from '../../../data/selectors';
|
import { getCanEdit, getCourseUnitData } from '../../../data/selectors';
|
||||||
import { copyToClipboard } from '../../../../generic/data/thunks';
|
import { useClipboard } from '../../../../generic/clipboard';
|
||||||
import messages from '../../messages';
|
import messages from '../../messages';
|
||||||
|
|
||||||
const ActionButtons = ({ openDiscardModal, handlePublishing }) => {
|
const ActionButtons = ({ openDiscardModal, handlePublishing }) => {
|
||||||
const dispatch = useDispatch();
|
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const {
|
const {
|
||||||
id,
|
id,
|
||||||
@@ -18,6 +17,7 @@ const ActionButtons = ({ openDiscardModal, handlePublishing }) => {
|
|||||||
enableCopyPasteUnits,
|
enableCopyPasteUnits,
|
||||||
} = useSelector(getCourseUnitData);
|
} = useSelector(getCourseUnitData);
|
||||||
const canEdit = useSelector(getCanEdit);
|
const canEdit = useSelector(getCanEdit);
|
||||||
|
const { copyToClipboard } = useClipboard();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -40,7 +40,7 @@ const ActionButtons = ({ openDiscardModal, handlePublishing }) => {
|
|||||||
<>
|
<>
|
||||||
<Divider className="course-unit-sidebar-footer__divider" />
|
<Divider className="course-unit-sidebar-footer__divider" />
|
||||||
<Button
|
<Button
|
||||||
onClick={() => dispatch(copyToClipboard(id))}
|
onClick={() => copyToClipboard(id)}
|
||||||
variant="outline-primary"
|
variant="outline-primary"
|
||||||
size="sm"
|
size="sm"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { QueryClientProvider, QueryClient } from '@tanstack/react-query';
|
||||||
import { render } from '@testing-library/react';
|
import { render } from '@testing-library/react';
|
||||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||||
import { AppProvider } from '@edx/frontend-platform/react';
|
import { AppProvider } from '@edx/frontend-platform/react';
|
||||||
@@ -12,24 +13,28 @@ import { clipboardUnit } from '../../../../__mocks__';
|
|||||||
import { getCourseUnitApiUrl } from '../../../data/api';
|
import { getCourseUnitApiUrl } from '../../../data/api';
|
||||||
import { getClipboardUrl } from '../../../../generic/data/api';
|
import { getClipboardUrl } from '../../../../generic/data/api';
|
||||||
import { fetchCourseUnitQuery } from '../../../data/thunk';
|
import { fetchCourseUnitQuery } from '../../../data/thunk';
|
||||||
import { copyToClipboard } from '../../../../generic/data/thunks';
|
|
||||||
import { courseUnitIndexMock } from '../../../__mocks__';
|
import { courseUnitIndexMock } from '../../../__mocks__';
|
||||||
import messages from '../../messages';
|
import messages from '../../messages';
|
||||||
import ActionButtons from './ActionButtons';
|
import ActionButtons from './ActionButtons';
|
||||||
|
|
||||||
jest.mock('../../../../generic/data/thunks', () => ({
|
|
||||||
...jest.requireActual('../../../../generic/data/thunks'),
|
|
||||||
copyToClipboard: jest.fn().mockImplementation(() => () => {}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
let store;
|
let store;
|
||||||
let axiosMock;
|
let axiosMock;
|
||||||
|
let queryClient;
|
||||||
const courseId = '123';
|
const courseId = '123';
|
||||||
|
|
||||||
|
const clipboardBroadcastChannelMock = {
|
||||||
|
postMessage: jest.fn(),
|
||||||
|
close: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
global.BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
|
||||||
|
|
||||||
const renderComponent = (props = {}) => render(
|
const renderComponent = (props = {}) => render(
|
||||||
<AppProvider store={store}>
|
<AppProvider store={store}>
|
||||||
<IntlProvider locale="en">
|
<IntlProvider locale="en">
|
||||||
<ActionButtons {...props} />
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<ActionButtons {...props} />
|
||||||
|
</QueryClientProvider>
|
||||||
</IntlProvider>
|
</IntlProvider>
|
||||||
</AppProvider>,
|
</AppProvider>,
|
||||||
);
|
);
|
||||||
@@ -57,6 +62,8 @@ describe('<ActionButtons />', () => {
|
|||||||
.onGet(getClipboardUrl())
|
.onGet(getClipboardUrl())
|
||||||
.reply(200, clipboardUnit);
|
.reply(200, clipboardUnit);
|
||||||
|
|
||||||
|
queryClient = new QueryClient();
|
||||||
|
|
||||||
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
|
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -73,8 +80,8 @@ describe('<ActionButtons />', () => {
|
|||||||
const copyXBlockBtn = getByRole('button', { name: messages.actionButtonCopyUnitTitle.defaultMessage });
|
const copyXBlockBtn = getByRole('button', { name: messages.actionButtonCopyUnitTitle.defaultMessage });
|
||||||
|
|
||||||
userEvent.click(copyXBlockBtn);
|
userEvent.click(copyXBlockBtn);
|
||||||
|
expect(axiosMock.history.post.length).toBe(1);
|
||||||
expect(copyToClipboard).toHaveBeenCalledWith(courseUnitIndexMock.id);
|
expect(axiosMock.history.post[0].data).toBe(JSON.stringify({ usage_key: courseUnitIndexMock.id }));
|
||||||
jest.resetAllMocks();
|
jest.resetAllMocks();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ exports[`VideoSourceWidget snapshots snapshots: renders as expected with default
|
|||||||
</div>
|
</div>
|
||||||
<Form.Row
|
<Form.Row
|
||||||
className="mt-3.5 mx-0 flex-nowrap"
|
className="mt-3.5 mx-0 flex-nowrap"
|
||||||
|
key="somEUrL"
|
||||||
>
|
>
|
||||||
<Form.Group>
|
<Form.Group>
|
||||||
<Form.Control
|
<Form.Control
|
||||||
@@ -86,7 +87,6 @@ exports[`VideoSourceWidget snapshots snapshots: renders as expected with default
|
|||||||
<IconButtonWithTooltip
|
<IconButtonWithTooltip
|
||||||
alt="Delete"
|
alt="Delete"
|
||||||
iconAs="Icon"
|
iconAs="Icon"
|
||||||
key="top-delete-somEUrL"
|
|
||||||
onClick={[Function]}
|
onClick={[Function]}
|
||||||
tooltipContent="Delete"
|
tooltipContent="Delete"
|
||||||
tooltipPlacement="top"
|
tooltipPlacement="top"
|
||||||
@@ -237,6 +237,7 @@ exports[`VideoSourceWidget snapshots snapshots: renders as expected with videoSh
|
|||||||
</div>
|
</div>
|
||||||
<Form.Row
|
<Form.Row
|
||||||
className="mt-3.5 mx-0 flex-nowrap"
|
className="mt-3.5 mx-0 flex-nowrap"
|
||||||
|
key="somEUrL"
|
||||||
>
|
>
|
||||||
<Form.Group>
|
<Form.Group>
|
||||||
<Form.Control
|
<Form.Control
|
||||||
@@ -245,7 +246,6 @@ exports[`VideoSourceWidget snapshots snapshots: renders as expected with videoSh
|
|||||||
<IconButtonWithTooltip
|
<IconButtonWithTooltip
|
||||||
alt="Delete"
|
alt="Delete"
|
||||||
iconAs="Icon"
|
iconAs="Icon"
|
||||||
key="top-delete-somEUrL"
|
|
||||||
onClick={[Function]}
|
onClick={[Function]}
|
||||||
tooltipContent="Delete"
|
tooltipContent="Delete"
|
||||||
tooltipPlacement="top"
|
tooltipPlacement="top"
|
||||||
|
|||||||
@@ -39,16 +39,17 @@ export const sourceHooks = ({ dispatch, previousVideoId, setAlert }) => ({
|
|||||||
|
|
||||||
export const fallbackHooks = ({ fallbackVideos, dispatch }) => ({
|
export const fallbackHooks = ({ fallbackVideos, dispatch }) => ({
|
||||||
addFallbackVideo: () => dispatch(actions.video.updateField({ fallbackVideos: [...fallbackVideos, ''] })),
|
addFallbackVideo: () => dispatch(actions.video.updateField({ fallbackVideos: [...fallbackVideos, ''] })),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deletes the first occurrence of the given videoUrl from the fallbackVideos list
|
* Deletes the first occurrence of the given videoUrl from the fallbackVideos list
|
||||||
* @param {string} videoUrl - the video URL to delete
|
* @param {string} videoUrl - the video URL to delete
|
||||||
*/
|
*/
|
||||||
deleteFallbackVideo: (videoUrl) => {
|
deleteFallbackVideo: (videoIndex) => {
|
||||||
const index = fallbackVideos.findIndex(video => video === videoUrl);
|
|
||||||
const updatedFallbackVideos = [
|
const updatedFallbackVideos = [
|
||||||
...fallbackVideos.slice(0, index),
|
...fallbackVideos.slice(0, videoIndex),
|
||||||
...fallbackVideos.slice(index + 1),
|
...fallbackVideos.slice(videoIndex + 1),
|
||||||
];
|
];
|
||||||
|
|
||||||
dispatch(actions.video.updateField({ fallbackVideos: updatedFallbackVideos }));
|
dispatch(actions.video.updateField({ fallbackVideos: updatedFallbackVideos }));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ const VideoSourceWidget = ({
|
|||||||
<FormattedMessage {...messages.fallbackVideoMessage} />
|
<FormattedMessage {...messages.fallbackVideoMessage} />
|
||||||
</div>
|
</div>
|
||||||
{fallbackVideos.formValue.length > 0 ? fallbackVideos.formValue.map((videoUrl, index) => (
|
{fallbackVideos.formValue.length > 0 ? fallbackVideos.formValue.map((videoUrl, index) => (
|
||||||
<Form.Row className="mt-3.5 mx-0 flex-nowrap">
|
<Form.Row className="mt-3.5 mx-0 flex-nowrap" key={videoUrl}>
|
||||||
<Form.Group>
|
<Form.Group>
|
||||||
<Form.Control
|
<Form.Control
|
||||||
floatingLabel={intl.formatMessage(messages.fallbackVideoLabel)}
|
floatingLabel={intl.formatMessage(messages.fallbackVideoLabel)}
|
||||||
@@ -110,13 +110,12 @@ const VideoSourceWidget = ({
|
|||||||
onBlur={fallbackVideos.onBlur(index)}
|
onBlur={fallbackVideos.onBlur(index)}
|
||||||
/>
|
/>
|
||||||
<IconButtonWithTooltip
|
<IconButtonWithTooltip
|
||||||
key={`top-delete-${videoUrl}`}
|
|
||||||
tooltipPlacement="top"
|
tooltipPlacement="top"
|
||||||
tooltipContent={intl.formatMessage(messages.deleteFallbackVideo)}
|
tooltipContent={intl.formatMessage(messages.deleteFallbackVideo)}
|
||||||
src={DeleteOutline}
|
src={DeleteOutline}
|
||||||
iconAs={Icon}
|
iconAs={Icon}
|
||||||
alt={intl.formatMessage(messages.deleteFallbackVideo)}
|
alt={intl.formatMessage(messages.deleteFallbackVideo)}
|
||||||
onClick={() => deleteFallbackVideo(videoUrl)}
|
onClick={() => deleteFallbackVideo(index)}
|
||||||
/>
|
/>
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
</Form.Row>
|
</Form.Row>
|
||||||
|
|||||||
@@ -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 = ({
|
export const setupCustomBehavior = ({
|
||||||
updateContent,
|
updateContent,
|
||||||
openImgModal,
|
openImgModal,
|
||||||
@@ -221,30 +255,17 @@ export const setupCustomBehavior = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
editor.on('init', /* istanbul ignore next */ () => {
|
editor.on('init', /* istanbul ignore next */ () => {
|
||||||
// Moving TinyMce aux modal inside the Editor modal
|
// Check if this editor is inside a (Paragon) modal.
|
||||||
// if the editor is on modal mode.
|
// The way we get the editor's root <div> depends on whether or not this particular editor is using an iframe:
|
||||||
// This is to avoid issues using the aux modal:
|
const editorDiv = editor.bodyElement ?? editor.container;
|
||||||
// * Avoid close aux modal when clicking the content inside.
|
if (editorDiv?.closest('.pgn__modal')) {
|
||||||
// * When the user opens the `Edit Source Code` modal, this adds `data-focus-on-hidden`
|
// This editor is inside a Paragon modal. Use this hack to avoid interference with TinyMCE's own modal popups:
|
||||||
// to the TinyMce aux modal, making it unusable.
|
reparentTinyMceModals();
|
||||||
const modalLayer = document.querySelector('.pgn__modal-layer');
|
editor.on('selectionchange', reparentTinyMceModals);
|
||||||
const tinymceAux = document.querySelector('.tox.tox-tinymce-aux');
|
|
||||||
|
|
||||||
if (modalLayer && tinymceAux) {
|
|
||||||
modalLayer.appendChild(tinymceAux);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
editor.on('ExecCommand', /* istanbul ignore next */ (e) => {
|
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') {
|
if (editorType === 'text' && e.command === 'mceFocus') {
|
||||||
const initialContent = editor.getContent();
|
const initialContent = editor.getContent();
|
||||||
const newContent = module.replaceStaticWithAsset({
|
const newContent = module.replaceStaticWithAsset({
|
||||||
|
|||||||
@@ -64,16 +64,9 @@ const pluginConfig = ({ isLibrary, placeholder, editorType }) => {
|
|||||||
[editImageSettings],
|
[editImageSettings],
|
||||||
]),
|
]),
|
||||||
quickbarsInsertToolbar: toolbar ? false : mapToolbars([
|
quickbarsInsertToolbar: toolbar ? false : mapToolbars([
|
||||||
[buttons.undo, buttons.redo],
|
// To keep from blocking the whole text input field when it's empty, this "insert" toolbar
|
||||||
[buttons.formatSelect],
|
// used with ExpandableTextArea is kept as minimal as we can.
|
||||||
[buttons.bold, buttons.italic, buttons.underline, buttons.foreColor],
|
[imageUploadButton, buttons.table],
|
||||||
[
|
|
||||||
buttons.align.justify,
|
|
||||||
buttons.bullist,
|
|
||||||
buttons.numlist,
|
|
||||||
],
|
|
||||||
[imageUploadButton, buttons.blockQuote, buttons.codeBlock],
|
|
||||||
[buttons.table, buttons.emoticons, buttons.charmap, buttons.removeFormat, buttons.a11ycheck],
|
|
||||||
]),
|
]),
|
||||||
quickbarsSelectionToolbar: toolbar ? false : mapToolbars([
|
quickbarsSelectionToolbar: toolbar ? false : mapToolbars([
|
||||||
[buttons.undo, buttons.redo],
|
[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';
|
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 { Link } from 'react-router-dom';
|
||||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import { Icon, Popover, Stack } from '@openedx/paragon';
|
import { Icon, Popover, Stack } from '@openedx/paragon';
|
||||||
import { OpenInNew as OpenInNewIcon } from '@openedx/paragon/icons';
|
import { OpenInNew as OpenInNewIcon } from '@openedx/paragon/icons';
|
||||||
|
|
||||||
|
import type { ClipboardStatus } from '../../../data/api';
|
||||||
import messages from '../messages';
|
import messages from '../messages';
|
||||||
import { clipboardPropsTypes } from '../constants';
|
|
||||||
|
|
||||||
const PopoverContent = ({ clipboardData }) => {
|
interface PopoverContentProps {
|
||||||
|
clipboardData: ClipboardStatus,
|
||||||
|
}
|
||||||
|
|
||||||
|
const PopoverContent = ({ clipboardData } : PopoverContentProps) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const { sourceEditUrl, content, sourceContextTitle } = clipboardData;
|
const { sourceEditUrl, content, sourceContextTitle } = clipboardData;
|
||||||
|
|
||||||
|
// istanbul ignore if: this should never happen
|
||||||
|
if (!content) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover.Title
|
<Popover.Title
|
||||||
className="clipboard-popover-title"
|
className="clipboard-popover-title"
|
||||||
@@ -40,8 +48,4 @@ const PopoverContent = ({ clipboardData }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
PopoverContent.propTypes = {
|
|
||||||
clipboardData: PropTypes.shape(clipboardPropsTypes).isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PopoverContent;
|
export default PopoverContent;
|
||||||
@@ -1,14 +1,19 @@
|
|||||||
import { useRef } from 'react';
|
import { useRef } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import { Icon } from '@openedx/paragon';
|
import { Icon } from '@openedx/paragon';
|
||||||
import { Question as QuestionIcon } from '@openedx/paragon/icons';
|
import { Question as QuestionIcon } from '@openedx/paragon/icons';
|
||||||
|
|
||||||
import messages from '../messages';
|
import messages from '../messages';
|
||||||
|
|
||||||
|
interface WhatsInClipboardProps {
|
||||||
|
handlePopoverToggle: (show: boolean) => void;
|
||||||
|
togglePopover: (show: boolean) => void;
|
||||||
|
popoverElementRef: React.RefObject<HTMLDivElement>;
|
||||||
|
}
|
||||||
|
|
||||||
const WhatsInClipboard = ({
|
const WhatsInClipboard = ({
|
||||||
handlePopoverToggle, togglePopover, popoverElementRef,
|
handlePopoverToggle, togglePopover, popoverElementRef,
|
||||||
}) => {
|
}: WhatsInClipboardProps) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const triggerElementRef = useRef(null);
|
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;
|
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 { useRef, useState } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import { OverlayTrigger, Popover } from '@openedx/paragon';
|
import { OverlayTrigger, Popover } from '@openedx/paragon';
|
||||||
|
|
||||||
import { PopoverContent, PasteButton, WhatsInClipboard } from './components';
|
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 = ({
|
const PasteComponent = ({
|
||||||
onClick, clipboardData, text, className,
|
onClick, clipboardData, text, className,
|
||||||
}) => {
|
}: PasteComponentProps) => {
|
||||||
const [showPopover, togglePopover] = useState(false);
|
const [showPopover, togglePopover] = useState(false);
|
||||||
const popoverElementRef = useRef(null);
|
const popoverElementRef = useRef(null);
|
||||||
|
|
||||||
@@ -24,9 +30,7 @@ const PasteComponent = ({
|
|||||||
onBlur={() => handlePopoverToggle(false)}
|
onBlur={() => handlePopoverToggle(false)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{clipboardData && (
|
<PopoverContent clipboardData={clipboardData} />
|
||||||
<PopoverContent clipboardData={clipboardData} />
|
|
||||||
)}
|
|
||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
</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;
|
export default PasteComponent;
|
||||||
@@ -223,6 +223,7 @@ const ConfigureModal = ({
|
|||||||
category={category}
|
category={category}
|
||||||
isSubsection={isSubsection}
|
isSubsection={isSubsection}
|
||||||
showWarning={visibilityState === VisibilityTypes.STAFF_ONLY}
|
showWarning={visibilityState === VisibilityTypes.STAFF_ONLY}
|
||||||
|
isSelfPaced={isSelfPaced}
|
||||||
/>
|
/>
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab eventKey="advanced" title={intl.formatMessage(messages.advancedTabTitle)}>
|
<Tab eventKey="advanced" title={intl.formatMessage(messages.advancedTabTitle)}>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const VisibilityTab = ({
|
|||||||
category,
|
category,
|
||||||
showWarning,
|
showWarning,
|
||||||
isSubsection,
|
isSubsection,
|
||||||
|
isSelfPaced,
|
||||||
}) => {
|
}) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const visibilityTitle = COURSE_BLOCK_NAMES[category]?.name;
|
const visibilityTitle = COURSE_BLOCK_NAMES[category]?.name;
|
||||||
@@ -53,6 +54,13 @@ const VisibilityTab = ({
|
|||||||
setFieldValue('showCorrectness', e.target.value);
|
setFieldValue('showCorrectness', e.target.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const hideDueMessage = {
|
||||||
|
hideContentLabel: isSelfPaced ? messages.hideContentAfterEnd : messages.hideContentAfterDue,
|
||||||
|
hideContentDescription: (
|
||||||
|
isSelfPaced ? messages.hideContentAfterEndDescription : messages.hideContentAfterDueDescription
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h5 className="mt-4 text-gray-700">
|
<h5 className="mt-4 text-gray-700">
|
||||||
@@ -72,9 +80,10 @@ const VisibilityTab = ({
|
|||||||
</Form.Radio>
|
</Form.Radio>
|
||||||
<Form.Text><FormattedMessage {...messages.showEntireSubsectionDescription} /></Form.Text>
|
<Form.Text><FormattedMessage {...messages.showEntireSubsectionDescription} /></Form.Text>
|
||||||
<Form.Radio value="hideDue">
|
<Form.Radio value="hideDue">
|
||||||
<FormattedMessage {...messages.hideContentAfterDue} />
|
<FormattedMessage {...hideDueMessage.hideContentLabel} />
|
||||||
</Form.Radio>
|
</Form.Radio>
|
||||||
<Form.Text><FormattedMessage {...messages.hideContentAfterDueDescription} /></Form.Text>
|
<Form.Text><FormattedMessage {...hideDueMessage.hideContentDescription} />
|
||||||
|
</Form.Text>
|
||||||
<Form.Radio value="hide">
|
<Form.Radio value="hide">
|
||||||
<FormattedMessage {...messages.hideEntireSubsection} />
|
<FormattedMessage {...messages.hideEntireSubsection} />
|
||||||
</Form.Radio>
|
</Form.Radio>
|
||||||
@@ -130,6 +139,11 @@ VisibilityTab.propTypes = {
|
|||||||
category: PropTypes.string.isRequired,
|
category: PropTypes.string.isRequired,
|
||||||
showWarning: PropTypes.bool.isRequired,
|
showWarning: PropTypes.bool.isRequired,
|
||||||
isSubsection: PropTypes.bool.isRequired,
|
isSubsection: PropTypes.bool.isRequired,
|
||||||
|
isSelfPaced: PropTypes.bool,
|
||||||
|
};
|
||||||
|
|
||||||
|
VisibilityTab.defaultProps = {
|
||||||
|
isSelfPaced: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(VisibilityTab);
|
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',
|
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.',
|
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: {
|
hideEntireSubsection: {
|
||||||
id: 'course-authoring.course-outline.configure-modal.visibility-tab.hide-entire-subsection',
|
id: 'course-authoring.course-outline.configure-modal.visibility-tab.hide-entire-subsection',
|
||||||
defaultMessage: 'Hide entire subsection',
|
defaultMessage: 'Hide entire subsection',
|
||||||
|
|||||||
@@ -27,12 +27,12 @@ const useCreateOrRerunCourse = (initialValues) => {
|
|||||||
const allOrganizations = useSelector(getOrganizations);
|
const allOrganizations = useSelector(getOrganizations);
|
||||||
const postErrors = useSelector(getPostErrors);
|
const postErrors = useSelector(getPostErrors);
|
||||||
const {
|
const {
|
||||||
allowToCreateNewOrg,
|
canCreateOrganizations,
|
||||||
allowedOrganizations,
|
allowedOrganizations,
|
||||||
} = useSelector(getStudioHomeData);
|
} = useSelector(getStudioHomeData);
|
||||||
const [isFormFilled, setFormFilled] = useState(false);
|
const [isFormFilled, setFormFilled] = useState(false);
|
||||||
const [showErrorBanner, setShowErrorBanner] = useState(false);
|
const [showErrorBanner, setShowErrorBanner] = useState(false);
|
||||||
const organizations = allowToCreateNewOrg ? allOrganizations : allowedOrganizations;
|
const organizations = canCreateOrganizations ? allOrganizations : allowedOrganizations;
|
||||||
|
|
||||||
const { specialCharsRule, noSpaceRule } = REGEX_RULES;
|
const { specialCharsRule, noSpaceRule } = REGEX_RULES;
|
||||||
const validationSchema = Yup.object().shape({
|
const validationSchema = Yup.object().shape({
|
||||||
@@ -78,7 +78,7 @@ const useCreateOrRerunCourse = (initialValues) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (allowToCreateNewOrg) {
|
if (canCreateOrganizations) {
|
||||||
dispatch(fetchOrganizationsQuery());
|
dispatch(fetchOrganizationsQuery());
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -5,4 +5,3 @@ export const getCourseData = (state) => state.generic.createOrRerunCourse.course
|
|||||||
export const getCourseRerunData = (state) => state.generic.createOrRerunCourse.courseRerunData;
|
export const getCourseRerunData = (state) => state.generic.createOrRerunCourse.courseRerunData;
|
||||||
export const getRedirectUrlObj = (state) => state.generic.createOrRerunCourse.redirectUrlObj;
|
export const getRedirectUrlObj = (state) => state.generic.createOrRerunCourse.redirectUrlObj;
|
||||||
export const getPostErrors = (state) => state.generic.createOrRerunCourse.postErrors;
|
export const getPostErrors = (state) => state.generic.createOrRerunCourse.postErrors;
|
||||||
export const getClipboardData = (state) => state.generic.clipboardData;
|
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ const slice = createSlice({
|
|||||||
redirectUrlObj: {},
|
redirectUrlObj: {},
|
||||||
postErrors: {},
|
postErrors: {},
|
||||||
},
|
},
|
||||||
clipboardData: null,
|
|
||||||
},
|
},
|
||||||
reducers: {
|
reducers: {
|
||||||
fetchOrganizations: (state, { payload }) => {
|
fetchOrganizations: (state, { payload }) => {
|
||||||
@@ -42,9 +41,6 @@ const slice = createSlice({
|
|||||||
updatePostErrors: (state, { payload }) => {
|
updatePostErrors: (state, { payload }) => {
|
||||||
state.createOrRerunCourse.postErrors = payload;
|
state.createOrRerunCourse.postErrors = payload;
|
||||||
},
|
},
|
||||||
updateClipboardData: (state, { payload }) => {
|
|
||||||
state.clipboardData = payload;
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -56,7 +52,6 @@ export const {
|
|||||||
updateSavingStatus,
|
updateSavingStatus,
|
||||||
updateCourseData,
|
updateCourseData,
|
||||||
updateRedirectUrlObj,
|
updateRedirectUrlObj,
|
||||||
updateClipboardData,
|
|
||||||
} = slice.actions;
|
} = slice.actions;
|
||||||
|
|
||||||
export const {
|
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 { RequestStatus } from '../../data/constants';
|
||||||
import {
|
import {
|
||||||
fetchOrganizations,
|
fetchOrganizations,
|
||||||
@@ -13,14 +6,11 @@ import {
|
|||||||
updateRedirectUrlObj,
|
updateRedirectUrlObj,
|
||||||
updateCourseRerunData,
|
updateCourseRerunData,
|
||||||
updateSavingStatus,
|
updateSavingStatus,
|
||||||
updateClipboardData,
|
|
||||||
} from './slice';
|
} from './slice';
|
||||||
import {
|
import {
|
||||||
createOrRerunCourse,
|
createOrRerunCourse,
|
||||||
getOrganizations,
|
getOrganizations,
|
||||||
getCourseRerun,
|
getCourseRerun,
|
||||||
updateClipboard,
|
|
||||||
getClipboard,
|
|
||||||
} from './api';
|
} from './api';
|
||||||
|
|
||||||
export function fetchOrganizationsQuery() {
|
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 'react-datepicker/dist/react-datepicker.css';
|
||||||
import './index.scss';
|
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 = () => {
|
const App = () => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
import { v4 as uuid4 } from 'uuid';
|
import { v4 as uuid4 } from 'uuid';
|
||||||
|
|
||||||
import { ToastContext } from '../../generic/toast-context';
|
import { ToastContext } from '../../generic/toast-context';
|
||||||
import { useCopyToClipboard } from '../../generic/clipboard';
|
import { useClipboard } from '../../generic/clipboard';
|
||||||
import { getCanEdit } from '../../course-unit/data/selectors';
|
import { getCanEdit } from '../../course-unit/data/selectors';
|
||||||
import { useCreateLibraryBlock, useLibraryPasteClipboard, useAddComponentsToCollection } from '../data/apiHooks';
|
import { useCreateLibraryBlock, useLibraryPasteClipboard, useAddComponentsToCollection } from '../data/apiHooks';
|
||||||
import { useLibraryContext } from '../common/context';
|
import { useLibraryContext } from '../common/context';
|
||||||
@@ -77,7 +77,7 @@ const AddContentContainer = () => {
|
|||||||
const pasteClipboardMutation = useLibraryPasteClipboard();
|
const pasteClipboardMutation = useLibraryPasteClipboard();
|
||||||
const { showToast } = useContext(ToastContext);
|
const { showToast } = useContext(ToastContext);
|
||||||
const canEdit = useSelector(getCanEdit);
|
const canEdit = useSelector(getCanEdit);
|
||||||
const { showPasteXBlock, sharedClipboardData } = useCopyToClipboard(canEdit);
|
const { showPasteXBlock, sharedClipboardData } = useClipboard(canEdit);
|
||||||
|
|
||||||
const [isAddLibraryContentModalOpen, showAddLibraryContentModal, closeAddLibraryContentModal] = useToggle();
|
const [isAddLibraryContentModalOpen, showAddLibraryContentModal, closeAddLibraryContentModal] = useToggle();
|
||||||
|
|
||||||
@@ -172,7 +172,14 @@ const AddContentContainer = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onPaste = () => {
|
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));
|
showToast(intl.formatMessage(messages.unsupportedBlockPasteClipboardMessage));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,7 +77,8 @@ describe('<ComponentCard />', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
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(() => {
|
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 { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import {
|
import {
|
||||||
ActionRow,
|
ActionRow,
|
||||||
@@ -15,8 +15,7 @@ import {
|
|||||||
MoreVert,
|
MoreVert,
|
||||||
} from '@openedx/paragon/icons';
|
} from '@openedx/paragon/icons';
|
||||||
|
|
||||||
import { STUDIO_CLIPBOARD_CHANNEL } from '../../constants';
|
import { useClipboard } from '../../generic/clipboard';
|
||||||
import { updateClipboard } from '../../generic/data/api';
|
|
||||||
import { ToastContext } from '../../generic/toast-context';
|
import { ToastContext } from '../../generic/toast-context';
|
||||||
import { type ContentHit } from '../../search-manager';
|
import { type ContentHit } from '../../search-manager';
|
||||||
import { SidebarAdditionalActions, useLibraryContext } from '../common/context';
|
import { SidebarAdditionalActions, useLibraryContext } from '../common/context';
|
||||||
@@ -43,17 +42,12 @@ export const ComponentMenu = ({ usageKey }: { usageKey: string }) => {
|
|||||||
|
|
||||||
const canEdit = usageKey && canEditComponent(usageKey);
|
const canEdit = usageKey && canEditComponent(usageKey);
|
||||||
const { showToast } = useContext(ToastContext);
|
const { showToast } = useContext(ToastContext);
|
||||||
const [clipboardBroadcastChannel] = useState(() => new BroadcastChannel(STUDIO_CLIPBOARD_CHANNEL));
|
|
||||||
const removeComponentsMutation = useRemoveComponentsFromCollection(libraryId, collectionId);
|
const removeComponentsMutation = useRemoveComponentsFromCollection(libraryId, collectionId);
|
||||||
const [isConfirmingDelete, confirmDelete, cancelDelete] = useToggle(false);
|
const [isConfirmingDelete, confirmDelete, cancelDelete] = useToggle(false);
|
||||||
|
const { copyToClipboard } = useClipboard();
|
||||||
|
|
||||||
const updateClipboardClick = () => {
|
const updateClipboardClick = () => {
|
||||||
updateClipboard(usageKey)
|
copyToClipboard(usageKey);
|
||||||
.then((clipboardData) => {
|
|
||||||
clipboardBroadcastChannel.postMessage(clipboardData);
|
|
||||||
showToast(intl.formatMessage(messages.copyToClipboardSuccess));
|
|
||||||
})
|
|
||||||
.catch(() => showToast(intl.formatMessage(messages.copyToClipboardError)));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeFromCollection = () => {
|
const removeFromCollection = () => {
|
||||||
|
|||||||
@@ -51,16 +51,6 @@ const messages = defineMessages({
|
|||||||
defaultMessage: 'Failed to remove Component',
|
defaultMessage: 'Failed to remove Component',
|
||||||
description: 'Message for failure of removal of component from collection.',
|
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: {
|
deleteComponentWarningTitle: {
|
||||||
id: 'course-authoring.library-authoring.component.delete-confirmation-title',
|
id: 'course-authoring.library-authoring.component.delete-confirmation-title',
|
||||||
defaultMessage: 'Delete Component',
|
defaultMessage: 'Delete Component',
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import MockAdapter from 'axios-mock-adapter';
|
import type 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 userEvent from '@testing-library/user-event';
|
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 { CreateLibrary } from '.';
|
||||||
import { getContentLibraryV2CreateApiUrl } from './data/api';
|
import { getContentLibraryV2CreateApiUrl } from './data/api';
|
||||||
|
|
||||||
let store;
|
|
||||||
const mockNavigate = jest.fn();
|
const mockNavigate = jest.fn();
|
||||||
let axiosMock: MockAdapter;
|
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 />', () => {
|
describe('<CreateLibrary />', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
initializeMockApp({
|
axiosMock = initializeMocks().axiosMock;
|
||||||
authenticatedUser: {
|
|
||||||
userId: 3,
|
|
||||||
username: 'abc123',
|
|
||||||
administrator: true,
|
|
||||||
roles: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
store = initializeStore();
|
|
||||||
|
|
||||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
axiosMock.restore();
|
axiosMock.restore();
|
||||||
queryClient.clear();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('call api data with correct data', async () => {
|
test('call api data with correct data', async () => {
|
||||||
|
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, studioHomeMock);
|
||||||
axiosMock.onPost(getContentLibraryV2CreateApiUrl()).reply(200, {
|
axiosMock.onPost(getContentLibraryV2CreateApiUrl()).reply(200, {
|
||||||
id: 'library-id',
|
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.click(titleInput);
|
||||||
userEvent.type(titleInput, 'Test Library Name');
|
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.click(orgInput);
|
||||||
userEvent.type(orgInput, 'org1');
|
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.click(slugInput);
|
||||||
userEvent.type(slugInput, 'test_library_slug');
|
userEvent.type(slugInput, 'test_library_slug');
|
||||||
|
|
||||||
fireEvent.click(getByRole('button', { name: /create/i }));
|
fireEvent.click(await screen.findByRole('button', { name: /create/i }));
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(axiosMock.history.post.length).toBe(1);
|
expect(axiosMock.history.post.length).toBe(1);
|
||||||
expect(axiosMock.history.post[0].data).toBe(
|
expect(axiosMock.history.post[0].data).toBe(
|
||||||
@@ -98,41 +72,115 @@ describe('<CreateLibrary />', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('show api error', async () => {
|
test('cannot create new org unless allowed', async () => {
|
||||||
axiosMock.onPost(getContentLibraryV2CreateApiUrl()).reply(400, {
|
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, studioHomeMock);
|
||||||
field: 'Error message',
|
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.click(titleInput);
|
||||||
userEvent.type(titleInput, 'Test Library Name');
|
userEvent.type(titleInput, 'Test Library Name');
|
||||||
|
|
||||||
const orgInput = getByTestId('autosuggest-textbox-input');
|
// We cannot create a new org, and so we're restricted to the allowed list
|
||||||
userEvent.click(orgInput);
|
const orgOptions = screen.getByTestId('autosuggest-iconbutton');
|
||||||
userEvent.type(orgInput, 'org1');
|
userEvent.click(orgOptions);
|
||||||
userEvent.tab();
|
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.click(slugInput);
|
||||||
userEvent.type(slugInput, 'test_library_slug');
|
userEvent.type(slugInput, 'test_library_slug');
|
||||||
|
|
||||||
fireEvent.click(getByRole('button', { name: /create/i }));
|
fireEvent.click(await screen.findByRole('button', { name: /create/i }));
|
||||||
await waitFor(() => {
|
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.length).toBe(1);
|
||||||
expect(axiosMock.history.post[0].data).toBe(
|
expect(axiosMock.history.post[0].data).toBe(
|
||||||
'{"description":"","title":"Test Library Name","org":"org1","slug":"test_library_slug"}',
|
'{"description":"","title":"Test Library Name","org":"org1","slug":"test_library_slug"}',
|
||||||
);
|
);
|
||||||
expect(mockNavigate).not.toHaveBeenCalled();
|
expect(mockNavigate).not.toHaveBeenCalled();
|
||||||
expect(getByRole('alert')).toHaveTextContent('Request failed with status code 400');
|
expect(await screen.findByRole('alert')).toHaveTextContent('Request failed with status code 400');
|
||||||
expect(getByRole('alert')).toHaveTextContent('{"field":"Error message"}');
|
expect(await screen.findByRole('alert')).toHaveTextContent('{"field":"Error message"}');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('cancel creating library navigates to libraries page', async () => {
|
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(() => {
|
await waitFor(() => {
|
||||||
expect(mockNavigate).toHaveBeenCalledWith('/libraries');
|
expect(mockNavigate).toHaveBeenCalledWith('/libraries');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import FormikErrorFeedback from '../../generic/FormikErrorFeedback';
|
|||||||
import AlertError from '../../generic/alert-error';
|
import AlertError from '../../generic/alert-error';
|
||||||
import { useOrganizationListData } from '../../generic/data/apiHooks';
|
import { useOrganizationListData } from '../../generic/data/apiHooks';
|
||||||
import SubHeader from '../../generic/sub-header/SubHeader';
|
import SubHeader from '../../generic/sub-header/SubHeader';
|
||||||
|
import { useStudioHome } from '../../studio-home/hooks';
|
||||||
import { useCreateLibraryV2 } from './data/apiHooks';
|
import { useCreateLibraryV2 } from './data/apiHooks';
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
|
|
||||||
@@ -38,10 +39,23 @@ const CreateLibrary = () => {
|
|||||||
} = useCreateLibraryV2();
|
} = useCreateLibraryV2();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: organizationListData,
|
data: allOrganizations,
|
||||||
isLoading: isOrganizationListLoading,
|
isLoading: isOrganizationListLoading,
|
||||||
} = useOrganizationListData();
|
} = useOrganizationListData();
|
||||||
|
|
||||||
|
const {
|
||||||
|
studioHomeData: {
|
||||||
|
allowedOrganizationsForLibraries,
|
||||||
|
allowToCreateNewOrg,
|
||||||
|
},
|
||||||
|
} = useStudioHome();
|
||||||
|
|
||||||
|
const organizations = (
|
||||||
|
allowToCreateNewOrg
|
||||||
|
? allOrganizations
|
||||||
|
: allowedOrganizationsForLibraries
|
||||||
|
) || [];
|
||||||
|
|
||||||
const handleOnClickCancel = () => {
|
const handleOnClickCancel = () => {
|
||||||
navigate('/libraries');
|
navigate('/libraries');
|
||||||
};
|
};
|
||||||
@@ -100,12 +114,17 @@ const CreateLibrary = () => {
|
|||||||
<Form.Autosuggest
|
<Form.Autosuggest
|
||||||
name="org"
|
name="org"
|
||||||
isLoading={isOrganizationListLoading}
|
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)}
|
placeholder={intl.formatMessage(messages.orgPlaceholder)}
|
||||||
>
|
>
|
||||||
{organizationListData ? organizationListData.map((org) => (
|
{organizations.map((org) => (
|
||||||
<Form.AutosuggestOption key={org} id={org}>{org}</Form.AutosuggestOption>
|
<Form.AutosuggestOption key={org} id={org}>{org}</Form.AutosuggestOption>
|
||||||
)) : []}
|
))}
|
||||||
</Form.Autosuggest>
|
</Form.Autosuggest>
|
||||||
<FormikErrorFeedback name="org">
|
<FormikErrorFeedback name="org">
|
||||||
<Form.Text>{intl.formatMessage(messages.orgHelp)}</Form.Text>
|
<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',
|
cmsLink: '//localhost:18010/courses/course-v1:Design+123+e.g.2025',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
canCreateOrganizations: true,
|
canCreateOrganizations: false,
|
||||||
courseCreatorStatus: 'granted',
|
courseCreatorStatus: 'granted',
|
||||||
courses: [
|
courses: [
|
||||||
{
|
{
|
||||||
@@ -76,4 +76,5 @@ module.exports = {
|
|||||||
platformName: 'Your Platform Name Here',
|
platformName: 'Your Platform Name Here',
|
||||||
userIsActive: true,
|
userIsActive: true,
|
||||||
allowToCreateNewOrg: false,
|
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 }) => (
|
const AllTheProviders = ({ children }) => (
|
||||||
<AppProvider store={reduxStore} wrapWithRouter={false}>
|
<AppProvider store={reduxStore} wrapWithRouter={false}>
|
||||||
<IntlProvider locale="en" messages={{}}>
|
<IntlProvider locale="en" messages={{}}>
|
||||||
@@ -192,7 +192,7 @@ export function initializeMocks({ user = defaultUser, initialState = undefined }
|
|||||||
}
|
}
|
||||||
|
|
||||||
export * from '@testing-library/react';
|
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) */
|
/** 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 }) {
|
export function createAxiosError({ code, message, path }: { code: number, message: string, path: string }) {
|
||||||
|
|||||||
Reference in New Issue
Block a user