Compare commits

...

5 Commits

48 changed files with 600 additions and 600 deletions

View File

@@ -104,7 +104,6 @@ const CourseOutline = ({ courseId }) => {
handleNewUnitSubmit,
getUnitUrl,
handleVideoSharingOptionChange,
handleCopyToClipboardClick,
handlePasteClipboardClick,
notificationDismissUrl,
discussionsSettings,
@@ -397,7 +396,6 @@ const CourseOutline = ({ courseId }) => {
onDuplicateSubmit={handleDuplicateUnitSubmit}
getTitleLink={getUnitUrl}
onOrderChange={updateUnitOrderByIndex}
onCopyToClipboardClick={handleCopyToClipboardClick}
discussionsSettings={discussionsSettings}
/>
))}

View File

@@ -2182,9 +2182,6 @@ describe('<CourseOutline />', () => {
.onPost(getClipboardUrl(), {
usage_key: unit.id,
}).reply(200, clipboardUnit);
// check that initialUserClipboard state is empty
const { initialUserClipboard } = store.getState().courseOutline;
expect(initialUserClipboard).toBeUndefined();
// find menu button and click on it to open menu
const menu = await within(unitElement).findByTestId('unit-card-header__menu-button');
@@ -2194,9 +2191,6 @@ describe('<CourseOutline />', () => {
const copyButton = await within(unitElement).findByText(cardHeaderMessages.menuCopy.defaultMessage);
await act(async () => fireEvent.click(copyButton));
// check that initialUserClipboard state is updated
expect(store.getState().generic.clipboardData).toEqual(clipboardUnit);
[subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
// find clipboard content label
const clipboardLabel = await within(subsectionElement).findByText(

View File

@@ -28,7 +28,6 @@ export const getCourseReindexApiUrl = (reindexLink) => `${getApiBaseUrl()}${rein
export const getXBlockBaseApiUrl = () => `${getApiBaseUrl()}/xblock/`;
export const getCourseItemApiUrl = (itemId) => `${getXBlockBaseApiUrl()}${itemId}`;
export const getXBlockApiUrl = (blockId) => `${getXBlockBaseApiUrl()}outline/${blockId}`;
export const getClipboardUrl = () => `${getApiBaseUrl()}/api/content-staging/v1/clipboard/`;
export const exportTags = (courseId) => `${getApiBaseUrl()}/api/content_tagging/v1/object_tags/${courseId}/export/`;
/**

View File

@@ -1,5 +1,4 @@
import { RequestStatus } from '../../data/constants';
import { updateClipboardData } from '../../generic/data/slice';
import { NOTIFICATION_MESSAGES } from '../../constants';
import { API_ERROR_TYPES, COURSE_BLOCK_NAMES } from '../constants';
import {
@@ -88,7 +87,6 @@ export function fetchCourseOutlineIndexQuery(courseId) {
},
} = outlineIndex;
dispatch(fetchOutlineIndexSuccess(outlineIndex));
dispatch(updateClipboardData(outlineIndex.initialUserClipboard));
dispatch(updateStatusBar({
courseReleaseDate,
highlightsEnabledForMessaging,

View File

@@ -4,7 +4,6 @@ import { useNavigate } from 'react-router-dom';
import { useToggle } from '@openedx/paragon';
import { getConfig } from '@edx/frontend-platform';
import { copyToClipboard } from '../generic/data/thunks';
import { getSavingStatus as getGenericSavingStatus } from '../generic/data/selectors';
import { RequestStatus } from '../data/constants';
import { COURSE_BLOCK_NAMES } from './constants';
@@ -72,6 +71,7 @@ const useCourseOutline = ({ courseId }) => {
mfeProctoredExamSettingsUrl,
advanceSettingsUrl,
} = useSelector(getOutlineIndexData);
const { outlineIndexLoadingStatus, reIndexLoadingStatus } = useSelector(getLoadingStatus);
const statusBarData = useSelector(getStatusBarData);
const savingStatus = useSelector(getSavingStatus);
@@ -95,10 +95,6 @@ const useCourseOutline = ({ courseId }) => {
const isSavingStatusFailed = savingStatus === RequestStatus.FAILED || genericSavingStatus === RequestStatus.FAILED;
const handleCopyToClipboardClick = (usageKey) => {
dispatch(copyToClipboard(usageKey));
};
const handlePasteClipboardClick = (parentLocator, sectionId) => {
dispatch(pasteClipboardContent(parentLocator, sectionId));
};
@@ -339,7 +335,6 @@ const useCourseOutline = ({ courseId }) => {
openUnitPage,
handleNewUnitSubmit,
handleVideoSharingOptionChange,
handleCopyToClipboardClick,
handlePasteClipboardClick,
notificationDismissUrl,
discussionsSettings,

View File

@@ -16,7 +16,7 @@ import { RequestStatus } from '../../data/constants';
import CardHeader from '../card-header/CardHeader';
import SortableItem from '../../generic/drag-helper/SortableItem';
import { DragContext } from '../../generic/drag-helper/DragContextProvider';
import { useCopyToClipboard, PasteComponent } from '../../generic/clipboard';
import { useClipboard, PasteComponent } from '../../generic/clipboard';
import TitleButton from '../card-header/TitleButton';
import XBlockStatus from '../xblock-status/XBlockStatus';
import { getItemStatus, getItemStatusBorder, scrollToElement } from '../utils';
@@ -49,7 +49,7 @@ const SubsectionCard = ({
const isScrolledToElement = locatorId === subsection.id;
const [isFormOpen, openForm, closeForm] = useToggle(false);
const namePrefix = 'subsection';
const { sharedClipboardData, showPasteUnit } = useCopyToClipboard();
const { sharedClipboardData, showPasteUnit } = useClipboard();
const {
id,
@@ -233,7 +233,7 @@ const SubsectionCard = ({
>
{intl.formatMessage(messages.newUnitButton)}
</Button>
{enableCopyPasteUnits && showPasteUnit && (
{enableCopyPasteUnits && showPasteUnit && sharedClipboardData && (
<PasteComponent
className="mt-4"
text={intl.formatMessage(messages.pasteButton)}

View File

@@ -13,6 +13,7 @@ import SortableItem from '../../generic/drag-helper/SortableItem';
import TitleLink from '../card-header/TitleLink';
import XBlockStatus from '../xblock-status/XBlockStatus';
import { getItemStatus, getItemStatusBorder, scrollToElement } from '../utils';
import { useClipboard } from '../../generic/clipboard';
const UnitCard = ({
unit,
@@ -30,7 +31,6 @@ const UnitCard = ({
onDuplicateSubmit,
getTitleLink,
onOrderChange,
onCopyToClipboardClick,
discussionsSettings,
}) => {
const currentRef = useRef(null);
@@ -41,6 +41,8 @@ const UnitCard = ({
const [isFormOpen, openForm, closeForm] = useToggle(false);
const namePrefix = 'unit';
const { copyToClipboard } = useClipboard();
const {
id,
category,
@@ -98,7 +100,7 @@ const UnitCard = ({
};
const handleCopyClick = () => {
onCopyToClipboardClick(unit.id);
copyToClipboard(id);
};
const titleComponent = (
@@ -241,7 +243,6 @@ UnitCard.propTypes = {
onOrderChange: PropTypes.func.isRequired,
isSelfPaced: PropTypes.bool.isRequired,
isCustomRelativeDatesActive: PropTypes.bool.isRequired,
onCopyToClipboardClick: PropTypes.func.isRequired,
discussionsSettings: PropTypes.shape({
providerType: PropTypes.string,
enableGradedUnits: PropTypes.bool,

View File

@@ -1,4 +1,3 @@
import React from 'react';
import {
act, render, fireEvent, within,
} from '@testing-library/react';
@@ -48,6 +47,13 @@ const unit = {
const queryClient = new QueryClient();
const clipboardBroadcastChannelMock = {
postMessage: jest.fn(),
close: jest.fn(),
};
global.BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
const renderComponent = (props) => render(
<AppProvider store={store}>
<QueryClientProvider client={queryClient}>
@@ -62,7 +68,6 @@ const renderComponent = (props) => render(
onOpenPublishModal={jest.fn()}
onOpenDeleteModal={jest.fn()}
onOpenConfigureModal={jest.fn()}
onCopyToClipboardClick={jest.fn()}
savingStatus=""
onEditSubmit={jest.fn()}
onDuplicateSubmit={jest.fn()}

View File

@@ -185,7 +185,9 @@ const CourseUnit = ({ courseId }) => {
{showPasteXBlock && canPasteComponent && (
<PasteComponent
clipboardData={sharedClipboardData}
onClick={handleCreateNewCourseXBlock}
onClick={
() => handleCreateNewCourseXBlock({ stagedContent: 'clipboard', parentLocator: blockId })
}
text={intl.formatMessage(messages.pasteButtonText)}
/>
)}

View File

@@ -1,4 +1,5 @@
import MockAdapter from 'axios-mock-adapter';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import {
act, render, waitFor, fireEvent, within, screen,
} from '@testing-library/react';
@@ -38,10 +39,7 @@ import {
courseVerticalChildrenMock,
clipboardMockResponse,
} from './__mocks__';
import {
clipboardUnit,
clipboardXBlock,
} from '../__mocks__';
import { clipboardUnit, clipboardXBlock } from '../__mocks__';
import { executeThunk } from '../utils';
import deleteModalMessages from '../generic/delete-modal/messages';
import pasteComponentMessages from '../generic/clipboard/paste-component/messages';
@@ -54,6 +52,7 @@ import { extractCourseUnitId } from './sidebar/utils';
import CourseUnit from './CourseUnit';
import configureModalMessages from '../generic/configure-modal/messages';
import { getClipboardUrl } from '../generic/data/api';
import courseXBlockMessages from './course-xblock/messages';
import addComponentMessages from './add-component/messages';
import { PUBLISH_TYPES, UNIT_VISIBILITY_STATES } from './constants';
@@ -63,6 +62,7 @@ import { RequestStatus } from '../data/constants';
let axiosMock;
let store;
let queryClient;
const courseId = '123';
const blockId = '567890';
const unitDisplayName = courseUnitIndexMock.metadata.display_name;
@@ -80,31 +80,6 @@ jest.mock('react-router-dom', () => ({
useNavigate: () => mockedUsedNavigate,
}));
jest.mock('@tanstack/react-query', () => ({
useQuery: jest.fn(({ queryKey }) => {
if (queryKey[0] === 'contentTaxonomyTags') {
return {
data: {
taxonomies: [],
},
isSuccess: true,
};
} if (queryKey[0] === 'contentTagsCount') {
return {
data: 17,
isSuccess: true,
};
}
return {
data: {},
isSuccess: true,
};
}),
useQueryClient: jest.fn(() => ({
setQueryData: jest.fn(),
})),
}));
const clipboardBroadcastChannelMock = {
postMessage: jest.fn(),
close: jest.fn(),
@@ -115,7 +90,9 @@ global.BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
const RootWrapper = () => (
<AppProvider store={store}>
<IntlProvider locale="en">
<CourseUnit courseId={courseId} />
<QueryClientProvider client={queryClient}>
<CourseUnit courseId={courseId} />
</QueryClientProvider>
</IntlProvider>
</AppProvider>
);
@@ -132,7 +109,17 @@ describe('<CourseUnit />', () => {
});
global.localStorage.clear();
store = initializeStore();
queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock
.onGet(getClipboardUrl())
.reply(200, clipboardUnit);
axiosMock
.onGet(getCourseUnitApiUrl(courseId))
.reply(200, courseUnitIndexMock);
@@ -147,7 +134,7 @@ describe('<CourseUnit />', () => {
await executeThunk(fetchCourseVerticalChildrenData(blockId), store.dispatch);
axiosMock
.onGet(getContentTaxonomyTagsApiUrl(blockId))
.reply(200, {});
.reply(200, { taxonomies: [] });
axiosMock
.onGet(getContentTaxonomyTagsCountApiUrl(blockId))
.reply(200, 17);
@@ -1087,19 +1074,16 @@ describe('<CourseUnit />', () => {
queryByTestId, getByRole, getAllByLabelText, getByText,
} = render(<RootWrapper />);
axiosMock
.onGet(getClipboardUrl())
.reply(200, clipboardXBlock);
await waitFor(() => {
const [xblockActionBtn] = getAllByLabelText(courseXBlockMessages.blockActionsDropdownAlt.defaultMessage);
userEvent.click(xblockActionBtn);
userEvent.click(getByRole('button', { name: courseXBlockMessages.blockLabelButtonCopyToClipboard.defaultMessage }));
});
axiosMock
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseSectionVerticalMock,
user_clipboard: clipboardXBlock,
});
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
expect(getByRole('button', { name: messages.pasteButtonText.defaultMessage })).toBeInTheDocument();
@@ -1141,11 +1125,8 @@ describe('<CourseUnit />', () => {
});
axiosMock
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseSectionVerticalMock,
user_clipboard: clipboardXBlock,
});
.onGet(getClipboardUrl())
.reply(200, clipboardXBlock);
axiosMock
.onGet(getCourseUnitApiUrl(courseId))
@@ -1197,11 +1178,8 @@ describe('<CourseUnit />', () => {
});
axiosMock
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseSectionVerticalMock,
user_clipboard: clipboardXBlock,
});
.onGet(getClipboardUrl())
.reply(200, clipboardXBlock);
axiosMock
.onGet(getCourseUnitApiUrl(courseId))
@@ -1228,13 +1206,6 @@ describe('<CourseUnit />', () => {
enable_copy_paste_units: true,
});
axiosMock
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseSectionVerticalMock,
user_clipboard: clipboardUnit,
});
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
@@ -1288,13 +1259,6 @@ describe('<CourseUnit />', () => {
enable_copy_paste_units: true,
});
axiosMock
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseSectionVerticalMock,
user_clipboard: clipboardUnit,
});
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
@@ -1349,13 +1313,6 @@ describe('<CourseUnit />', () => {
enable_copy_paste_units: true,
});
axiosMock
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseSectionVerticalMock,
user_clipboard: clipboardUnit,
});
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
@@ -1410,13 +1367,6 @@ describe('<CourseUnit />', () => {
enable_copy_paste_units: true,
});
axiosMock
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseSectionVerticalMock,
user_clipboard: clipboardUnit,
});
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);

View File

@@ -1,7 +1,7 @@
import { useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useDispatch, useSelector } from 'react-redux';
import { useSelector } from 'react-redux';
import {
ActionRow, Card, Dropdown, Icon, IconButton, useToggle,
} from '@openedx/paragon';
@@ -15,7 +15,7 @@ import ConfigureModal from '../../generic/configure-modal/ConfigureModal';
import SortableItem from '../../generic/drag-helper/SortableItem';
import { scrollToElement } from '../../course-outline/utils';
import { COURSE_BLOCK_NAMES } from '../../constants';
import { copyToClipboard } from '../../generic/data/thunks';
import { useClipboard } from '../../generic/clipboard';
import { COMPONENT_TYPES } from '../../generic/block-type-utils/constants';
import XBlockMessages from './xblock-messages/XBlockMessages';
import messages from './messages';
@@ -28,7 +28,6 @@ const CourseXBlock = ({
const courseXBlockElementRef = useRef(null);
const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggle(false);
const [isConfigureModalOpen, openConfigureModal, closeConfigureModal] = useToggle(false);
const dispatch = useDispatch();
const canEdit = useSelector(getCanEdit);
const courseId = useSelector(getCourseId);
const intl = useIntl();
@@ -48,6 +47,8 @@ const CourseXBlock = ({
showCorrectness: 'always',
};
const { copyToClipboard } = useClipboard(canEdit);
const onDeleteSubmit = () => {
unitXBlockActions.handleDelete(id);
closeDeleteModal();
@@ -120,7 +121,7 @@ const CourseXBlock = ({
{intl.formatMessage(messages.blockLabelButtonMove)}
</Dropdown.Item>
{canEdit && (
<Dropdown.Item onClick={() => dispatch(copyToClipboard(id))}>
<Dropdown.Item onClick={() => copyToClipboard(id)}>
{intl.formatMessage(messages.blockLabelButtonCopyToClipboard)}
</Dropdown.Item>
)}

View File

@@ -1,3 +1,4 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import {
render, waitFor, within,
} from '@testing-library/react';
@@ -24,6 +25,15 @@ import messages from './messages';
let axiosMock;
let store;
let queryClient;
const clipboardBroadcastChannelMock = {
postMessage: jest.fn(),
close: jest.fn(),
};
global.BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
const courseId = '1234';
const blockId = '567890';
const handleDeleteMock = jest.fn();
@@ -49,17 +59,19 @@ jest.mock('react-redux', () => ({
const renderComponent = (props) => render(
<AppProvider store={store}>
<IntlProvider locale="en">
<CourseXBlock
id={id}
title={name}
type={type}
blockId={blockId}
unitXBlockActions={unitXBlockActionsMock}
userPartitionInfo={userPartitionInfoFormatted}
shouldScroll={false}
handleConfigureSubmit={handleConfigureSubmitMock}
{...props}
/>
<QueryClientProvider client={queryClient}>
<CourseXBlock
id={id}
title={name}
type={type}
blockId={blockId}
unitXBlockActions={unitXBlockActionsMock}
userPartitionInfo={userPartitionInfoFormatted}
shouldScroll={false}
handleConfigureSubmit={handleConfigureSubmitMock}
{...props}
/>
</QueryClientProvider>
</IntlProvider>
</AppProvider>,
);
@@ -98,6 +110,7 @@ describe('<CourseXBlock />', () => {
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, courseSectionVerticalMock);
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
queryClient = new QueryClient();
});
it('render CourseXBlock component correctly', async () => {

View File

@@ -8,7 +8,6 @@ import { handleResponseErrors } from '../../generic/saving-error-alert';
import { RequestStatus } from '../../data/constants';
import { NOTIFICATION_MESSAGES } from '../../constants';
import { updateModel, updateModels } from '../../generic/model-store';
import { updateClipboardData } from '../../generic/data/slice';
import {
getCourseUnitData,
editUnitDisplayName,
@@ -75,7 +74,6 @@ export function fetchCourseSectionVerticalData(courseId, sequenceId) {
}));
dispatch(fetchStaticFileNoticesSuccess(JSON.parse(localStorage.getItem('staticFileNotices'))));
localStorage.removeItem('staticFileNotices');
dispatch(updateClipboardData(courseSectionVerticalData.userClipboard));
dispatch(fetchSequenceSuccess({ sequenceId }));
return true;
} catch (error) {
@@ -214,8 +212,6 @@ export function deleteUnitItemQuery(itemId, xblockId) {
try {
await deleteUnitItem(xblockId);
dispatch(deleteXBlock(xblockId));
const { userClipboard } = await getCourseSectionVerticalData(itemId);
dispatch(updateClipboardData(userClipboard));
const courseUnit = await getCourseUnitData(itemId);
dispatch(fetchCourseItemSuccess(courseUnit));
dispatch(hideProcessingNotification());

View File

@@ -3,6 +3,7 @@ import { useDispatch, useSelector } from 'react-redux';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { RequestStatus } from '../data/constants';
import { useClipboard } from '../generic/clipboard';
import {
createNewCourseXBlock,
fetchCourseUnitQuery,
@@ -28,8 +29,6 @@ import {
import { changeEditTitleFormOpen, updateQueryPendingStatus } from './data/slice';
import { PUBLISH_TYPES } from './constants';
import { useCopyToClipboard } from '../generic/clipboard';
// eslint-disable-next-line import/prefer-default-export
export const useCourseUnit = ({ courseId, blockId }) => {
const dispatch = useDispatch();
@@ -47,7 +46,7 @@ export const useCourseUnit = ({ courseId, blockId }) => {
const isTitleEditFormOpen = useSelector(state => state.courseUnit.isTitleEditFormOpen);
const canEdit = useSelector(getCanEdit);
const { currentlyVisibleToStudents } = courseUnit;
const { sharedClipboardData, showPasteXBlock, showPasteUnit } = useCopyToClipboard(canEdit);
const { sharedClipboardData, showPasteXBlock, showPasteUnit } = useClipboard(canEdit);
const { canPasteComponent } = courseVerticalChildren;
const unitTitle = courseUnit.metadata?.displayName || '';

View File

@@ -1,15 +1,14 @@
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import { useSelector } from 'react-redux';
import { Button } from '@openedx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Divider } from '../../../../generic/divider';
import { getCanEdit, getCourseUnitData } from '../../../data/selectors';
import { copyToClipboard } from '../../../../generic/data/thunks';
import { useClipboard } from '../../../../generic/clipboard';
import messages from '../../messages';
const ActionButtons = ({ openDiscardModal, handlePublishing }) => {
const dispatch = useDispatch();
const intl = useIntl();
const {
id,
@@ -18,6 +17,7 @@ const ActionButtons = ({ openDiscardModal, handlePublishing }) => {
enableCopyPasteUnits,
} = useSelector(getCourseUnitData);
const canEdit = useSelector(getCanEdit);
const { copyToClipboard } = useClipboard();
return (
<>
@@ -40,7 +40,7 @@ const ActionButtons = ({ openDiscardModal, handlePublishing }) => {
<>
<Divider className="course-unit-sidebar-footer__divider" />
<Button
onClick={() => dispatch(copyToClipboard(id))}
onClick={() => copyToClipboard(id)}
variant="outline-primary"
size="sm"
>

View File

@@ -1,3 +1,4 @@
import { QueryClientProvider, QueryClient } from '@tanstack/react-query';
import { render } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { AppProvider } from '@edx/frontend-platform/react';
@@ -12,24 +13,28 @@ import { clipboardUnit } from '../../../../__mocks__';
import { getCourseUnitApiUrl } from '../../../data/api';
import { getClipboardUrl } from '../../../../generic/data/api';
import { fetchCourseUnitQuery } from '../../../data/thunk';
import { copyToClipboard } from '../../../../generic/data/thunks';
import { courseUnitIndexMock } from '../../../__mocks__';
import messages from '../../messages';
import ActionButtons from './ActionButtons';
jest.mock('../../../../generic/data/thunks', () => ({
...jest.requireActual('../../../../generic/data/thunks'),
copyToClipboard: jest.fn().mockImplementation(() => () => {}),
}));
let store;
let axiosMock;
let queryClient;
const courseId = '123';
const clipboardBroadcastChannelMock = {
postMessage: jest.fn(),
close: jest.fn(),
};
global.BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
const renderComponent = (props = {}) => render(
<AppProvider store={store}>
<IntlProvider locale="en">
<ActionButtons {...props} />
<QueryClientProvider client={queryClient}>
<ActionButtons {...props} />
</QueryClientProvider>
</IntlProvider>
</AppProvider>,
);
@@ -57,6 +62,8 @@ describe('<ActionButtons />', () => {
.onGet(getClipboardUrl())
.reply(200, clipboardUnit);
queryClient = new QueryClient();
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
});
@@ -73,8 +80,8 @@ describe('<ActionButtons />', () => {
const copyXBlockBtn = getByRole('button', { name: messages.actionButtonCopyUnitTitle.defaultMessage });
userEvent.click(copyXBlockBtn);
expect(copyToClipboard).toHaveBeenCalledWith(courseUnitIndexMock.id);
expect(axiosMock.history.post.length).toBe(1);
expect(axiosMock.history.post[0].data).toBe(JSON.stringify({ usage_key: courseUnitIndexMock.id }));
jest.resetAllMocks();
});
});

View File

@@ -148,6 +148,40 @@ export const getImageResizeHandler = ({ editor, imagesRef, setImage }) => () =>
});
};
/**
* Fix TinyMCE editors used in Paragon modals, by re-parenting their modal <div>
* from the body to the Paragon modal container.
*
* This fixes a problem where clicking on any modal/popup within TinyMCE (e.g.
* the emoji inserter, the link inserter, the floating format toolbar -
* quickbars, etc.) would cause the parent Paragon modal to close, because
* Paragon sees it as a "click outside" event. Also fixes some hover effects by
* ensuring the layering of the divs is correct.
*
* This could potentially cause problems if there are TinyMCE editors being used
* both on the parent page and inside a Paragon modal popup, but I don't think
* we have that situation.
*
* Note: we can't just do this on init, because the quickbars plugin used by
* ExpandableTextEditors creates its modal DIVs later. Ideally we could listen
* for some kind of "modal open" event, but I haven't been able to find anything
* like that so for now we do this quite frequently, every time there is a
* "selectionchange" event (which is pretty often).
*/
export const reparentTinyMceModals = /* istanbul ignore next */ () => {
const modalLayer = document.querySelector('.pgn__modal-layer');
if (!modalLayer) {
return;
}
const tinymceAuxDivs = document.querySelectorAll('.tox.tox-tinymce-aux');
for (const tinymceAux of tinymceAuxDivs) {
if (tinymceAux.parentElement !== modalLayer) {
// Move this tinyMCE modal div into the paragon modal layer.
modalLayer.appendChild(tinymceAux);
}
}
};
export const setupCustomBehavior = ({
updateContent,
openImgModal,
@@ -221,30 +255,17 @@ export const setupCustomBehavior = ({
}
editor.on('init', /* istanbul ignore next */ () => {
// Moving TinyMce aux modal inside the Editor modal
// if the editor is on modal mode.
// This is to avoid issues using the aux modal:
// * Avoid close aux modal when clicking the content inside.
// * When the user opens the `Edit Source Code` modal, this adds `data-focus-on-hidden`
// to the TinyMce aux modal, making it unusable.
const modalLayer = document.querySelector('.pgn__modal-layer');
const tinymceAux = document.querySelector('.tox.tox-tinymce-aux');
if (modalLayer && tinymceAux) {
modalLayer.appendChild(tinymceAux);
// Check if this editor is inside a (Paragon) modal.
// The way we get the editor's root <div> depends on whether or not this particular editor is using an iframe:
const editorDiv = editor.bodyElement ?? editor.container;
if (editorDiv?.closest('.pgn__modal')) {
// This editor is inside a Paragon modal. Use this hack to avoid interference with TinyMCE's own modal popups:
reparentTinyMceModals();
editor.on('selectionchange', reparentTinyMceModals);
}
});
editor.on('ExecCommand', /* istanbul ignore next */ (e) => {
// Remove `data-focus-on-hidden` and `aria-hidden` on TinyMce aux modal used on emoticons, formulas, etc.
// When using the Editor in modal mode, it may happen that the editor modal is rendered
// before the TinyMce aux modal, which adds these attributes, making the TinyMce aux modal unusable.
const modalElement = document.querySelector('.tox.tox-silver-sink.tox-tinymce-aux');
if (modalElement) {
modalElement.removeAttribute('data-focus-on-hidden');
modalElement.removeAttribute('aria-hidden');
}
if (editorType === 'text' && e.command === 'mceFocus') {
const initialContent = editor.getContent();
const newContent = module.replaceStaticWithAsset({

View File

@@ -64,16 +64,9 @@ const pluginConfig = ({ isLibrary, placeholder, editorType }) => {
[editImageSettings],
]),
quickbarsInsertToolbar: toolbar ? false : mapToolbars([
[buttons.undo, buttons.redo],
[buttons.formatSelect],
[buttons.bold, buttons.italic, buttons.underline, buttons.foreColor],
[
buttons.align.justify,
buttons.bullist,
buttons.numlist,
],
[imageUploadButton, buttons.blockQuote, buttons.codeBlock],
[buttons.table, buttons.emoticons, buttons.charmap, buttons.removeFormat, buttons.a11ycheck],
// To keep from blocking the whole text input field when it's empty, this "insert" toolbar
// used with ExpandableTextArea is kept as minimal as we can.
[imageUploadButton, buttons.table],
]),
quickbarsSelectionToolbar: toolbar ? false : mapToolbars([
[buttons.undo, buttons.redo],

View 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;

View 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');
});
});

View 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;

View File

@@ -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;

View File

@@ -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);
});
});
});

View File

@@ -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';

View File

@@ -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;

View File

@@ -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;

View File

@@ -1,16 +1,24 @@
import PropTypes from 'prop-types';
import { Link } from 'react-router-dom';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Icon, Popover, Stack } from '@openedx/paragon';
import { OpenInNew as OpenInNewIcon } from '@openedx/paragon/icons';
import type { ClipboardStatus } from '../../../data/api';
import messages from '../messages';
import { clipboardPropsTypes } from '../constants';
const PopoverContent = ({ clipboardData }) => {
interface PopoverContentProps {
clipboardData: ClipboardStatus,
}
const PopoverContent = ({ clipboardData } : PopoverContentProps) => {
const intl = useIntl();
const { sourceEditUrl, content, sourceContextTitle } = clipboardData;
// istanbul ignore if: this should never happen
if (!content) {
return null;
}
return (
<Popover.Title
className="clipboard-popover-title"
@@ -40,8 +48,4 @@ const PopoverContent = ({ clipboardData }) => {
);
};
PopoverContent.propTypes = {
clipboardData: PropTypes.shape(clipboardPropsTypes).isRequired,
};
export default PopoverContent;

View File

@@ -1,14 +1,19 @@
import { useRef } from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Icon } from '@openedx/paragon';
import { Question as QuestionIcon } from '@openedx/paragon/icons';
import messages from '../messages';
interface WhatsInClipboardProps {
handlePopoverToggle: (show: boolean) => void;
togglePopover: (show: boolean) => void;
popoverElementRef: React.RefObject<HTMLDivElement>;
}
const WhatsInClipboard = ({
handlePopoverToggle, togglePopover, popoverElementRef,
}) => {
}: WhatsInClipboardProps) => {
const intl = useIntl();
const triggerElementRef = useRef(null);
@@ -46,13 +51,4 @@ const WhatsInClipboard = ({
);
};
WhatsInClipboard.propTypes = {
handlePopoverToggle: PropTypes.func.isRequired,
togglePopover: PropTypes.func.isRequired,
popoverElementRef: PropTypes.oneOfType([
PropTypes.func,
PropTypes.shape({ current: PropTypes.instanceOf(Element) }),
]).isRequired,
};
export default WhatsInClipboard;

View File

@@ -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,
};

View File

@@ -1,13 +1,19 @@
import { useRef, useState } from 'react';
import PropTypes from 'prop-types';
import { OverlayTrigger, Popover } from '@openedx/paragon';
import { PopoverContent, PasteButton, WhatsInClipboard } from './components';
import { clipboardPropsTypes } from './constants';
import type { ClipboardStatus } from '../../data/api';
interface PasteComponentProps {
onClick: () => void;
clipboardData: ClipboardStatus;
text: string;
className?: string;
}
const PasteComponent = ({
onClick, clipboardData, text, className,
}) => {
}: PasteComponentProps) => {
const [showPopover, togglePopover] = useState(false);
const popoverElementRef = useRef(null);
@@ -24,9 +30,7 @@ const PasteComponent = ({
onBlur={() => handlePopoverToggle(false)}
{...props}
>
{clipboardData && (
<PopoverContent clipboardData={clipboardData} />
)}
<PopoverContent clipboardData={clipboardData} />
</Popover>
</div>
);
@@ -48,18 +52,4 @@ const PasteComponent = ({
);
};
PasteComponent.propTypes = {
onClick: PropTypes.func.isRequired,
text: PropTypes.string.isRequired,
clipboardData: PropTypes.shape(clipboardPropsTypes),
blockType: PropTypes.string,
className: PropTypes.string,
};
PasteComponent.defaultProps = {
clipboardData: null,
blockType: null,
className: undefined,
};
export default PasteComponent;

View File

@@ -223,6 +223,7 @@ const ConfigureModal = ({
category={category}
isSubsection={isSubsection}
showWarning={visibilityState === VisibilityTypes.STAFF_ONLY}
isSelfPaced={isSelfPaced}
/>
</Tab>
<Tab eventKey="advanced" title={intl.formatMessage(messages.advancedTabTitle)}>

View File

@@ -11,6 +11,7 @@ const VisibilityTab = ({
category,
showWarning,
isSubsection,
isSelfPaced,
}) => {
const intl = useIntl();
const visibilityTitle = COURSE_BLOCK_NAMES[category]?.name;
@@ -53,6 +54,13 @@ const VisibilityTab = ({
setFieldValue('showCorrectness', e.target.value);
};
const hideDueMessage = {
hideContentLabel: isSelfPaced ? messages.hideContentAfterEnd : messages.hideContentAfterDue,
hideContentDescription: (
isSelfPaced ? messages.hideContentAfterEndDescription : messages.hideContentAfterDueDescription
),
};
return (
<>
<h5 className="mt-4 text-gray-700">
@@ -72,9 +80,10 @@ const VisibilityTab = ({
</Form.Radio>
<Form.Text><FormattedMessage {...messages.showEntireSubsectionDescription} /></Form.Text>
<Form.Radio value="hideDue">
<FormattedMessage {...messages.hideContentAfterDue} />
<FormattedMessage {...hideDueMessage.hideContentLabel} />
</Form.Radio>
<Form.Text><FormattedMessage {...messages.hideContentAfterDueDescription} /></Form.Text>
<Form.Text><FormattedMessage {...hideDueMessage.hideContentDescription} />
</Form.Text>
<Form.Radio value="hide">
<FormattedMessage {...messages.hideEntireSubsection} />
</Form.Radio>
@@ -130,6 +139,11 @@ VisibilityTab.propTypes = {
category: PropTypes.string.isRequired,
showWarning: PropTypes.bool.isRequired,
isSubsection: PropTypes.bool.isRequired,
isSelfPaced: PropTypes.bool,
};
VisibilityTab.defaultProps = {
isSelfPaced: false,
};
export default injectIntl(VisibilityTab);

View File

@@ -139,6 +139,14 @@ const messages = defineMessages({
id: 'course-authoring.course-outline.configure-modal.visibility-tab.hide-content-after-due-description',
defaultMessage: 'After the subsection\'s due date has passed, learners can no longer access its content. The subsection is not included in grade calculations.',
},
hideContentAfterEnd: {
id: 'course-authoring.course-outline.configure-modal.visibility-tab.hide-content-after-end',
defaultMessage: 'Hide content after end date',
},
hideContentAfterEndDescription: {
id: 'course-authoring.course-outline.configure-modal.visibility-tab.hide-content-after-end-description',
defaultMessage: 'After the course\'s end date has passed, learners can no longer access its content. The subsection is not included in grade calculations.',
},
hideEntireSubsection: {
id: 'course-authoring.course-outline.configure-modal.visibility-tab.hide-entire-subsection',
defaultMessage: 'Hide entire subsection',

View File

@@ -27,12 +27,12 @@ const useCreateOrRerunCourse = (initialValues) => {
const allOrganizations = useSelector(getOrganizations);
const postErrors = useSelector(getPostErrors);
const {
allowToCreateNewOrg,
canCreateOrganizations,
allowedOrganizations,
} = useSelector(getStudioHomeData);
const [isFormFilled, setFormFilled] = useState(false);
const [showErrorBanner, setShowErrorBanner] = useState(false);
const organizations = allowToCreateNewOrg ? allOrganizations : allowedOrganizations;
const organizations = canCreateOrganizations ? allOrganizations : allowedOrganizations;
const { specialCharsRule, noSpaceRule } = REGEX_RULES;
const validationSchema = Yup.object().shape({
@@ -78,7 +78,7 @@ const useCreateOrRerunCourse = (initialValues) => {
});
useEffect(() => {
if (allowToCreateNewOrg) {
if (canCreateOrganizations) {
dispatch(fetchOrganizationsQuery());
}
}, []);

View File

@@ -5,4 +5,3 @@ export const getCourseData = (state) => state.generic.createOrRerunCourse.course
export const getCourseRerunData = (state) => state.generic.createOrRerunCourse.courseRerunData;
export const getRedirectUrlObj = (state) => state.generic.createOrRerunCourse.redirectUrlObj;
export const getPostErrors = (state) => state.generic.createOrRerunCourse.postErrors;
export const getClipboardData = (state) => state.generic.clipboardData;

View File

@@ -18,7 +18,6 @@ const slice = createSlice({
redirectUrlObj: {},
postErrors: {},
},
clipboardData: null,
},
reducers: {
fetchOrganizations: (state, { payload }) => {
@@ -42,9 +41,6 @@ const slice = createSlice({
updatePostErrors: (state, { payload }) => {
state.createOrRerunCourse.postErrors = payload;
},
updateClipboardData: (state, { payload }) => {
state.clipboardData = payload;
},
},
});
@@ -56,7 +52,6 @@ export const {
updateSavingStatus,
updateCourseData,
updateRedirectUrlObj,
updateClipboardData,
} = slice.actions;
export const {

View File

@@ -1,10 +1,3 @@
import { logError } from '@edx/frontend-platform/logging';
import { CLIPBOARD_STATUS, NOTIFICATION_MESSAGES } from '../../constants';
import {
hideProcessingNotification,
showProcessingNotification,
} from '../processing-notification/data/slice';
import { RequestStatus } from '../../data/constants';
import {
fetchOrganizations,
@@ -13,14 +6,11 @@ import {
updateRedirectUrlObj,
updateCourseRerunData,
updateSavingStatus,
updateClipboardData,
} from './slice';
import {
createOrRerunCourse,
getOrganizations,
getCourseRerun,
updateClipboard,
getClipboard,
} from './api';
export function fetchOrganizationsQuery() {
@@ -63,33 +53,3 @@ export function updateCreateOrRerunCourseQuery(courseData) {
}
};
}
export function copyToClipboard(usageKey) {
const POLL_INTERVAL_MS = 1000; // Timeout duration for polling in milliseconds
return async (dispatch) => {
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.copying));
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
try {
let clipboardData = await updateClipboard(usageKey);
while (clipboardData.content?.status === CLIPBOARD_STATUS.loading) {
// eslint-disable-next-line no-await-in-loop,no-promise-executor-return
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
clipboardData = await getClipboard(); // eslint-disable-line no-await-in-loop
}
if (clipboardData.content?.status === CLIPBOARD_STATUS.ready) {
dispatch(updateClipboardData(clipboardData));
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
} else {
throw new Error(`Unexpected clipboard status "${clipboardData.content?.status}" in successful API response.`);
}
} catch (error) {
logError('Error copying to clipboard:', error);
} finally {
dispatch(hideProcessingNotification());
}
};
}

View File

@@ -35,7 +35,13 @@ import { ToastProvider } from './generic/toast-context';
import 'react-datepicker/dist/react-datepicker.css';
import './index.scss';
const queryClient = new QueryClient();
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 60_000, // If cache is up to one hour old, no need to re-fetch
},
},
});
const App = () => {
useEffect(() => {

View File

@@ -22,7 +22,7 @@ import {
import { v4 as uuid4 } from 'uuid';
import { ToastContext } from '../../generic/toast-context';
import { useCopyToClipboard } from '../../generic/clipboard';
import { useClipboard } from '../../generic/clipboard';
import { getCanEdit } from '../../course-unit/data/selectors';
import { useCreateLibraryBlock, useLibraryPasteClipboard, useAddComponentsToCollection } from '../data/apiHooks';
import { useLibraryContext } from '../common/context';
@@ -77,7 +77,7 @@ const AddContentContainer = () => {
const pasteClipboardMutation = useLibraryPasteClipboard();
const { showToast } = useContext(ToastContext);
const canEdit = useSelector(getCanEdit);
const { showPasteXBlock, sharedClipboardData } = useCopyToClipboard(canEdit);
const { showPasteXBlock, sharedClipboardData } = useClipboard(canEdit);
const [isAddLibraryContentModalOpen, showAddLibraryContentModal, closeAddLibraryContentModal] = useToggle();
@@ -172,7 +172,14 @@ const AddContentContainer = () => {
};
const onPaste = () => {
if (!isBlockTypeEnabled(sharedClipboardData.content?.blockType)) {
const clipboardBlockType = sharedClipboardData?.content?.blockType;
// istanbul ignore if: this should never happen
if (!clipboardBlockType) {
return;
}
if (!isBlockTypeEnabled(clipboardBlockType)) {
showToast(intl.formatMessage(messages.unsupportedBlockPasteClipboardMessage));
return;
}

View File

@@ -77,7 +77,8 @@ describe('<ComponentCard />', () => {
);
await waitFor(() => {
expect(mockShowToast).toHaveBeenCalledWith('Component copied to clipboard');
expect(mockShowToast).toHaveBeenCalledWith('Copying');
expect(mockShowToast).toHaveBeenCalledWith('Copied to clipboard');
});
});
@@ -100,7 +101,8 @@ describe('<ComponentCard />', () => {
);
await waitFor(() => {
expect(mockShowToast).toHaveBeenCalledWith('Failed to copy component to clipboard');
expect(mockShowToast).toHaveBeenCalledWith('Copying');
expect(mockShowToast).toHaveBeenCalledWith('Error copying to clipboard');
});
});
});

View File

@@ -1,4 +1,4 @@
import { useContext, useState } from 'react';
import { useContext } from 'react';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import {
ActionRow,
@@ -15,8 +15,7 @@ import {
MoreVert,
} from '@openedx/paragon/icons';
import { STUDIO_CLIPBOARD_CHANNEL } from '../../constants';
import { updateClipboard } from '../../generic/data/api';
import { useClipboard } from '../../generic/clipboard';
import { ToastContext } from '../../generic/toast-context';
import { type ContentHit } from '../../search-manager';
import { SidebarAdditionalActions, useLibraryContext } from '../common/context';
@@ -43,17 +42,12 @@ export const ComponentMenu = ({ usageKey }: { usageKey: string }) => {
const canEdit = usageKey && canEditComponent(usageKey);
const { showToast } = useContext(ToastContext);
const [clipboardBroadcastChannel] = useState(() => new BroadcastChannel(STUDIO_CLIPBOARD_CHANNEL));
const removeComponentsMutation = useRemoveComponentsFromCollection(libraryId, collectionId);
const [isConfirmingDelete, confirmDelete, cancelDelete] = useToggle(false);
const { copyToClipboard } = useClipboard();
const updateClipboardClick = () => {
updateClipboard(usageKey)
.then((clipboardData) => {
clipboardBroadcastChannel.postMessage(clipboardData);
showToast(intl.formatMessage(messages.copyToClipboardSuccess));
})
.catch(() => showToast(intl.formatMessage(messages.copyToClipboardError)));
copyToClipboard(usageKey);
};
const removeFromCollection = () => {

View File

@@ -51,16 +51,6 @@ const messages = defineMessages({
defaultMessage: 'Failed to remove Component',
description: 'Message for failure of removal of component from collection.',
},
copyToClipboardSuccess: {
id: 'course-authoring.library-authoring.component.copyToClipboardSuccess',
defaultMessage: 'Component copied to clipboard',
description: 'Message for successful copy component to clipboard.',
},
copyToClipboardError: {
id: 'course-authoring.library-authoring.component.copyToClipboardError',
defaultMessage: 'Failed to copy component to clipboard',
description: 'Message for failed to copy component to clipboard.',
},
deleteComponentWarningTitle: {
id: 'course-authoring.library-authoring.component.delete-confirmation-title',
defaultMessage: 'Delete Component',

View File

@@ -1,18 +1,20 @@
import React from 'react';
import MockAdapter from 'axios-mock-adapter';
import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { AppProvider } from '@edx/frontend-platform/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { fireEvent, render, waitFor } from '@testing-library/react';
import type MockAdapter from 'axios-mock-adapter';
import userEvent from '@testing-library/user-event';
import initializeStore from '../../store';
import {
act,
fireEvent,
initializeMocks,
render,
screen,
waitFor,
} from '../../testUtils';
import { studioHomeMock } from '../../studio-home/__mocks__';
import { getStudioHomeApiUrl } from '../../studio-home/data/api';
import { CreateLibrary } from '.';
import { getContentLibraryV2CreateApiUrl } from './data/api';
let store;
const mockNavigate = jest.fn();
let axiosMock: MockAdapter;
@@ -29,66 +31,38 @@ jest.mock('../../generic/data/apiHooks', () => ({
}),
}));
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
const RootWrapper = () => (
<AppProvider store={store}>
<IntlProvider locale="en" messages={{}}>
<QueryClientProvider client={queryClient}>
<CreateLibrary />
</QueryClientProvider>
</IntlProvider>
</AppProvider>
);
describe('<CreateLibrary />', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock = initializeMocks().axiosMock;
});
afterEach(() => {
jest.clearAllMocks();
axiosMock.restore();
queryClient.clear();
});
test('call api data with correct data', async () => {
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, studioHomeMock);
axiosMock.onPost(getContentLibraryV2CreateApiUrl()).reply(200, {
id: 'library-id',
});
const { getByRole } = render(<RootWrapper />);
render(<CreateLibrary />);
const titleInput = getByRole('textbox', { name: /library name/i });
const titleInput = await screen.findByRole('textbox', { name: /library name/i });
userEvent.click(titleInput);
userEvent.type(titleInput, 'Test Library Name');
const orgInput = getByRole('combobox', { name: /organization/i });
const orgInput = await screen.findByRole('combobox', { name: /organization/i });
userEvent.click(orgInput);
userEvent.type(orgInput, 'org1');
userEvent.tab();
act(() => userEvent.tab());
const slugInput = getByRole('textbox', { name: /library id/i });
const slugInput = await screen.findByRole('textbox', { name: /library id/i });
userEvent.click(slugInput);
userEvent.type(slugInput, 'test_library_slug');
fireEvent.click(getByRole('button', { name: /create/i }));
fireEvent.click(await screen.findByRole('button', { name: /create/i }));
await waitFor(() => {
expect(axiosMock.history.post.length).toBe(1);
expect(axiosMock.history.post[0].data).toBe(
@@ -98,41 +72,115 @@ describe('<CreateLibrary />', () => {
});
});
test('show api error', async () => {
axiosMock.onPost(getContentLibraryV2CreateApiUrl()).reply(400, {
field: 'Error message',
test('cannot create new org unless allowed', async () => {
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, studioHomeMock);
axiosMock.onPost(getContentLibraryV2CreateApiUrl()).reply(200, {
id: 'library-id',
});
const { getByRole, getByTestId } = render(<RootWrapper />);
const titleInput = getByRole('textbox', { name: /library name/i });
render(<CreateLibrary />);
const titleInput = await screen.findByRole('textbox', { name: /library name/i });
userEvent.click(titleInput);
userEvent.type(titleInput, 'Test Library Name');
const orgInput = getByTestId('autosuggest-textbox-input');
userEvent.click(orgInput);
userEvent.type(orgInput, 'org1');
userEvent.tab();
// We cannot create a new org, and so we're restricted to the allowed list
const orgOptions = screen.getByTestId('autosuggest-iconbutton');
userEvent.click(orgOptions);
expect(screen.getByText('org1')).toBeInTheDocument();
['org2', 'org3', 'org4', 'org5'].forEach((org) => expect(screen.queryByText(org)).not.toBeInTheDocument());
const slugInput = getByRole('textbox', { name: /library id/i });
const orgInput = await screen.findByRole('combobox', { name: /organization/i });
userEvent.click(orgInput);
userEvent.type(orgInput, 'NewOrg');
act(() => userEvent.tab());
const slugInput = await screen.findByRole('textbox', { name: /library id/i });
userEvent.click(slugInput);
userEvent.type(slugInput, 'test_library_slug');
fireEvent.click(getByRole('button', { name: /create/i }));
fireEvent.click(await screen.findByRole('button', { name: /create/i }));
await waitFor(() => {
expect(axiosMock.history.post.length).toBe(0);
});
expect(await screen.findByText('Required field.')).toBeInTheDocument();
});
test('can create new org if allowed', async () => {
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, {
...studioHomeMock,
allow_to_create_new_org: true,
});
axiosMock.onPost(getContentLibraryV2CreateApiUrl()).reply(200, {
id: 'library-id',
});
render(<CreateLibrary />);
const titleInput = await screen.findByRole('textbox', { name: /library name/i });
userEvent.click(titleInput);
userEvent.type(titleInput, 'Test Library Name');
// We can create a new org, so we're also allowed to use any existing org
const orgOptions = screen.getByTestId('autosuggest-iconbutton');
userEvent.click(orgOptions);
['org1', 'org2', 'org3', 'org4', 'org5'].forEach((org) => expect(screen.queryByText(org)).toBeInTheDocument());
const orgInput = await screen.findByRole('combobox', { name: /organization/i });
userEvent.click(orgInput);
userEvent.type(orgInput, 'NewOrg');
act(() => userEvent.tab());
const slugInput = await screen.findByRole('textbox', { name: /library id/i });
userEvent.click(slugInput);
userEvent.type(slugInput, 'test_library_slug');
fireEvent.click(await screen.findByRole('button', { name: /create/i }));
await waitFor(() => {
expect(axiosMock.history.post.length).toBe(1);
expect(axiosMock.history.post[0].data).toBe(
'{"description":"","title":"Test Library Name","org":"NewOrg","slug":"test_library_slug"}',
);
expect(mockNavigate).toHaveBeenCalledWith('/library/library-id');
});
});
test('show api error', async () => {
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, studioHomeMock);
axiosMock.onPost(getContentLibraryV2CreateApiUrl()).reply(400, {
field: 'Error message',
});
render(<CreateLibrary />);
const titleInput = await screen.findByRole('textbox', { name: /library name/i });
userEvent.click(titleInput);
userEvent.type(titleInput, 'Test Library Name');
const orgInput = await screen.findByTestId('autosuggest-textbox-input');
userEvent.click(orgInput);
userEvent.type(orgInput, 'org1');
act(() => userEvent.tab());
const slugInput = await screen.findByRole('textbox', { name: /library id/i });
userEvent.click(slugInput);
userEvent.type(slugInput, 'test_library_slug');
fireEvent.click(await screen.findByRole('button', { name: /create/i }));
await waitFor(async () => {
expect(axiosMock.history.post.length).toBe(1);
expect(axiosMock.history.post[0].data).toBe(
'{"description":"","title":"Test Library Name","org":"org1","slug":"test_library_slug"}',
);
expect(mockNavigate).not.toHaveBeenCalled();
expect(getByRole('alert')).toHaveTextContent('Request failed with status code 400');
expect(getByRole('alert')).toHaveTextContent('{"field":"Error message"}');
expect(await screen.findByRole('alert')).toHaveTextContent('Request failed with status code 400');
expect(await screen.findByRole('alert')).toHaveTextContent('{"field":"Error message"}');
});
});
test('cancel creating library navigates to libraries page', async () => {
const { getByRole } = render(<RootWrapper />);
render(<CreateLibrary />);
fireEvent.click(getByRole('button', { name: /cancel/i }));
fireEvent.click(await screen.findByRole('button', { name: /cancel/i }));
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith('/libraries');
});

View File

@@ -19,6 +19,7 @@ import FormikErrorFeedback from '../../generic/FormikErrorFeedback';
import AlertError from '../../generic/alert-error';
import { useOrganizationListData } from '../../generic/data/apiHooks';
import SubHeader from '../../generic/sub-header/SubHeader';
import { useStudioHome } from '../../studio-home/hooks';
import { useCreateLibraryV2 } from './data/apiHooks';
import messages from './messages';
@@ -38,10 +39,23 @@ const CreateLibrary = () => {
} = useCreateLibraryV2();
const {
data: organizationListData,
data: allOrganizations,
isLoading: isOrganizationListLoading,
} = useOrganizationListData();
const {
studioHomeData: {
allowedOrganizationsForLibraries,
allowToCreateNewOrg,
},
} = useStudioHome();
const organizations = (
allowToCreateNewOrg
? allOrganizations
: allowedOrganizationsForLibraries
) || [];
const handleOnClickCancel = () => {
navigate('/libraries');
};
@@ -100,12 +114,17 @@ const CreateLibrary = () => {
<Form.Autosuggest
name="org"
isLoading={isOrganizationListLoading}
onChange={(event) => formikProps.setFieldValue('org', event.selectionId)}
onChange={(event) => formikProps.setFieldValue(
'org',
allowToCreateNewOrg
? (event.selectionId || event.userProvidedText)
: event.selectionId,
)}
placeholder={intl.formatMessage(messages.orgPlaceholder)}
>
{organizationListData ? organizationListData.map((org) => (
{organizations.map((org) => (
<Form.AutosuggestOption key={org} id={org}>{org}</Form.AutosuggestOption>
)) : []}
))}
</Form.Autosuggest>
<FormikErrorFeedback name="org">
<Form.Text>{intl.formatMessage(messages.orgHelp)}</Form.Text>

View File

@@ -26,7 +26,7 @@ module.exports = {
cmsLink: '//localhost:18010/courses/course-v1:Design+123+e.g.2025',
},
],
canCreateOrganizations: true,
canCreateOrganizations: false,
courseCreatorStatus: 'granted',
courses: [
{
@@ -76,4 +76,5 @@ module.exports = {
platformName: 'Your Platform Name Here',
userIsActive: true,
allowToCreateNewOrg: false,
allowedOrganizationsForLibraries: ['org1'],
};

View File

@@ -116,7 +116,7 @@ const RouterAndRoute: React.FC<RouteOptions> = ({
);
};
function makeWrapper({ extraWrapper, ...routeArgs }: WrapperOptions & RouteOptions) {
function makeWrapper({ extraWrapper, ...routeArgs }: WrapperOptions & RouteOptions = {}) {
const AllTheProviders = ({ children }) => (
<AppProvider store={reduxStore} wrapWithRouter={false}>
<IntlProvider locale="en" messages={{}}>
@@ -192,7 +192,7 @@ export function initializeMocks({ user = defaultUser, initialState = undefined }
}
export * from '@testing-library/react';
export { customRender as render };
export { customRender as render, makeWrapper };
/** Simulate a real Axios error (such as we'd see in response to a 404) */
export function createAxiosError({ code, message, path }: { code: number, message: string, path: string }) {