Compare commits
27 Commits
master
...
open-relea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fea78afa85 | ||
|
|
6841e94ab0 | ||
|
|
4f04698a60 | ||
|
|
82a76b0cad | ||
|
|
62445a18f3 | ||
|
|
91ee5004a4 | ||
|
|
e0ec87c969 | ||
|
|
4835f72f2c | ||
|
|
3ab329d373 | ||
|
|
7c97ffecb5 | ||
|
|
90727590dd | ||
|
|
1c82a67364 | ||
|
|
d08ef83659 | ||
|
|
13bce7e034 | ||
|
|
54888d03bc | ||
|
|
e6d9f3a50d | ||
|
|
74b455287e | ||
|
|
e2adb45493 | ||
|
|
d4e9a6bec2 | ||
|
|
e6741496dc | ||
|
|
9304a83bef | ||
|
|
3173f41e63 | ||
|
|
866dd9bd31 | ||
|
|
f10ad9f525 | ||
|
|
81d78b9613 | ||
|
|
4886df7d6f | ||
|
|
62dfb75169 |
22
package-lock.json
generated
22
package-lock.json
generated
@@ -64,8 +64,8 @@
|
||||
"react-onclickoutside": "^6.13.0",
|
||||
"react-redux": "7.2.9",
|
||||
"react-responsive": "9.0.2",
|
||||
"react-router": "6.23.1",
|
||||
"react-router-dom": "6.23.1",
|
||||
"react-router": "6.27.0",
|
||||
"react-router-dom": "6.27.0",
|
||||
"react-select": "5.8.0",
|
||||
"react-textarea-autosize": "^8.5.3",
|
||||
"react-transition-group": "4.4.5",
|
||||
@@ -4275,7 +4275,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@remix-run/router": {
|
||||
"version": "1.16.1",
|
||||
"version": "1.20.0",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.20.0.tgz",
|
||||
"integrity": "sha512-mUnk8rPJBI9loFDZ+YzPGdeniYK+FTmRD1TMCz7ev2SNIozyKKpnGgsxO34u6Z4z/t0ITuu7voi/AshfsGsgFg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
@@ -17514,10 +17516,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "6.23.1",
|
||||
"version": "6.27.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.27.0.tgz",
|
||||
"integrity": "sha512-YA+HGZXz4jaAkVoYBE98VQl+nVzI+cVI2Oj/06F5ZM+0u3TgedN9Y9kmMRo2mnkSK2nCpNQn0DVob4HCsY/WLw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.16.1"
|
||||
"@remix-run/router": "1.20.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
@@ -17527,11 +17531,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "6.23.1",
|
||||
"version": "6.27.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.27.0.tgz",
|
||||
"integrity": "sha512-+bvtFWMC0DgAFrfKXKG9Fc+BcXWRUO1aJIihbB79xaeq0v5UzfvnM5houGUm1Y461WVRcgAQ+Clh5rdb1eCx4g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.16.1",
|
||||
"react-router": "6.23.1"
|
||||
"@remix-run/router": "1.20.0",
|
||||
"react-router": "6.27.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
|
||||
@@ -93,8 +93,8 @@
|
||||
"react-onclickoutside": "^6.13.0",
|
||||
"react-redux": "7.2.9",
|
||||
"react-responsive": "9.0.2",
|
||||
"react-router": "6.23.1",
|
||||
"react-router-dom": "6.23.1",
|
||||
"react-router": "6.27.0",
|
||||
"react-router-dom": "6.27.0",
|
||||
"react-select": "5.8.0",
|
||||
"react-textarea-autosize": "^8.5.3",
|
||||
"react-transition-group": "4.4.5",
|
||||
|
||||
@@ -104,7 +104,6 @@ const CourseOutline = ({ courseId }) => {
|
||||
handleNewUnitSubmit,
|
||||
getUnitUrl,
|
||||
handleVideoSharingOptionChange,
|
||||
handleCopyToClipboardClick,
|
||||
handlePasteClipboardClick,
|
||||
notificationDismissUrl,
|
||||
discussionsSettings,
|
||||
@@ -125,7 +124,8 @@ const CourseOutline = ({ courseId }) => {
|
||||
const [toastMessage, setToastMessage] = useState(/** @type{null|string} */ (null));
|
||||
|
||||
useEffect(() => {
|
||||
if (location.hash === '#export-tags') {
|
||||
// Wait for the course data to load before exporting tags.
|
||||
if (courseId && courseName && location.hash === '#export-tags') {
|
||||
setToastMessage(intl.formatMessage(messages.exportTagsCreatingToastMessage));
|
||||
getTagsExportFile(courseId, courseName).then(() => {
|
||||
setToastMessage(intl.formatMessage(messages.exportTagsSuccessToastMessage));
|
||||
@@ -136,7 +136,7 @@ const CourseOutline = ({ courseId }) => {
|
||||
// Delete `#export-tags` from location
|
||||
window.location.href = '#';
|
||||
}
|
||||
}, [location]);
|
||||
}, [location, courseId, courseName]);
|
||||
|
||||
const [sections, setSections] = useState(sectionsList);
|
||||
|
||||
@@ -396,7 +396,6 @@ const CourseOutline = ({ courseId }) => {
|
||||
onDuplicateSubmit={handleDuplicateUnitSubmit}
|
||||
getTitleLink={getUnitUrl}
|
||||
onOrderChange={updateUnitOrderByIndex}
|
||||
onCopyToClipboardClick={handleCopyToClipboardClick}
|
||||
discussionsSettings={discussionsSettings}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -2182,9 +2182,6 @@ describe('<CourseOutline />', () => {
|
||||
.onPost(getClipboardUrl(), {
|
||||
usage_key: unit.id,
|
||||
}).reply(200, clipboardUnit);
|
||||
// check that initialUserClipboard state is empty
|
||||
const { initialUserClipboard } = store.getState().courseOutline;
|
||||
expect(initialUserClipboard).toBeUndefined();
|
||||
|
||||
// find menu button and click on it to open menu
|
||||
const menu = await within(unitElement).findByTestId('unit-card-header__menu-button');
|
||||
@@ -2194,9 +2191,6 @@ describe('<CourseOutline />', () => {
|
||||
const copyButton = await within(unitElement).findByText(cardHeaderMessages.menuCopy.defaultMessage);
|
||||
await act(async () => fireEvent.click(copyButton));
|
||||
|
||||
// check that initialUserClipboard state is updated
|
||||
expect(store.getState().generic.clipboardData).toEqual(clipboardUnit);
|
||||
|
||||
[subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
|
||||
// find clipboard content label
|
||||
const clipboardLabel = await within(subsectionElement).findByText(
|
||||
|
||||
@@ -28,7 +28,6 @@ export const getCourseReindexApiUrl = (reindexLink) => `${getApiBaseUrl()}${rein
|
||||
export const getXBlockBaseApiUrl = () => `${getApiBaseUrl()}/xblock/`;
|
||||
export const getCourseItemApiUrl = (itemId) => `${getXBlockBaseApiUrl()}${itemId}`;
|
||||
export const getXBlockApiUrl = (blockId) => `${getXBlockBaseApiUrl()}outline/${blockId}`;
|
||||
export const getClipboardUrl = () => `${getApiBaseUrl()}/api/content-staging/v1/clipboard/`;
|
||||
export const exportTags = (courseId) => `${getApiBaseUrl()}/api/content_tagging/v1/object_tags/${courseId}/export/`;
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import { updateClipboardData } from '../../generic/data/slice';
|
||||
import { NOTIFICATION_MESSAGES } from '../../constants';
|
||||
import { API_ERROR_TYPES, COURSE_BLOCK_NAMES } from '../constants';
|
||||
import {
|
||||
@@ -88,7 +87,6 @@ export function fetchCourseOutlineIndexQuery(courseId) {
|
||||
},
|
||||
} = outlineIndex;
|
||||
dispatch(fetchOutlineIndexSuccess(outlineIndex));
|
||||
dispatch(updateClipboardData(outlineIndex.initialUserClipboard));
|
||||
dispatch(updateStatusBar({
|
||||
courseReleaseDate,
|
||||
highlightsEnabledForMessaging,
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { useToggle } from '@openedx/paragon';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
import { copyToClipboard } from '../generic/data/thunks';
|
||||
import { getSavingStatus as getGenericSavingStatus } from '../generic/data/selectors';
|
||||
import { RequestStatus } from '../data/constants';
|
||||
import { COURSE_BLOCK_NAMES } from './constants';
|
||||
@@ -72,6 +71,7 @@ const useCourseOutline = ({ courseId }) => {
|
||||
mfeProctoredExamSettingsUrl,
|
||||
advanceSettingsUrl,
|
||||
} = useSelector(getOutlineIndexData);
|
||||
|
||||
const { outlineIndexLoadingStatus, reIndexLoadingStatus } = useSelector(getLoadingStatus);
|
||||
const statusBarData = useSelector(getStatusBarData);
|
||||
const savingStatus = useSelector(getSavingStatus);
|
||||
@@ -95,10 +95,6 @@ const useCourseOutline = ({ courseId }) => {
|
||||
|
||||
const isSavingStatusFailed = savingStatus === RequestStatus.FAILED || genericSavingStatus === RequestStatus.FAILED;
|
||||
|
||||
const handleCopyToClipboardClick = (usageKey) => {
|
||||
dispatch(copyToClipboard(usageKey));
|
||||
};
|
||||
|
||||
const handlePasteClipboardClick = (parentLocator, sectionId) => {
|
||||
dispatch(pasteClipboardContent(parentLocator, sectionId));
|
||||
};
|
||||
@@ -339,7 +335,6 @@ const useCourseOutline = ({ courseId }) => {
|
||||
openUnitPage,
|
||||
handleNewUnitSubmit,
|
||||
handleVideoSharingOptionChange,
|
||||
handleCopyToClipboardClick,
|
||||
handlePasteClipboardClick,
|
||||
notificationDismissUrl,
|
||||
discussionsSettings,
|
||||
|
||||
@@ -16,7 +16,7 @@ import { RequestStatus } from '../../data/constants';
|
||||
import CardHeader from '../card-header/CardHeader';
|
||||
import SortableItem from '../../generic/drag-helper/SortableItem';
|
||||
import { DragContext } from '../../generic/drag-helper/DragContextProvider';
|
||||
import { useCopyToClipboard, PasteComponent } from '../../generic/clipboard';
|
||||
import { useClipboard, PasteComponent } from '../../generic/clipboard';
|
||||
import TitleButton from '../card-header/TitleButton';
|
||||
import XBlockStatus from '../xblock-status/XBlockStatus';
|
||||
import { getItemStatus, getItemStatusBorder, scrollToElement } from '../utils';
|
||||
@@ -49,7 +49,7 @@ const SubsectionCard = ({
|
||||
const isScrolledToElement = locatorId === subsection.id;
|
||||
const [isFormOpen, openForm, closeForm] = useToggle(false);
|
||||
const namePrefix = 'subsection';
|
||||
const { sharedClipboardData, showPasteUnit } = useCopyToClipboard();
|
||||
const { sharedClipboardData, showPasteUnit } = useClipboard();
|
||||
|
||||
const {
|
||||
id,
|
||||
@@ -233,7 +233,7 @@ const SubsectionCard = ({
|
||||
>
|
||||
{intl.formatMessage(messages.newUnitButton)}
|
||||
</Button>
|
||||
{enableCopyPasteUnits && showPasteUnit && (
|
||||
{enableCopyPasteUnits && showPasteUnit && sharedClipboardData && (
|
||||
<PasteComponent
|
||||
className="mt-4"
|
||||
text={intl.formatMessage(messages.pasteButton)}
|
||||
|
||||
@@ -13,6 +13,7 @@ import SortableItem from '../../generic/drag-helper/SortableItem';
|
||||
import TitleLink from '../card-header/TitleLink';
|
||||
import XBlockStatus from '../xblock-status/XBlockStatus';
|
||||
import { getItemStatus, getItemStatusBorder, scrollToElement } from '../utils';
|
||||
import { useClipboard } from '../../generic/clipboard';
|
||||
|
||||
const UnitCard = ({
|
||||
unit,
|
||||
@@ -30,7 +31,6 @@ const UnitCard = ({
|
||||
onDuplicateSubmit,
|
||||
getTitleLink,
|
||||
onOrderChange,
|
||||
onCopyToClipboardClick,
|
||||
discussionsSettings,
|
||||
}) => {
|
||||
const currentRef = useRef(null);
|
||||
@@ -41,6 +41,8 @@ const UnitCard = ({
|
||||
const [isFormOpen, openForm, closeForm] = useToggle(false);
|
||||
const namePrefix = 'unit';
|
||||
|
||||
const { copyToClipboard } = useClipboard();
|
||||
|
||||
const {
|
||||
id,
|
||||
category,
|
||||
@@ -98,7 +100,7 @@ const UnitCard = ({
|
||||
};
|
||||
|
||||
const handleCopyClick = () => {
|
||||
onCopyToClipboardClick(unit.id);
|
||||
copyToClipboard(id);
|
||||
};
|
||||
|
||||
const titleComponent = (
|
||||
@@ -241,7 +243,6 @@ UnitCard.propTypes = {
|
||||
onOrderChange: PropTypes.func.isRequired,
|
||||
isSelfPaced: PropTypes.bool.isRequired,
|
||||
isCustomRelativeDatesActive: PropTypes.bool.isRequired,
|
||||
onCopyToClipboardClick: PropTypes.func.isRequired,
|
||||
discussionsSettings: PropTypes.shape({
|
||||
providerType: PropTypes.string,
|
||||
enableGradedUnits: PropTypes.bool,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
act, render, fireEvent, within,
|
||||
} from '@testing-library/react';
|
||||
@@ -48,6 +47,13 @@ const unit = {
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
const clipboardBroadcastChannelMock = {
|
||||
postMessage: jest.fn(),
|
||||
close: jest.fn(),
|
||||
};
|
||||
|
||||
global.BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
|
||||
|
||||
const renderComponent = (props) => render(
|
||||
<AppProvider store={store}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
@@ -62,7 +68,6 @@ const renderComponent = (props) => render(
|
||||
onOpenPublishModal={jest.fn()}
|
||||
onOpenDeleteModal={jest.fn()}
|
||||
onOpenConfigureModal={jest.fn()}
|
||||
onCopyToClipboardClick={jest.fn()}
|
||||
savingStatus=""
|
||||
onEditSubmit={jest.fn()}
|
||||
onDuplicateSubmit={jest.fn()}
|
||||
|
||||
@@ -185,7 +185,9 @@ const CourseUnit = ({ courseId }) => {
|
||||
{showPasteXBlock && canPasteComponent && (
|
||||
<PasteComponent
|
||||
clipboardData={sharedClipboardData}
|
||||
onClick={handleCreateNewCourseXBlock}
|
||||
onClick={
|
||||
() => handleCreateNewCourseXBlock({ stagedContent: 'clipboard', parentLocator: blockId })
|
||||
}
|
||||
text={intl.formatMessage(messages.pasteButtonText)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import {
|
||||
act, render, waitFor, fireEvent, within, screen,
|
||||
} from '@testing-library/react';
|
||||
@@ -38,10 +39,7 @@ import {
|
||||
courseVerticalChildrenMock,
|
||||
clipboardMockResponse,
|
||||
} from './__mocks__';
|
||||
import {
|
||||
clipboardUnit,
|
||||
clipboardXBlock,
|
||||
} from '../__mocks__';
|
||||
import { clipboardUnit, clipboardXBlock } from '../__mocks__';
|
||||
import { executeThunk } from '../utils';
|
||||
import deleteModalMessages from '../generic/delete-modal/messages';
|
||||
import pasteComponentMessages from '../generic/clipboard/paste-component/messages';
|
||||
@@ -54,6 +52,7 @@ import { extractCourseUnitId } from './sidebar/utils';
|
||||
import CourseUnit from './CourseUnit';
|
||||
|
||||
import configureModalMessages from '../generic/configure-modal/messages';
|
||||
import { getClipboardUrl } from '../generic/data/api';
|
||||
import courseXBlockMessages from './course-xblock/messages';
|
||||
import addComponentMessages from './add-component/messages';
|
||||
import { PUBLISH_TYPES, UNIT_VISIBILITY_STATES } from './constants';
|
||||
@@ -63,6 +62,7 @@ import { RequestStatus } from '../data/constants';
|
||||
|
||||
let axiosMock;
|
||||
let store;
|
||||
let queryClient;
|
||||
const courseId = '123';
|
||||
const blockId = '567890';
|
||||
const unitDisplayName = courseUnitIndexMock.metadata.display_name;
|
||||
@@ -80,31 +80,6 @@ jest.mock('react-router-dom', () => ({
|
||||
useNavigate: () => mockedUsedNavigate,
|
||||
}));
|
||||
|
||||
jest.mock('@tanstack/react-query', () => ({
|
||||
useQuery: jest.fn(({ queryKey }) => {
|
||||
if (queryKey[0] === 'contentTaxonomyTags') {
|
||||
return {
|
||||
data: {
|
||||
taxonomies: [],
|
||||
},
|
||||
isSuccess: true,
|
||||
};
|
||||
} if (queryKey[0] === 'contentTagsCount') {
|
||||
return {
|
||||
data: 17,
|
||||
isSuccess: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
data: {},
|
||||
isSuccess: true,
|
||||
};
|
||||
}),
|
||||
useQueryClient: jest.fn(() => ({
|
||||
setQueryData: jest.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
const clipboardBroadcastChannelMock = {
|
||||
postMessage: jest.fn(),
|
||||
close: jest.fn(),
|
||||
@@ -115,7 +90,9 @@ global.BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
|
||||
const RootWrapper = () => (
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en">
|
||||
<CourseUnit courseId={courseId} />
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CourseUnit courseId={courseId} />
|
||||
</QueryClientProvider>
|
||||
</IntlProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
@@ -132,7 +109,17 @@ describe('<CourseUnit />', () => {
|
||||
});
|
||||
global.localStorage.clear();
|
||||
store = initializeStore();
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
axiosMock
|
||||
.onGet(getClipboardUrl())
|
||||
.reply(200, clipboardUnit);
|
||||
axiosMock
|
||||
.onGet(getCourseUnitApiUrl(courseId))
|
||||
.reply(200, courseUnitIndexMock);
|
||||
@@ -147,7 +134,7 @@ describe('<CourseUnit />', () => {
|
||||
await executeThunk(fetchCourseVerticalChildrenData(blockId), store.dispatch);
|
||||
axiosMock
|
||||
.onGet(getContentTaxonomyTagsApiUrl(blockId))
|
||||
.reply(200, {});
|
||||
.reply(200, { taxonomies: [] });
|
||||
axiosMock
|
||||
.onGet(getContentTaxonomyTagsCountApiUrl(blockId))
|
||||
.reply(200, 17);
|
||||
@@ -1087,19 +1074,16 @@ describe('<CourseUnit />', () => {
|
||||
queryByTestId, getByRole, getAllByLabelText, getByText,
|
||||
} = render(<RootWrapper />);
|
||||
|
||||
axiosMock
|
||||
.onGet(getClipboardUrl())
|
||||
.reply(200, clipboardXBlock);
|
||||
|
||||
await waitFor(() => {
|
||||
const [xblockActionBtn] = getAllByLabelText(courseXBlockMessages.blockActionsDropdownAlt.defaultMessage);
|
||||
userEvent.click(xblockActionBtn);
|
||||
userEvent.click(getByRole('button', { name: courseXBlockMessages.blockLabelButtonCopyToClipboard.defaultMessage }));
|
||||
});
|
||||
|
||||
axiosMock
|
||||
.onGet(getCourseSectionVerticalApiUrl(blockId))
|
||||
.reply(200, {
|
||||
...courseSectionVerticalMock,
|
||||
user_clipboard: clipboardXBlock,
|
||||
});
|
||||
|
||||
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
|
||||
expect(getByRole('button', { name: messages.pasteButtonText.defaultMessage })).toBeInTheDocument();
|
||||
|
||||
@@ -1141,11 +1125,8 @@ describe('<CourseUnit />', () => {
|
||||
});
|
||||
|
||||
axiosMock
|
||||
.onGet(getCourseSectionVerticalApiUrl(blockId))
|
||||
.reply(200, {
|
||||
...courseSectionVerticalMock,
|
||||
user_clipboard: clipboardXBlock,
|
||||
});
|
||||
.onGet(getClipboardUrl())
|
||||
.reply(200, clipboardXBlock);
|
||||
|
||||
axiosMock
|
||||
.onGet(getCourseUnitApiUrl(courseId))
|
||||
@@ -1197,11 +1178,8 @@ describe('<CourseUnit />', () => {
|
||||
});
|
||||
|
||||
axiosMock
|
||||
.onGet(getCourseSectionVerticalApiUrl(blockId))
|
||||
.reply(200, {
|
||||
...courseSectionVerticalMock,
|
||||
user_clipboard: clipboardXBlock,
|
||||
});
|
||||
.onGet(getClipboardUrl())
|
||||
.reply(200, clipboardXBlock);
|
||||
|
||||
axiosMock
|
||||
.onGet(getCourseUnitApiUrl(courseId))
|
||||
@@ -1228,13 +1206,6 @@ describe('<CourseUnit />', () => {
|
||||
enable_copy_paste_units: true,
|
||||
});
|
||||
|
||||
axiosMock
|
||||
.onGet(getCourseSectionVerticalApiUrl(blockId))
|
||||
.reply(200, {
|
||||
...courseSectionVerticalMock,
|
||||
user_clipboard: clipboardUnit,
|
||||
});
|
||||
|
||||
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
|
||||
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
|
||||
|
||||
@@ -1288,13 +1259,6 @@ describe('<CourseUnit />', () => {
|
||||
enable_copy_paste_units: true,
|
||||
});
|
||||
|
||||
axiosMock
|
||||
.onGet(getCourseSectionVerticalApiUrl(blockId))
|
||||
.reply(200, {
|
||||
...courseSectionVerticalMock,
|
||||
user_clipboard: clipboardUnit,
|
||||
});
|
||||
|
||||
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
|
||||
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
|
||||
|
||||
@@ -1349,13 +1313,6 @@ describe('<CourseUnit />', () => {
|
||||
enable_copy_paste_units: true,
|
||||
});
|
||||
|
||||
axiosMock
|
||||
.onGet(getCourseSectionVerticalApiUrl(blockId))
|
||||
.reply(200, {
|
||||
...courseSectionVerticalMock,
|
||||
user_clipboard: clipboardUnit,
|
||||
});
|
||||
|
||||
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
|
||||
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
|
||||
|
||||
@@ -1410,13 +1367,6 @@ describe('<CourseUnit />', () => {
|
||||
enable_copy_paste_units: true,
|
||||
});
|
||||
|
||||
axiosMock
|
||||
.onGet(getCourseSectionVerticalApiUrl(blockId))
|
||||
.reply(200, {
|
||||
...courseSectionVerticalMock,
|
||||
user_clipboard: clipboardUnit,
|
||||
});
|
||||
|
||||
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
|
||||
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
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';
|
||||
import { EditOutline as EditIcon, MoreVert as MoveVertIcon } from '@openedx/paragon/icons';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
import { getCanEdit, getCourseId } from 'CourseAuthoring/course-unit/data/selectors';
|
||||
import DeleteModal from '../../generic/delete-modal/DeleteModal';
|
||||
@@ -15,10 +15,11 @@ 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';
|
||||
import { createCorrectInternalRoute } from '../../utils';
|
||||
|
||||
const CourseXBlock = ({
|
||||
id, title, type, unitXBlockActions, shouldScroll, userPartitionInfo,
|
||||
@@ -27,8 +28,6 @@ const CourseXBlock = ({
|
||||
const courseXBlockElementRef = useRef(null);
|
||||
const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggle(false);
|
||||
const [isConfigureModalOpen, openConfigureModal, closeConfigureModal] = useToggle(false);
|
||||
const dispatch = useDispatch();
|
||||
const navigate = useNavigate();
|
||||
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();
|
||||
@@ -58,7 +59,11 @@ const CourseXBlock = ({
|
||||
case COMPONENT_TYPES.html:
|
||||
case COMPONENT_TYPES.problem:
|
||||
case COMPONENT_TYPES.video:
|
||||
navigate(`/course/${courseId}/editor/${type}/${id}`);
|
||||
// Not using useNavigate from react router to use browser navigation
|
||||
// which allows us to block back button if unsaved changes in editor are present.
|
||||
window.location.assign(
|
||||
createCorrectInternalRoute(`/course/${courseId}/editor/${type}/${id}`),
|
||||
);
|
||||
break;
|
||||
default:
|
||||
}
|
||||
@@ -116,7 +121,7 @@ const CourseXBlock = ({
|
||||
{intl.formatMessage(messages.blockLabelButtonMove)}
|
||||
</Dropdown.Item>
|
||||
{canEdit && (
|
||||
<Dropdown.Item onClick={() => dispatch(copyToClipboard(id))}>
|
||||
<Dropdown.Item onClick={() => copyToClipboard(id)}>
|
||||
{intl.formatMessage(messages.blockLabelButtonCopyToClipboard)}
|
||||
</Dropdown.Item>
|
||||
)}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import {
|
||||
render, waitFor, within,
|
||||
} from '@testing-library/react';
|
||||
@@ -24,12 +25,20 @@ 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();
|
||||
const handleDuplicateMock = jest.fn();
|
||||
const handleConfigureSubmitMock = jest.fn();
|
||||
const mockedUsedNavigate = jest.fn();
|
||||
const {
|
||||
name,
|
||||
block_id: id,
|
||||
@@ -42,11 +51,6 @@ const unitXBlockActionsMock = {
|
||||
handleDuplicate: handleDuplicateMock,
|
||||
};
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useNavigate: () => mockedUsedNavigate,
|
||||
}));
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
...jest.requireActual('react-redux'),
|
||||
useSelector: jest.fn(),
|
||||
@@ -55,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>,
|
||||
);
|
||||
@@ -78,6 +84,16 @@ useSelector.mockImplementation((selector) => {
|
||||
});
|
||||
|
||||
describe('<CourseXBlock />', () => {
|
||||
const locationTemp = window.location;
|
||||
beforeAll(() => {
|
||||
delete window.location;
|
||||
window.location = {
|
||||
assign: jest.fn(),
|
||||
};
|
||||
});
|
||||
afterAll(() => {
|
||||
window.location = locationTemp;
|
||||
});
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
@@ -94,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 () => {
|
||||
@@ -168,8 +185,8 @@ describe('<CourseXBlock />', () => {
|
||||
expect(editButton).toBeInTheDocument();
|
||||
|
||||
userEvent.click(editButton);
|
||||
expect(mockedUsedNavigate).toHaveBeenCalled();
|
||||
expect(mockedUsedNavigate).toHaveBeenCalledWith(`/course/${courseId}/editor/html/${id}`);
|
||||
expect(window.location.assign).toHaveBeenCalled();
|
||||
expect(window.location.assign).toHaveBeenCalledWith(`/course/${courseId}/editor/html/${id}`);
|
||||
});
|
||||
|
||||
it('navigates to editor page on edit Video xblock', () => {
|
||||
@@ -182,8 +199,8 @@ describe('<CourseXBlock />', () => {
|
||||
expect(editButton).toBeInTheDocument();
|
||||
|
||||
userEvent.click(editButton);
|
||||
expect(mockedUsedNavigate).toHaveBeenCalled();
|
||||
expect(mockedUsedNavigate).toHaveBeenCalledWith(`/course/${courseId}/editor/video/${id}`);
|
||||
expect(window.location.assign).toHaveBeenCalled();
|
||||
expect(window.location.assign).toHaveBeenCalledWith(`/course/${courseId}/editor/video/${id}`);
|
||||
});
|
||||
|
||||
it('navigates to editor page on edit Problem xblock', () => {
|
||||
@@ -196,8 +213,8 @@ describe('<CourseXBlock />', () => {
|
||||
expect(editButton).toBeInTheDocument();
|
||||
|
||||
userEvent.click(editButton);
|
||||
expect(mockedUsedNavigate).toHaveBeenCalled();
|
||||
expect(mockedUsedNavigate).toHaveBeenCalledWith(`/course/${courseId}/editor/problem/${id}`);
|
||||
expect(window.location.assign).toHaveBeenCalled();
|
||||
expect(window.location.assign).toHaveBeenCalledWith(`/course/${courseId}/editor/problem/${id}`);
|
||||
expect(handleDeleteMock).toHaveBeenCalledWith(id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,7 +8,6 @@ import { handleResponseErrors } from '../../generic/saving-error-alert';
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import { NOTIFICATION_MESSAGES } from '../../constants';
|
||||
import { updateModel, updateModels } from '../../generic/model-store';
|
||||
import { updateClipboardData } from '../../generic/data/slice';
|
||||
import {
|
||||
getCourseUnitData,
|
||||
editUnitDisplayName,
|
||||
@@ -75,7 +74,6 @@ export function fetchCourseSectionVerticalData(courseId, sequenceId) {
|
||||
}));
|
||||
dispatch(fetchStaticFileNoticesSuccess(JSON.parse(localStorage.getItem('staticFileNotices'))));
|
||||
localStorage.removeItem('staticFileNotices');
|
||||
dispatch(updateClipboardData(courseSectionVerticalData.userClipboard));
|
||||
dispatch(fetchSequenceSuccess({ sequenceId }));
|
||||
return true;
|
||||
} catch (error) {
|
||||
@@ -214,8 +212,6 @@ export function deleteUnitItemQuery(itemId, xblockId) {
|
||||
try {
|
||||
await deleteUnitItem(xblockId);
|
||||
dispatch(deleteXBlock(xblockId));
|
||||
const { userClipboard } = await getCourseSectionVerticalData(itemId);
|
||||
dispatch(updateClipboardData(userClipboard));
|
||||
const courseUnit = await getCourseUnitData(itemId);
|
||||
dispatch(fetchCourseItemSuccess(courseUnit));
|
||||
dispatch(hideProcessingNotification());
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
|
||||
import { RequestStatus } from '../data/constants';
|
||||
import { useClipboard } from '../generic/clipboard';
|
||||
import {
|
||||
createNewCourseXBlock,
|
||||
fetchCourseUnitQuery,
|
||||
@@ -28,8 +29,6 @@ import {
|
||||
import { changeEditTitleFormOpen, updateQueryPendingStatus } from './data/slice';
|
||||
import { PUBLISH_TYPES } from './constants';
|
||||
|
||||
import { useCopyToClipboard } from '../generic/clipboard';
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export const useCourseUnit = ({ courseId, blockId }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -47,7 +46,7 @@ export const useCourseUnit = ({ courseId, blockId }) => {
|
||||
const isTitleEditFormOpen = useSelector(state => state.courseUnit.isTitleEditFormOpen);
|
||||
const canEdit = useSelector(getCanEdit);
|
||||
const { currentlyVisibleToStudents } = courseUnit;
|
||||
const { sharedClipboardData, showPasteXBlock, showPasteUnit } = useCopyToClipboard(canEdit);
|
||||
const { sharedClipboardData, showPasteXBlock, showPasteUnit } = useClipboard(canEdit);
|
||||
const { canPasteComponent } = courseVerticalChildren;
|
||||
|
||||
const unitTitle = courseUnit.metadata?.displayName || '';
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Button } from '@openedx/paragon';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { Divider } from '../../../../generic/divider';
|
||||
import { getCanEdit, getCourseUnitData } from '../../../data/selectors';
|
||||
import { copyToClipboard } from '../../../../generic/data/thunks';
|
||||
import { useClipboard } from '../../../../generic/clipboard';
|
||||
import messages from '../../messages';
|
||||
|
||||
const ActionButtons = ({ openDiscardModal, handlePublishing }) => {
|
||||
const dispatch = useDispatch();
|
||||
const intl = useIntl();
|
||||
const {
|
||||
id,
|
||||
@@ -18,6 +17,7 @@ const ActionButtons = ({ openDiscardModal, handlePublishing }) => {
|
||||
enableCopyPasteUnits,
|
||||
} = useSelector(getCourseUnitData);
|
||||
const canEdit = useSelector(getCanEdit);
|
||||
const { copyToClipboard } = useClipboard();
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -40,7 +40,7 @@ const ActionButtons = ({ openDiscardModal, handlePublishing }) => {
|
||||
<>
|
||||
<Divider className="course-unit-sidebar-footer__divider" />
|
||||
<Button
|
||||
onClick={() => dispatch(copyToClipboard(id))}
|
||||
onClick={() => copyToClipboard(id)}
|
||||
variant="outline-primary"
|
||||
size="sm"
|
||||
>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { QueryClientProvider, QueryClient } from '@tanstack/react-query';
|
||||
import { render } from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
@@ -12,24 +13,28 @@ import { clipboardUnit } from '../../../../__mocks__';
|
||||
import { getCourseUnitApiUrl } from '../../../data/api';
|
||||
import { getClipboardUrl } from '../../../../generic/data/api';
|
||||
import { fetchCourseUnitQuery } from '../../../data/thunk';
|
||||
import { copyToClipboard } from '../../../../generic/data/thunks';
|
||||
import { courseUnitIndexMock } from '../../../__mocks__';
|
||||
import messages from '../../messages';
|
||||
import ActionButtons from './ActionButtons';
|
||||
|
||||
jest.mock('../../../../generic/data/thunks', () => ({
|
||||
...jest.requireActual('../../../../generic/data/thunks'),
|
||||
copyToClipboard: jest.fn().mockImplementation(() => () => {}),
|
||||
}));
|
||||
|
||||
let store;
|
||||
let axiosMock;
|
||||
let queryClient;
|
||||
const courseId = '123';
|
||||
|
||||
const clipboardBroadcastChannelMock = {
|
||||
postMessage: jest.fn(),
|
||||
close: jest.fn(),
|
||||
};
|
||||
|
||||
global.BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
|
||||
|
||||
const renderComponent = (props = {}) => render(
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en">
|
||||
<ActionButtons {...props} />
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ActionButtons {...props} />
|
||||
</QueryClientProvider>
|
||||
</IntlProvider>
|
||||
</AppProvider>,
|
||||
);
|
||||
@@ -57,6 +62,8 @@ describe('<ActionButtons />', () => {
|
||||
.onGet(getClipboardUrl())
|
||||
.reply(200, clipboardUnit);
|
||||
|
||||
queryClient = new QueryClient();
|
||||
|
||||
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
|
||||
});
|
||||
|
||||
@@ -73,8 +80,8 @@ describe('<ActionButtons />', () => {
|
||||
const copyXBlockBtn = getByRole('button', { name: messages.actionButtonCopyUnitTitle.defaultMessage });
|
||||
|
||||
userEvent.click(copyXBlockBtn);
|
||||
|
||||
expect(copyToClipboard).toHaveBeenCalledWith(courseUnitIndexMock.id);
|
||||
expect(axiosMock.history.post.length).toBe(1);
|
||||
expect(axiosMock.history.post[0].data).toBe(JSON.stringify({ usage_key: courseUnitIndexMock.id }));
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,6 +29,12 @@ jest.spyOn(editorCmsApi, 'fetchByUnitId').mockImplementation(async () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const isDirtyMock = jest.fn();
|
||||
jest.mock('../TextEditor/hooks', () => ({
|
||||
...jest.requireActual('../TextEditor/hooks'),
|
||||
isDirty: () => isDirtyMock,
|
||||
}));
|
||||
|
||||
const defaultPropsHtml = {
|
||||
blockId: 'block-v1:Org+TS100+24+type@html+block@123456html',
|
||||
blockType: 'html',
|
||||
@@ -45,15 +51,27 @@ const fieldsHtml = {
|
||||
};
|
||||
|
||||
describe('EditorContainer', () => {
|
||||
let mockEvent: Event;
|
||||
|
||||
beforeEach(() => {
|
||||
initializeMocks();
|
||||
mockEvent = new Event('beforeunload');
|
||||
jest.spyOn(window, 'addEventListener');
|
||||
jest.spyOn(window, 'removeEventListener');
|
||||
jest.spyOn(mockEvent, 'preventDefault');
|
||||
Object.defineProperty(mockEvent, 'returnValue', { writable: true });
|
||||
});
|
||||
|
||||
test('it displays a confirmation dialog when closing the editor modal', async () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('it displays a confirmation dialog when closing the editor modal if data is changed', async () => {
|
||||
jest.spyOn(editorCmsApi, 'fetchBlockById').mockImplementationOnce(async () => (
|
||||
{ status: 200, data: snakeCaseObject(fieldsHtml) }
|
||||
));
|
||||
|
||||
isDirtyMock.mockReturnValue(true);
|
||||
render(<EditorPage {...defaultPropsHtml} />);
|
||||
|
||||
// Then the editor should open
|
||||
@@ -68,12 +86,48 @@ describe('EditorContainer', () => {
|
||||
fireEvent.click(closeButton);
|
||||
// Now we should see the confirmation message:
|
||||
expect(await screen.findByText(confirmMessage)).toBeInTheDocument();
|
||||
|
||||
expect(defaultPropsHtml.onClose).not.toHaveBeenCalled();
|
||||
|
||||
// Should close modal if cancelled
|
||||
const cancelBtn = await screen.findByRole('button', { name: 'Cancel' });
|
||||
fireEvent.click(cancelBtn);
|
||||
expect(defaultPropsHtml.onClose).not.toHaveBeenCalled();
|
||||
|
||||
// open modal again
|
||||
fireEvent.click(closeButton);
|
||||
// And can confirm the cancelation:
|
||||
const confirmButton = await screen.findByRole('button', { name: 'OK' });
|
||||
fireEvent.click(confirmButton);
|
||||
expect(defaultPropsHtml.onClose).toHaveBeenCalled();
|
||||
window.dispatchEvent(mockEvent);
|
||||
// should not be blocked by beforeunload event as the page was unloaded using close/cancel option
|
||||
expect(window.removeEventListener).toHaveBeenCalledWith('beforeunload', expect.any(Function));
|
||||
expect(mockEvent.preventDefault).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('it does not display any confirmation dialog when closing the editor modal if data is not changed', async () => {
|
||||
jest.spyOn(editorCmsApi, 'fetchBlockById').mockImplementationOnce(async () => (
|
||||
{ status: 200, data: snakeCaseObject(fieldsHtml) }
|
||||
));
|
||||
|
||||
isDirtyMock.mockReturnValue(false);
|
||||
render(<EditorPage {...defaultPropsHtml} />);
|
||||
|
||||
// Then the editor should open
|
||||
expect(await screen.findByRole('heading', { name: /Introduction to Testing/ })).toBeInTheDocument();
|
||||
|
||||
// Assert the "are you sure?" message isn't visible yet
|
||||
const confirmMessage = /Are you sure you want to exit the editor/;
|
||||
expect(screen.queryByText(confirmMessage)).not.toBeInTheDocument();
|
||||
|
||||
// Find and click the close button
|
||||
const closeButton = await screen.findByRole('button', { name: 'Exit the editor' });
|
||||
fireEvent.click(closeButton);
|
||||
// Even now we should not see the confirmation message as data is not dirty, i.e. not changed:
|
||||
expect(screen.queryByText(confirmMessage)).not.toBeInTheDocument();
|
||||
|
||||
// And onClose is directly called
|
||||
expect(defaultPropsHtml.onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('it disables the save button until the fields have been loaded', async () => {
|
||||
@@ -94,4 +148,21 @@ describe('EditorContainer', () => {
|
||||
// Now the save button should be active:
|
||||
await waitFor(() => expect(saveButton).not.toBeDisabled());
|
||||
});
|
||||
|
||||
test('beforeunload event is triggered on page unload if data is changed', async () => {
|
||||
jest.spyOn(editorCmsApi, 'fetchBlockById').mockImplementationOnce(async () => (
|
||||
{ status: 200, data: snakeCaseObject(fieldsHtml) }
|
||||
));
|
||||
|
||||
isDirtyMock.mockReturnValue(true);
|
||||
render(<EditorPage {...defaultPropsHtml} />);
|
||||
|
||||
// Then the editor should open
|
||||
expect(await screen.findByRole('heading', { name: /Introduction to Testing/ })).toBeInTheDocument();
|
||||
// on beforeunload event block user
|
||||
window.dispatchEvent(mockEvent);
|
||||
expect(window.removeEventListener).toHaveBeenCalledWith('beforeunload', expect.any(Function));
|
||||
expect(mockEvent.preventDefault).toHaveBeenCalled();
|
||||
expect(mockEvent.returnValue).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@ import TitleHeader from './components/TitleHeader';
|
||||
import * as hooks from './hooks';
|
||||
import messages from './messages';
|
||||
import './index.scss';
|
||||
import usePromptIfDirty from '../../../generic/promptIfDirty/usePromptIfDirty';
|
||||
|
||||
interface WrapperProps {
|
||||
children: React.ReactNode;
|
||||
@@ -61,32 +62,57 @@ export const FooterWrapper: React.FC<WrapperProps> = ({ children }) => {
|
||||
interface Props extends EditorComponent {
|
||||
children: React.ReactNode;
|
||||
getContent: Function;
|
||||
isDirty: () => boolean;
|
||||
validateEntry?: Function | null;
|
||||
}
|
||||
|
||||
const EditorContainer: React.FC<Props> = ({
|
||||
children,
|
||||
getContent,
|
||||
isDirty,
|
||||
onClose = null,
|
||||
validateEntry = null,
|
||||
returnFunction = null,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
// Required to mark data as not dirty on save
|
||||
const [saved, setSaved] = React.useState(false);
|
||||
const isInitialized = hooks.isInitialized();
|
||||
const { isCancelConfirmOpen, openCancelConfirmModal, closeCancelConfirmModal } = hooks.cancelConfirmModalToggle();
|
||||
const handleCancel = hooks.handleCancel({ onClose, returnFunction });
|
||||
const disableSave = !isInitialized;
|
||||
const saveFailed = hooks.saveFailed();
|
||||
const clearSaveFailed = hooks.clearSaveError({ dispatch });
|
||||
const onSave = hooks.handleSaveClicked({
|
||||
const handleSave = hooks.handleSaveClicked({
|
||||
dispatch,
|
||||
getContent,
|
||||
validateEntry,
|
||||
returnFunction,
|
||||
});
|
||||
|
||||
const onSave = () => {
|
||||
setSaved(true);
|
||||
handleSave();
|
||||
};
|
||||
// Stops user from navigating away if they have unsaved changes.
|
||||
usePromptIfDirty(() => {
|
||||
// Do not block if cancel modal is used or data is saved.
|
||||
if (isCancelConfirmOpen || saved) {
|
||||
return false;
|
||||
}
|
||||
return isDirty();
|
||||
});
|
||||
|
||||
const confirmCancelIfDirty = () => {
|
||||
if (isDirty()) {
|
||||
openCancelConfirmModal();
|
||||
} else {
|
||||
handleCancel();
|
||||
}
|
||||
};
|
||||
return (
|
||||
<EditorModalWrapper onClose={openCancelConfirmModal}>
|
||||
<EditorModalWrapper onClose={confirmCancelIfDirty}>
|
||||
{saveFailed && (
|
||||
<Toast show onClose={clearSaveFailed}>
|
||||
<FormattedMessage {...messages.contentSaveFailed} />
|
||||
@@ -108,7 +134,9 @@ const EditorContainer: React.FC<Props> = ({
|
||||
</Button>
|
||||
)}
|
||||
isOpen={isCancelConfirmOpen}
|
||||
close={closeCancelConfirmModal}
|
||||
close={() => {
|
||||
closeCancelConfirmModal();
|
||||
}}
|
||||
title={intl.formatMessage(messages.cancelConfirmTitle)}
|
||||
>
|
||||
<FormattedMessage {...messages.cancelConfirmDescription} />
|
||||
@@ -121,7 +149,7 @@ const EditorContainer: React.FC<Props> = ({
|
||||
<IconButton
|
||||
src={Close}
|
||||
iconAs={Icon}
|
||||
onClick={openCancelConfirmModal}
|
||||
onClick={confirmCancelIfDirty}
|
||||
alt={intl.formatMessage(messages.exitButtonAlt)}
|
||||
/>
|
||||
</div>
|
||||
@@ -135,7 +163,7 @@ const EditorContainer: React.FC<Props> = ({
|
||||
<Button
|
||||
aria-label={intl.formatMessage(messages.cancelButtonAriaLabel)}
|
||||
variant="tertiary"
|
||||
onClick={openCancelConfirmModal}
|
||||
onClick={confirmCancelIfDirty}
|
||||
>
|
||||
<FormattedMessage {...messages.cancelButtonLabel} />
|
||||
</Button>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
exports[`EditorProblemView component renders raw editor 1`] = `
|
||||
<EditorContainer
|
||||
getContent={[Function]}
|
||||
isDirty={[Function]}
|
||||
returnFunction={null}
|
||||
>
|
||||
<AlertModal
|
||||
@@ -72,6 +73,7 @@ exports[`EditorProblemView component renders raw editor 1`] = `
|
||||
exports[`EditorProblemView component renders simple view 1`] = `
|
||||
<EditorContainer
|
||||
getContent={[Function]}
|
||||
isDirty={[Function]}
|
||||
returnFunction={null}
|
||||
>
|
||||
<AlertModal
|
||||
|
||||
@@ -20,6 +20,19 @@ export const saveWarningModalToggle = () => {
|
||||
};
|
||||
};
|
||||
|
||||
/** Checks if any tinymce editor in window is dirty */
|
||||
export const checkIfEditorsDirty = () => {
|
||||
const EditorsArray = window.tinymce.editors;
|
||||
return Object.entries(EditorsArray).some(([id, editor]) => {
|
||||
if (Number.isNaN(parseInt(id, 10))) {
|
||||
if (!editor.isNotDirty) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
};
|
||||
|
||||
export const fetchEditorContent = ({ format }) => {
|
||||
const editorObject = { hints: [] };
|
||||
const EditorsArray = window.tinymce.editors;
|
||||
|
||||
@@ -362,3 +362,43 @@ describe('EditProblemView hooks parseState', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkIfEditorsDirty', () => {
|
||||
let windowSpy;
|
||||
beforeEach(() => {
|
||||
windowSpy = jest.spyOn(window, 'window', 'get');
|
||||
});
|
||||
afterEach(() => {
|
||||
windowSpy.mockRestore();
|
||||
});
|
||||
describe('state hook', () => {
|
||||
test('should return false if none of editors are dirty', () => {
|
||||
windowSpy.mockImplementation(() => ({
|
||||
tinymce: {
|
||||
editors: {
|
||||
some_id: { isNotDirty: true },
|
||||
some_id2: { isNotDirty: true },
|
||||
some_id3: { isNotDirty: true },
|
||||
some_id4: { isNotDirty: true },
|
||||
some_id5: { isNotDirty: true },
|
||||
},
|
||||
},
|
||||
}));
|
||||
expect(hooks.checkIfEditorsDirty()).toEqual(false);
|
||||
});
|
||||
test('should return true if any editor is dirty', () => {
|
||||
windowSpy.mockImplementation(() => ({
|
||||
tinymce: {
|
||||
editors: {
|
||||
some_id: { isNotDirty: true },
|
||||
some_id2: { isNotDirty: true },
|
||||
some_id3: { isNotDirty: false },
|
||||
some_id4: { isNotDirty: true },
|
||||
some_id5: { isNotDirty: false },
|
||||
},
|
||||
},
|
||||
}));
|
||||
expect(hooks.checkIfEditorsDirty()).toEqual(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,7 +17,9 @@ import { selectors } from '../../../../data/redux';
|
||||
import RawEditor from '../../../../sharedComponents/RawEditor';
|
||||
import { ProblemTypeKeys } from '../../../../data/constants/problem';
|
||||
|
||||
import { parseState, saveWarningModalToggle, getContent } from './hooks';
|
||||
import {
|
||||
checkIfEditorsDirty, parseState, saveWarningModalToggle, getContent,
|
||||
} from './hooks';
|
||||
import './index.scss';
|
||||
import messages from './messages';
|
||||
|
||||
@@ -32,6 +34,7 @@ const EditProblemView = ({
|
||||
lmsEndpointUrl,
|
||||
returnUrl,
|
||||
analytics,
|
||||
isDirty,
|
||||
// injected
|
||||
intl,
|
||||
}) => {
|
||||
@@ -40,6 +43,14 @@ const EditProblemView = ({
|
||||
const isAdvancedProblemType = problemType === ProblemTypeKeys.ADVANCED;
|
||||
const { isSaveWarningModalOpen, openSaveWarningModal, closeSaveWarningModal } = saveWarningModalToggle();
|
||||
|
||||
const checkIfDirty = () => {
|
||||
if (isAdvancedProblemType && editorRef && editorRef?.current) {
|
||||
/* istanbul ignore next */
|
||||
return editorRef.current.observer?.lastChange !== 0;
|
||||
}
|
||||
return isDirty || checkIfEditorsDirty();
|
||||
};
|
||||
|
||||
return (
|
||||
<EditorContainer
|
||||
getContent={() => getContent({
|
||||
@@ -49,6 +60,7 @@ const EditProblemView = ({
|
||||
editorRef,
|
||||
lmsEndpointUrl,
|
||||
})}
|
||||
isDirty={checkIfDirty}
|
||||
returnFunction={returnFunction}
|
||||
>
|
||||
<AlertModal
|
||||
@@ -117,6 +129,7 @@ const EditProblemView = ({
|
||||
EditProblemView.defaultProps = {
|
||||
lmsEndpointUrl: null,
|
||||
returnFunction: null,
|
||||
isDirty: false,
|
||||
};
|
||||
|
||||
EditProblemView.propTypes = {
|
||||
@@ -127,6 +140,7 @@ EditProblemView.propTypes = {
|
||||
analytics: PropTypes.shape({}).isRequired,
|
||||
lmsEndpointUrl: PropTypes.string,
|
||||
returnUrl: PropTypes.string.isRequired,
|
||||
isDirty: PropTypes.bool,
|
||||
// injected
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
@@ -137,6 +151,7 @@ export const mapStateToProps = (state) => ({
|
||||
returnUrl: selectors.app.returnUrl(state),
|
||||
problemType: selectors.problem.problemType(state),
|
||||
problemState: selectors.problem.completeState(state),
|
||||
isDirty: selectors.problem.isDirty(state),
|
||||
});
|
||||
|
||||
export const EditProblemViewInternal = EditProblemView; // For testing only
|
||||
|
||||
@@ -14,6 +14,18 @@ exports[`TextEditor snapshots block failed to load, Toast is shown 1`] = `
|
||||
},
|
||||
}
|
||||
}
|
||||
isDirty={
|
||||
{
|
||||
"isDirty": {
|
||||
"editorRef": {
|
||||
"current": {
|
||||
"value": "something",
|
||||
},
|
||||
},
|
||||
"showRawEditor": false,
|
||||
},
|
||||
}
|
||||
}
|
||||
onClose={[MockFunction props.onClose]}
|
||||
returnFunction={null}
|
||||
>
|
||||
@@ -67,6 +79,18 @@ exports[`TextEditor snapshots loaded, raw editor 1`] = `
|
||||
},
|
||||
}
|
||||
}
|
||||
isDirty={
|
||||
{
|
||||
"isDirty": {
|
||||
"editorRef": {
|
||||
"current": {
|
||||
"value": "something",
|
||||
},
|
||||
},
|
||||
"showRawEditor": true,
|
||||
},
|
||||
}
|
||||
}
|
||||
onClose={[MockFunction props.onClose]}
|
||||
returnFunction={null}
|
||||
>
|
||||
@@ -114,6 +138,18 @@ exports[`TextEditor snapshots not yet loaded, Spinner appears 1`] = `
|
||||
},
|
||||
}
|
||||
}
|
||||
isDirty={
|
||||
{
|
||||
"isDirty": {
|
||||
"editorRef": {
|
||||
"current": {
|
||||
"value": "something",
|
||||
},
|
||||
},
|
||||
"showRawEditor": false,
|
||||
},
|
||||
}
|
||||
}
|
||||
onClose={[MockFunction props.onClose]}
|
||||
returnFunction={null}
|
||||
>
|
||||
@@ -153,6 +189,18 @@ exports[`TextEditor snapshots renders as expected with default behavior 1`] = `
|
||||
},
|
||||
}
|
||||
}
|
||||
isDirty={
|
||||
{
|
||||
"isDirty": {
|
||||
"editorRef": {
|
||||
"current": {
|
||||
"value": "something",
|
||||
},
|
||||
},
|
||||
"showRawEditor": false,
|
||||
},
|
||||
}
|
||||
}
|
||||
onClose={[MockFunction props.onClose]}
|
||||
returnFunction={null}
|
||||
>
|
||||
@@ -206,6 +254,18 @@ exports[`TextEditor snapshots renders static images with relative paths 1`] = `
|
||||
},
|
||||
}
|
||||
}
|
||||
isDirty={
|
||||
{
|
||||
"isDirty": {
|
||||
"editorRef": {
|
||||
"current": {
|
||||
"value": "something",
|
||||
},
|
||||
},
|
||||
"showRawEditor": false,
|
||||
},
|
||||
}
|
||||
}
|
||||
onClose={[MockFunction props.onClose]}
|
||||
returnFunction={null}
|
||||
>
|
||||
|
||||
@@ -9,3 +9,14 @@ export const getContent = ({ editorRef, showRawEditor }) => () => {
|
||||
: editorRef.current?.getContent());
|
||||
return setAssetToStaticUrl({ editorValue: content });
|
||||
};
|
||||
|
||||
export const isDirty = ({ editorRef, showRawEditor }) => () => {
|
||||
/* istanbul ignore next */
|
||||
if (!editorRef?.current) {
|
||||
return false;
|
||||
}
|
||||
const dirty = (showRawEditor && editorRef && editorRef.current
|
||||
? editorRef.current.observer?.lastChange !== 0
|
||||
: !editorRef.current.isNotDirty);
|
||||
return dirty;
|
||||
};
|
||||
|
||||
@@ -61,5 +61,26 @@ describe('TextEditor hooks', () => {
|
||||
expect(getContent).toEqual(rawContent);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isDirty', () => {
|
||||
test('checks isNotDirty flag when showRawEditor is false', () => {
|
||||
const editorRef = {
|
||||
current: {
|
||||
isNotDirty: false,
|
||||
},
|
||||
};
|
||||
const isDirty = module.isDirty({ editorRef, showRawEditor: false })();
|
||||
expect(isDirty).toEqual(true);
|
||||
});
|
||||
test('checks observer.lastChange flag when showRawEditor is true', () => {
|
||||
const editorRef = {
|
||||
current: {
|
||||
observer: { lastChange: 123 },
|
||||
},
|
||||
};
|
||||
const isDirty = module.isDirty({ editorRef, showRawEditor: true })();
|
||||
expect(isDirty).toEqual(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -80,6 +80,7 @@ const TextEditor = ({
|
||||
return (
|
||||
<EditorContainer
|
||||
getContent={hooks.getContent({ editorRef, showRawEditor })}
|
||||
isDirty={hooks.isDirty({ editorRef, showRawEditor })}
|
||||
onClose={onClose}
|
||||
returnFunction={returnFunction}
|
||||
>
|
||||
|
||||
@@ -22,6 +22,7 @@ jest.mock('../EditorContainer', () => 'EditorContainer');
|
||||
|
||||
jest.mock('./hooks', () => ({
|
||||
getContent: jest.fn(args => ({ getContent: args })),
|
||||
isDirty: jest.fn(args => ({ isDirty: args })),
|
||||
nullMethod: jest.fn().mockName('hooks.nullMethod'),
|
||||
}));
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ exports[`VideoEditor snapshots renders as expected with default behavior 1`] = `
|
||||
value="hooks.errorsHook.error"
|
||||
>
|
||||
<EditorContainer
|
||||
isDirty={[Function]}
|
||||
onClose={[MockFunction props.onClose]}
|
||||
validateEntry={[MockFunction validateEntry]}
|
||||
>
|
||||
|
||||
@@ -11,11 +11,13 @@ import hooks from './hooks';
|
||||
import LanguageNamesWidget from './LanguageNamesWidget';
|
||||
import videoThumbnail from '../../../../../../data/images/videoThumbnail.svg';
|
||||
|
||||
const VideoPreviewWidget = ({
|
||||
// Exporting to test this component separately
|
||||
export const VideoPreviewWidget = ({
|
||||
thumbnail,
|
||||
videoSource,
|
||||
transcripts,
|
||||
blockTitle,
|
||||
isLibrary,
|
||||
intl,
|
||||
}) => {
|
||||
const imgRef = React.useRef();
|
||||
@@ -45,7 +47,10 @@ const VideoPreviewWidget = ({
|
||||
/>
|
||||
<Stack gap={1} className="justify-content-center">
|
||||
<h4 className="text-primary mb-0">{blockTitle}</h4>
|
||||
<LanguageNamesWidget transcripts={transcripts} />
|
||||
{!isLibrary && (
|
||||
// Since content libraries v2 don't support static assets yet, we can't include transcripts.
|
||||
<LanguageNamesWidget transcripts={transcripts} />
|
||||
)}
|
||||
{videoType && (
|
||||
<Hyperlink
|
||||
className="text-primary x-small"
|
||||
@@ -69,6 +74,7 @@ VideoPreviewWidget.propTypes = {
|
||||
thumbnail: PropTypes.string.isRequired,
|
||||
transcripts: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
blockTitle: PropTypes.string.isRequired,
|
||||
isLibrary: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
export const mapStateToProps = (state) => ({
|
||||
@@ -76,6 +82,7 @@ export const mapStateToProps = (state) => ({
|
||||
videoSource: selectors.video.videoSource(state),
|
||||
thumbnail: selectors.video.thumbnail(state),
|
||||
blockTitle: selectors.app.blockTitle(state),
|
||||
isLibrary: selectors.app.isLibrary(state),
|
||||
});
|
||||
|
||||
export default injectIntl(connect(mapStateToProps)(VideoPreviewWidget));
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
initializeMocks,
|
||||
render,
|
||||
screen,
|
||||
} from '../../../../../../../testUtils';
|
||||
|
||||
import { VideoPreviewWidget } from '.';
|
||||
|
||||
describe('VideoPreviewWidget', () => {
|
||||
const mockIntl = {
|
||||
formatMessage: (message) => message.defaultMessage,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
initializeMocks();
|
||||
});
|
||||
|
||||
describe('render', () => {
|
||||
test('renders transcripts section in preview for courses', () => {
|
||||
render(
|
||||
<VideoPreviewWidget
|
||||
videoSource="some-source"
|
||||
isLibrary={false}
|
||||
intl={mockIntl}
|
||||
transcripts={[]}
|
||||
blockTitle="some title"
|
||||
thumbnail=""
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText('No transcripts added')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('hides transcripts section in preview for libraries', () => {
|
||||
render(
|
||||
<VideoPreviewWidget
|
||||
videoSource="some-source"
|
||||
isLibrary
|
||||
intl={mockIntl}
|
||||
transcripts={[]}
|
||||
blockTitle="some title"
|
||||
thumbnail=""
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText('No transcripts added')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -78,6 +78,7 @@ exports[`VideoSourceWidget snapshots snapshots: renders as expected with default
|
||||
</div>
|
||||
<Form.Row
|
||||
className="mt-3.5 mx-0 flex-nowrap"
|
||||
key="somEUrL"
|
||||
>
|
||||
<Form.Group>
|
||||
<Form.Control
|
||||
@@ -86,7 +87,6 @@ exports[`VideoSourceWidget snapshots snapshots: renders as expected with default
|
||||
<IconButtonWithTooltip
|
||||
alt="Delete"
|
||||
iconAs="Icon"
|
||||
key="top-delete-somEUrL"
|
||||
onClick={[Function]}
|
||||
tooltipContent="Delete"
|
||||
tooltipPlacement="top"
|
||||
@@ -237,6 +237,7 @@ exports[`VideoSourceWidget snapshots snapshots: renders as expected with videoSh
|
||||
</div>
|
||||
<Form.Row
|
||||
className="mt-3.5 mx-0 flex-nowrap"
|
||||
key="somEUrL"
|
||||
>
|
||||
<Form.Group>
|
||||
<Form.Control
|
||||
@@ -245,7 +246,6 @@ exports[`VideoSourceWidget snapshots snapshots: renders as expected with videoSh
|
||||
<IconButtonWithTooltip
|
||||
alt="Delete"
|
||||
iconAs="Icon"
|
||||
key="top-delete-somEUrL"
|
||||
onClick={[Function]}
|
||||
tooltipContent="Delete"
|
||||
tooltipPlacement="top"
|
||||
|
||||
@@ -39,16 +39,17 @@ export const sourceHooks = ({ dispatch, previousVideoId, setAlert }) => ({
|
||||
|
||||
export const fallbackHooks = ({ fallbackVideos, dispatch }) => ({
|
||||
addFallbackVideo: () => dispatch(actions.video.updateField({ fallbackVideos: [...fallbackVideos, ''] })),
|
||||
|
||||
/**
|
||||
* Deletes the first occurrence of the given videoUrl from the fallbackVideos list
|
||||
* @param {string} videoUrl - the video URL to delete
|
||||
*/
|
||||
deleteFallbackVideo: (videoUrl) => {
|
||||
const index = fallbackVideos.findIndex(video => video === videoUrl);
|
||||
deleteFallbackVideo: (videoIndex) => {
|
||||
const updatedFallbackVideos = [
|
||||
...fallbackVideos.slice(0, index),
|
||||
...fallbackVideos.slice(index + 1),
|
||||
...fallbackVideos.slice(0, videoIndex),
|
||||
...fallbackVideos.slice(videoIndex + 1),
|
||||
];
|
||||
|
||||
dispatch(actions.video.updateField({ fallbackVideos: updatedFallbackVideos }));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -101,7 +101,7 @@ const VideoSourceWidget = ({
|
||||
<FormattedMessage {...messages.fallbackVideoMessage} />
|
||||
</div>
|
||||
{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.Control
|
||||
floatingLabel={intl.formatMessage(messages.fallbackVideoLabel)}
|
||||
@@ -110,13 +110,12 @@ const VideoSourceWidget = ({
|
||||
onBlur={fallbackVideos.onBlur(index)}
|
||||
/>
|
||||
<IconButtonWithTooltip
|
||||
key={`top-delete-${videoUrl}`}
|
||||
tooltipPlacement="top"
|
||||
tooltipContent={intl.formatMessage(messages.deleteFallbackVideo)}
|
||||
src={DeleteOutline}
|
||||
iconAs={Icon}
|
||||
alt={intl.formatMessage(messages.deleteFallbackVideo)}
|
||||
onClick={() => deleteFallbackVideo(videoUrl)}
|
||||
onClick={() => deleteFallbackVideo(index)}
|
||||
/>
|
||||
</Form.Group>
|
||||
</Form.Row>
|
||||
|
||||
@@ -11,7 +11,8 @@ import LicenseWidget from './components/LicenseWidget';
|
||||
import ThumbnailWidget from './components/ThumbnailWidget';
|
||||
import TranscriptWidget from './components/TranscriptWidget';
|
||||
import VideoSourceWidget from './components/VideoSourceWidget';
|
||||
import VideoPreviewWidget from './components/VideoPreviewWidget';
|
||||
// Using default import to get selectors connected VideoSourceWidget
|
||||
import ConnectedVideoPreviewWidget from './components/VideoPreviewWidget';
|
||||
import './index.scss';
|
||||
import SocialShareWidget from './components/SocialShareWidget';
|
||||
import messages from '../../messages';
|
||||
@@ -42,7 +43,7 @@ const VideoSettingsModal: React.FC<Props> = ({
|
||||
</Button>
|
||||
)}
|
||||
<ErrorSummary />
|
||||
<VideoPreviewWidget />
|
||||
<ConnectedVideoPreviewWidget />
|
||||
<VideoSourceWidget />
|
||||
{!isLibrary && (
|
||||
<SocialShareWidget />
|
||||
|
||||
@@ -31,6 +31,7 @@ const VideoEditor: React.FC<EditorComponent> = ({
|
||||
<ErrorContext.Provider value={error}>
|
||||
<EditorContainer
|
||||
getContent={fetchVideoContent()}
|
||||
isDirty={/* istanbul ignore next */ () => true}
|
||||
onClose={onClose}
|
||||
returnFunction={returnFunction}
|
||||
validateEntry={validateEntry}
|
||||
|
||||
@@ -16,6 +16,7 @@ const initialState = {
|
||||
generalFeedback: '',
|
||||
additionalAttributes: {},
|
||||
defaultSettings: {},
|
||||
isDirty: false,
|
||||
settings: {
|
||||
randomization: null,
|
||||
scoring: {
|
||||
@@ -52,6 +53,7 @@ const problem = createSlice({
|
||||
updateQuestion: (state, { payload }) => ({
|
||||
...state,
|
||||
question: payload,
|
||||
isDirty: true,
|
||||
}),
|
||||
updateAnswer: (state, { payload }) => {
|
||||
const { id, hasSingleAnswer, ...answer } = payload;
|
||||
@@ -77,6 +79,7 @@ const problem = createSlice({
|
||||
...state,
|
||||
correctAnswerCount,
|
||||
answers,
|
||||
isDirty: true,
|
||||
};
|
||||
},
|
||||
deleteAnswer: (state, { payload }) => {
|
||||
@@ -86,6 +89,7 @@ const problem = createSlice({
|
||||
return {
|
||||
...state,
|
||||
correctAnswerCount: state.problemType === ProblemTypeKeys.NUMERIC ? 1 : 0,
|
||||
isDirty: true,
|
||||
answers: [{
|
||||
id: 'A',
|
||||
title: '',
|
||||
@@ -140,6 +144,7 @@ const problem = createSlice({
|
||||
answers,
|
||||
correctAnswerCount: correct ? state.correctAnswerCount - 1 : state.correctAnswerCount,
|
||||
groupFeedbackList,
|
||||
isDirty: true,
|
||||
};
|
||||
},
|
||||
addAnswer: (state) => {
|
||||
@@ -167,6 +172,7 @@ const problem = createSlice({
|
||||
return {
|
||||
...state,
|
||||
correctAnswerCount,
|
||||
isDirty: true,
|
||||
answers,
|
||||
};
|
||||
},
|
||||
@@ -185,6 +191,7 @@ const problem = createSlice({
|
||||
...state,
|
||||
correctAnswerCount,
|
||||
answers: [newOption],
|
||||
isDirty: true,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -194,6 +201,7 @@ const problem = createSlice({
|
||||
...state.settings,
|
||||
...payload,
|
||||
},
|
||||
isDirty: true,
|
||||
}),
|
||||
load: (state, { payload: { settings: { scoring, showAnswer, ...settings }, ...payload } }) => ({
|
||||
...state,
|
||||
|
||||
@@ -19,6 +19,7 @@ describe('problem reducer', () => {
|
||||
it(`load ${target} from payload`, () => {
|
||||
expect(reducer(testingState, actions[action](testValue))).toEqual({
|
||||
...testingState,
|
||||
isDirty: true,
|
||||
[target]: testValue,
|
||||
});
|
||||
});
|
||||
@@ -62,6 +63,7 @@ describe('problem reducer', () => {
|
||||
expect(reducer(testingState, actions.addAnswer(answer))).toEqual({
|
||||
...testingState,
|
||||
answers: [answer],
|
||||
isDirty: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -79,6 +81,7 @@ describe('problem reducer', () => {
|
||||
const payload = { hints: ['soMehInt'] };
|
||||
expect(reducer(testingState, actions.updateSettings(payload))).toEqual({
|
||||
...testingState,
|
||||
isDirty: true,
|
||||
settings: {
|
||||
...testingState.settings,
|
||||
...payload,
|
||||
@@ -99,6 +102,7 @@ describe('problem reducer', () => {
|
||||
expect(reducer({ ...testingState, problemType: 'choiceresponse' }, actions.addAnswer())).toEqual({
|
||||
...testingState,
|
||||
problemType: 'choiceresponse',
|
||||
isDirty: true,
|
||||
answers: [answer],
|
||||
});
|
||||
});
|
||||
@@ -111,6 +115,7 @@ describe('problem reducer', () => {
|
||||
expect(reducer(numericTestState, actions.addAnswer())).toEqual({
|
||||
...numericTestState,
|
||||
correctAnswerCount: 1,
|
||||
isDirty: true,
|
||||
answers: [{
|
||||
...answer,
|
||||
correct: true,
|
||||
@@ -131,6 +136,7 @@ describe('problem reducer', () => {
|
||||
expect(reducer({ ...testingState, problemType: ProblemTypeKeys.NUMERIC }, actions.addAnswerRange())).toEqual({
|
||||
...testingState,
|
||||
correctAnswerCount: 1,
|
||||
isDirty: true,
|
||||
problemType: ProblemTypeKeys.NUMERIC,
|
||||
answers: [answerRange],
|
||||
});
|
||||
@@ -151,6 +157,7 @@ describe('problem reducer', () => {
|
||||
)).toEqual({
|
||||
...testingState,
|
||||
correctAnswerCount: 1,
|
||||
isDirty: true,
|
||||
answers: [{ id: 'A', correct: true }],
|
||||
});
|
||||
});
|
||||
@@ -183,6 +190,7 @@ describe('problem reducer', () => {
|
||||
actions.deleteAnswer(payload),
|
||||
)).toEqual({
|
||||
...testingState,
|
||||
isDirty: true,
|
||||
correctAnswerCount: 0,
|
||||
answers: [{
|
||||
id: 'A',
|
||||
@@ -220,6 +228,7 @@ describe('problem reducer', () => {
|
||||
)).toEqual({
|
||||
...testingState,
|
||||
correctAnswerCount: 1,
|
||||
isDirty: true,
|
||||
answers: [{
|
||||
id: 'A',
|
||||
correct: true,
|
||||
@@ -259,6 +268,7 @@ describe('problem reducer', () => {
|
||||
)).toEqual({
|
||||
...testingState,
|
||||
problemType: ProblemTypeKeys.SINGLESELECT,
|
||||
isDirty: true,
|
||||
correctAnswerCount: 1,
|
||||
answers: [{
|
||||
id: 'A',
|
||||
@@ -300,6 +310,7 @@ describe('problem reducer', () => {
|
||||
)).toEqual({
|
||||
...testingState,
|
||||
correctAnswerCount: 1,
|
||||
isDirty: true,
|
||||
answers: [{
|
||||
id: 'A',
|
||||
correct: true,
|
||||
@@ -380,6 +391,7 @@ describe('problem reducer', () => {
|
||||
)).toEqual({
|
||||
...testingState,
|
||||
correctAnswerCount: 1,
|
||||
isDirty: true,
|
||||
answers: [{
|
||||
id: 'A',
|
||||
correct: true,
|
||||
@@ -429,6 +441,7 @@ describe('problem reducer', () => {
|
||||
...testingState,
|
||||
problemType: ProblemTypeKeys.NUMERIC,
|
||||
correctAnswerCount: 1,
|
||||
isDirty: true,
|
||||
answers: [{
|
||||
id: 'A',
|
||||
title: '',
|
||||
|
||||
@@ -17,6 +17,7 @@ export const simpleSelectors = {
|
||||
question: mkSimpleSelector(problemData => problemData.question),
|
||||
defaultSettings: mkSimpleSelector(problemData => problemData.defaultSettings),
|
||||
completeState: mkSimpleSelector(problemData => problemData),
|
||||
isDirty: mkSimpleSelector(problemData => problemData.isDirty),
|
||||
};
|
||||
|
||||
export default {
|
||||
|
||||
@@ -50,11 +50,19 @@ describe('hooks', () => {
|
||||
describe('initializeApp', () => {
|
||||
test('calls provided function with provided data as args when useEffect is called', () => {
|
||||
const dispatch = jest.fn();
|
||||
const fakeData = { some: 'data' };
|
||||
const fakeData = {
|
||||
blockId: 'blockId',
|
||||
studioEndpointUrl: 'studioEndpointUrl',
|
||||
learningContextId: 'learningContextId',
|
||||
};
|
||||
hooks.initializeApp({ dispatch, data: fakeData });
|
||||
expect(dispatch).not.toHaveBeenCalledWith(fakeData);
|
||||
const [cb, prereqs] = useEffect.mock.calls[0];
|
||||
expect(prereqs).toStrictEqual([fakeData]);
|
||||
expect(prereqs).toStrictEqual([
|
||||
fakeData.blockId,
|
||||
fakeData.studioEndpointUrl,
|
||||
fakeData.learningContextId,
|
||||
]);
|
||||
cb();
|
||||
expect(dispatch).toHaveBeenCalledWith(thunkActions.app.initialize(fakeData));
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ import { RequestKeys } from './data/constants/requests';
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
export const initializeApp = ({ dispatch, data }) => useEffect(
|
||||
() => dispatch(thunkActions.app.initialize(data)),
|
||||
[data],
|
||||
[data?.blockId, data?.studioEndpointUrl, data?.learningContextId],
|
||||
);
|
||||
|
||||
export const navigateTo = (destination: string | URL) => {
|
||||
|
||||
@@ -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,
|
||||
@@ -219,7 +253,19 @@ export const setupCustomBehavior = ({
|
||||
if (newContent) { updateContent(newContent); }
|
||||
});
|
||||
}
|
||||
editor.on('ExecCommand', (e) => {
|
||||
|
||||
editor.on('init', /* istanbul ignore next */ () => {
|
||||
// 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) => {
|
||||
if (editorType === 'text' && e.command === 'mceFocus') {
|
||||
const initialContent = editor.getContent();
|
||||
const newContent = module.replaceStaticWithAsset({
|
||||
|
||||
@@ -64,16 +64,9 @@ const pluginConfig = ({ isLibrary, placeholder, editorType }) => {
|
||||
[editImageSettings],
|
||||
]),
|
||||
quickbarsInsertToolbar: toolbar ? false : mapToolbars([
|
||||
[buttons.undo, buttons.redo],
|
||||
[buttons.formatSelect],
|
||||
[buttons.bold, buttons.italic, buttons.underline, buttons.foreColor],
|
||||
[
|
||||
buttons.align.justify,
|
||||
buttons.bullist,
|
||||
buttons.numlist,
|
||||
],
|
||||
[imageUploadButton, buttons.blockQuote, buttons.codeBlock],
|
||||
[buttons.table, buttons.emoticons, buttons.charmap, buttons.removeFormat, buttons.a11ycheck],
|
||||
// To keep from blocking the whole text input field when it's empty, this "insert" toolbar
|
||||
// used with ExpandableTextArea is kept as minimal as we can.
|
||||
[imageUploadButton, buttons.table],
|
||||
]),
|
||||
quickbarsSelectionToolbar: toolbar ? false : mapToolbars([
|
||||
[buttons.undo, buttons.redo],
|
||||
|
||||
21
src/generic/clipboard/hooks/messages.ts
Normal file
21
src/generic/clipboard/hooks/messages.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
copying: {
|
||||
id: 'copypaste.copying',
|
||||
defaultMessage: 'Copying',
|
||||
description: 'Message shown when copying content to clipboard',
|
||||
},
|
||||
done: {
|
||||
id: 'copypaste.done',
|
||||
defaultMessage: 'Copied to clipboard',
|
||||
description: 'Message shown when content is copied to clipboard',
|
||||
},
|
||||
error: {
|
||||
id: 'copypaste.error',
|
||||
defaultMessage: 'Error copying to clipboard',
|
||||
description: 'Message shown when an error occurs while copying content to clipboard',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
119
src/generic/clipboard/hooks/useClipboard.test.tsx
Normal file
119
src/generic/clipboard/hooks/useClipboard.test.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
|
||||
import {
|
||||
clipboardUnit,
|
||||
clipboardXBlock,
|
||||
} from '../../../__mocks__';
|
||||
import { initializeMocks, makeWrapper } from '../../../testUtils';
|
||||
import { getClipboardUrl } from '../../data/api';
|
||||
import useClipboard from './useClipboard';
|
||||
|
||||
initializeMocks();
|
||||
|
||||
let axiosMock: MockAdapter;
|
||||
let mockShowToast: jest.Mock;
|
||||
|
||||
const unitId = 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_0270f6de40fc';
|
||||
const xblockId = 'block-v1:edX+DemoX+Demo_Course+type@html+block@030e35c4756a4ddc8d40b95fbbfff4d4';
|
||||
const clipboardBroadcastChannelMock = {
|
||||
postMessage: jest.fn(),
|
||||
close: jest.fn(),
|
||||
onmessage: jest.fn(),
|
||||
};
|
||||
|
||||
(global as any).BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
|
||||
|
||||
describe('useClipboard', () => {
|
||||
beforeEach(async () => {
|
||||
const mocks = initializeMocks();
|
||||
axiosMock = mocks.axiosMock;
|
||||
mockShowToast = mocks.mockShowToast as jest.Mock;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
axiosMock.restore();
|
||||
});
|
||||
|
||||
describe('clipboard data update effect', () => {
|
||||
it('returns falsy flags if canEdit = false', async () => {
|
||||
const { result, rerender } = renderHook(() => useClipboard(false), { wrapper: makeWrapper() });
|
||||
|
||||
axiosMock
|
||||
.onPost(getClipboardUrl())
|
||||
.reply(200, clipboardUnit);
|
||||
|
||||
await result.current.copyToClipboard(unitId);
|
||||
|
||||
rerender();
|
||||
|
||||
expect(mockShowToast).toHaveBeenCalledWith('Copying');
|
||||
expect(mockShowToast).toHaveBeenCalledWith('Copied to clipboard');
|
||||
|
||||
expect(result.current.showPasteUnit).toBe(false);
|
||||
expect(result.current.showPasteXBlock).toBe(false);
|
||||
});
|
||||
|
||||
it('returns flag to display the Paste Unit button', async () => {
|
||||
const { result, rerender } = renderHook(() => useClipboard(true), { wrapper: makeWrapper() });
|
||||
|
||||
axiosMock
|
||||
.onPost(getClipboardUrl())
|
||||
.reply(200, clipboardUnit);
|
||||
|
||||
await result.current.copyToClipboard(unitId);
|
||||
|
||||
rerender();
|
||||
|
||||
expect(result.current.showPasteUnit).toBe(true);
|
||||
expect(result.current.showPasteXBlock).toBe(false);
|
||||
});
|
||||
|
||||
it('returns flag to display the Paste XBlock button', async () => {
|
||||
const { result, rerender } = renderHook(() => useClipboard(true), { wrapper: makeWrapper() });
|
||||
|
||||
axiosMock
|
||||
.onPost(getClipboardUrl())
|
||||
.reply(200, clipboardXBlock);
|
||||
|
||||
await result.current.copyToClipboard(xblockId);
|
||||
|
||||
rerender();
|
||||
|
||||
expect(result.current.showPasteUnit).toBe(false);
|
||||
expect(result.current.showPasteXBlock).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('broadcast channel message handling', () => {
|
||||
it('updates states correctly on receiving a broadcast message', async () => {
|
||||
const { result, rerender } = renderHook(() => useClipboard(true), { wrapper: makeWrapper() });
|
||||
clipboardBroadcastChannelMock.onmessage({ data: clipboardUnit });
|
||||
|
||||
rerender();
|
||||
|
||||
expect(result.current.showPasteUnit).toBe(true);
|
||||
expect(result.current.showPasteXBlock).toBe(false);
|
||||
|
||||
clipboardBroadcastChannelMock.onmessage({ data: clipboardXBlock });
|
||||
rerender();
|
||||
|
||||
expect(result.current.showPasteUnit).toBe(false);
|
||||
expect(result.current.showPasteXBlock).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the current status while copying to clipboard', async () => {
|
||||
const { result, rerender } = renderHook(() => useClipboard(true), { wrapper: makeWrapper() });
|
||||
|
||||
axiosMock
|
||||
.onPost(getClipboardUrl())
|
||||
.networkError();
|
||||
|
||||
await result.current.copyToClipboard(unitId);
|
||||
|
||||
rerender();
|
||||
|
||||
expect(mockShowToast).toHaveBeenCalledWith('Error copying to clipboard');
|
||||
});
|
||||
});
|
||||
82
src/generic/clipboard/hooks/useClipboard.ts
Normal file
82
src/generic/clipboard/hooks/useClipboard.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useContext, useEffect, useState } from 'react';
|
||||
|
||||
import { getClipboard, updateClipboard } from '../../data/api';
|
||||
import {
|
||||
CLIPBOARD_STATUS,
|
||||
STRUCTURAL_XBLOCK_TYPES,
|
||||
STUDIO_CLIPBOARD_CHANNEL,
|
||||
} from '../../../constants';
|
||||
import { ToastContext } from '../../toast-context';
|
||||
import messages from './messages';
|
||||
|
||||
/**
|
||||
* Custom React hook for managing clipboard functionality.
|
||||
*
|
||||
* @param canEdit - Flag indicating whether the clipboard is editable.
|
||||
* @returns - An object containing state variables and functions related to clipboard functionality.
|
||||
* @property showPasteUnit - Flag indicating whether the "Paste Unit" button should be visible.
|
||||
* @property showPasteXBlock - Flag indicating whether the "Paste XBlock" button should be visible.
|
||||
* @property sharedClipboardData - The shared clipboard data object.
|
||||
* @property copyToClipboard - Function to copy the current selection to the clipboard.
|
||||
*/
|
||||
const useClipboard = (canEdit: boolean = true) => {
|
||||
const intl = useIntl();
|
||||
const [clipboardBroadcastChannel] = useState(() => new BroadcastChannel(STUDIO_CLIPBOARD_CHANNEL));
|
||||
const { data: clipboardData } = useQuery({
|
||||
queryKey: ['clipboard'],
|
||||
queryFn: getClipboard,
|
||||
refetchInterval: (data) => (data?.content?.status === CLIPBOARD_STATUS.loading ? 1000 : false),
|
||||
});
|
||||
const { showToast } = useContext(ToastContext);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const copyToClipboard = async (usageKey: string) => {
|
||||
// This code is synchronous for now, but it could be made asynchronous in the future.
|
||||
// In that case, the `done` message should be shown after the asynchronous operation completes.
|
||||
showToast(intl.formatMessage(messages.copying));
|
||||
try {
|
||||
const newData = await updateClipboard(usageKey);
|
||||
clipboardBroadcastChannel.postMessage(newData);
|
||||
queryClient.setQueryData(['clipboard'], newData);
|
||||
showToast(intl.formatMessage(messages.done));
|
||||
} catch (error) {
|
||||
showToast(intl.formatMessage(messages.error));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Handle messages from the broadcast channel
|
||||
clipboardBroadcastChannel.onmessage = (event) => {
|
||||
// Note: if this useClipboard() hook is used many times on one page,
|
||||
// this will result in many separate calls to setQueryData() whenever
|
||||
// the clipboard contents change, but that is fine and shouldn't actually
|
||||
// cause any issues. If it did, we could refactor this into a
|
||||
// <ClipboardContextProvider> that manages a single clipboardBroadcastChannel
|
||||
// rather than having a separate channel per useClipboard hook.
|
||||
queryClient.setQueryData(['clipboard'], event.data);
|
||||
};
|
||||
|
||||
// Cleanup function for the BroadcastChannel when the hook is unmounted
|
||||
return () => {
|
||||
clipboardBroadcastChannel.close();
|
||||
};
|
||||
}, [clipboardBroadcastChannel]);
|
||||
|
||||
const isPasteable = canEdit && clipboardData?.content?.status !== CLIPBOARD_STATUS.expired;
|
||||
const showPasteUnit = isPasteable && clipboardData?.content?.blockType === 'vertical';
|
||||
const showPasteXBlock = isPasteable
|
||||
&& clipboardData?.content
|
||||
&& !STRUCTURAL_XBLOCK_TYPES.includes(clipboardData.content?.blockType);
|
||||
|
||||
return {
|
||||
showPasteUnit,
|
||||
showPasteXBlock,
|
||||
sharedClipboardData: clipboardData,
|
||||
copyToClipboard,
|
||||
};
|
||||
};
|
||||
|
||||
export default useClipboard;
|
||||
@@ -1,81 +0,0 @@
|
||||
// @ts-check
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { getClipboard } from '../../data/api';
|
||||
import { updateClipboardData } from '../../data/slice';
|
||||
import { CLIPBOARD_STATUS, STRUCTURAL_XBLOCK_TYPES, STUDIO_CLIPBOARD_CHANNEL } from '../../../constants';
|
||||
import { getClipboardData } from '../../data/selectors';
|
||||
|
||||
/**
|
||||
* Custom React hook for managing clipboard functionality.
|
||||
*
|
||||
* @param {boolean} canEdit - Flag indicating whether the clipboard is editable.
|
||||
* @returns {Object} - An object containing state variables and functions related to clipboard functionality.
|
||||
* @property {boolean} showPasteUnit - Flag indicating whether the "Paste Unit" button should be visible.
|
||||
* @property {boolean} showPasteXBlock - Flag indicating whether the "Paste XBlock" button should be visible.
|
||||
* @property {Object} sharedClipboardData - The shared clipboard data object.
|
||||
*/
|
||||
const useCopyToClipboard = (canEdit = true) => {
|
||||
const dispatch = useDispatch();
|
||||
const [clipboardBroadcastChannel] = useState(() => new BroadcastChannel(STUDIO_CLIPBOARD_CHANNEL));
|
||||
const [showPasteUnit, setShowPasteUnit] = useState(false);
|
||||
const [showPasteXBlock, setShowPasteXBlock] = useState(false);
|
||||
const [sharedClipboardData, setSharedClipboardData] = useState({});
|
||||
const clipboardData = useSelector(getClipboardData);
|
||||
|
||||
// Function to refresh the paste button's visibility
|
||||
const refreshPasteButton = (data) => {
|
||||
const isPasteable = canEdit && data?.content && data.content.status !== CLIPBOARD_STATUS.expired;
|
||||
const isPasteableXBlock = isPasteable && !STRUCTURAL_XBLOCK_TYPES.includes(data.content.blockType);
|
||||
const isPasteableUnit = isPasteable && data.content.blockType === 'vertical';
|
||||
|
||||
setShowPasteXBlock(!!isPasteableXBlock);
|
||||
setShowPasteUnit(!!isPasteableUnit);
|
||||
};
|
||||
|
||||
// Called on initial render to fetch and populate the initial clipboard data in redux state.
|
||||
// Without this, the initial clipboard data redux state is always null.
|
||||
useEffect(() => {
|
||||
const fetchInitialClipboardData = async () => {
|
||||
try {
|
||||
const userClipboard = await getClipboard();
|
||||
dispatch(updateClipboardData(userClipboard));
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`Failed to fetch initial clipboard data: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
fetchInitialClipboardData();
|
||||
}, [dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
// Handle updates to clipboard data
|
||||
if (canEdit) {
|
||||
refreshPasteButton(clipboardData);
|
||||
setSharedClipboardData(clipboardData);
|
||||
clipboardBroadcastChannel.postMessage(clipboardData);
|
||||
} else {
|
||||
setShowPasteXBlock(false);
|
||||
setShowPasteUnit(false);
|
||||
}
|
||||
}, [clipboardData, canEdit, clipboardBroadcastChannel]);
|
||||
|
||||
useEffect(() => {
|
||||
// Handle messages from the broadcast channel
|
||||
clipboardBroadcastChannel.onmessage = (event) => {
|
||||
setSharedClipboardData(event.data);
|
||||
refreshPasteButton(event.data);
|
||||
};
|
||||
|
||||
// Cleanup function for the BroadcastChannel when the hook is unmounted
|
||||
return () => {
|
||||
clipboardBroadcastChannel.close();
|
||||
};
|
||||
}, [clipboardBroadcastChannel]);
|
||||
|
||||
return { showPasteUnit, showPasteXBlock, sharedClipboardData };
|
||||
};
|
||||
|
||||
export default useCopyToClipboard;
|
||||
@@ -1,122 +0,0 @@
|
||||
import { renderHook, act } from '@testing-library/react-hooks';
|
||||
import { Provider } from 'react-redux';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import initializeStore from '../../../store';
|
||||
import { executeThunk } from '../../../utils';
|
||||
import { clipboardUnit, clipboardXBlock } from '../../../__mocks__';
|
||||
import { copyToClipboard } from '../../data/thunks';
|
||||
import { getClipboardUrl } from '../../data/api';
|
||||
import useCopyToClipboard from './useCopyToClipboard';
|
||||
|
||||
let axiosMock;
|
||||
let store;
|
||||
const unitId = 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_0270f6de40fc';
|
||||
const xblockId = 'block-v1:edX+DemoX+Demo_Course+type@html+block@030e35c4756a4ddc8d40b95fbbfff4d4';
|
||||
const clipboardBroadcastChannelMock = {
|
||||
postMessage: jest.fn(),
|
||||
close: jest.fn(),
|
||||
};
|
||||
|
||||
global.BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
|
||||
|
||||
const wrapper = ({ children }) => (
|
||||
<Provider store={store}>
|
||||
<IntlProvider locale="en">
|
||||
{children}
|
||||
</IntlProvider>
|
||||
</Provider>
|
||||
);
|
||||
|
||||
describe('useCopyToClipboard', () => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
});
|
||||
|
||||
it('initializes correctly', () => {
|
||||
const { result } = renderHook(() => useCopyToClipboard(true), { wrapper });
|
||||
|
||||
expect(result.current.showPasteUnit).toBe(false);
|
||||
expect(result.current.showPasteXBlock).toBe(false);
|
||||
});
|
||||
|
||||
describe('clipboard data update effect', () => {
|
||||
it('returns falsy flags if canEdit = false', async () => {
|
||||
const { result } = renderHook(() => useCopyToClipboard(false), { wrapper });
|
||||
|
||||
axiosMock
|
||||
.onPost(getClipboardUrl())
|
||||
.reply(200, clipboardUnit);
|
||||
axiosMock
|
||||
.onGet(getClipboardUrl())
|
||||
.reply(200, clipboardUnit);
|
||||
|
||||
await act(async () => {
|
||||
await executeThunk(copyToClipboard(unitId), store.dispatch);
|
||||
});
|
||||
expect(result.current.showPasteUnit).toBe(false);
|
||||
expect(result.current.showPasteXBlock).toBe(false);
|
||||
});
|
||||
|
||||
it('returns flag to display the Paste Unit button', async () => {
|
||||
const { result } = renderHook(() => useCopyToClipboard(true), { wrapper });
|
||||
|
||||
axiosMock
|
||||
.onPost(getClipboardUrl())
|
||||
.reply(200, clipboardUnit);
|
||||
axiosMock
|
||||
.onGet(getClipboardUrl())
|
||||
.reply(200, clipboardUnit);
|
||||
|
||||
await act(async () => {
|
||||
await executeThunk(copyToClipboard(unitId), store.dispatch);
|
||||
});
|
||||
expect(result.current.showPasteUnit).toBe(true);
|
||||
expect(result.current.showPasteXBlock).toBe(false);
|
||||
});
|
||||
|
||||
it('returns flag to display the Paste XBlock button', async () => {
|
||||
const { result } = renderHook(() => useCopyToClipboard(true), { wrapper });
|
||||
|
||||
axiosMock
|
||||
.onPost(getClipboardUrl())
|
||||
.reply(200, clipboardXBlock);
|
||||
axiosMock
|
||||
.onGet(getClipboardUrl())
|
||||
.reply(200, clipboardXBlock);
|
||||
|
||||
await act(async () => {
|
||||
await executeThunk(copyToClipboard(xblockId), store.dispatch);
|
||||
});
|
||||
expect(result.current.showPasteUnit).toBe(false);
|
||||
expect(result.current.showPasteXBlock).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('broadcast channel message handling', () => {
|
||||
it('updates states correctly on receiving a broadcast message', async () => {
|
||||
const { result } = renderHook(() => useCopyToClipboard(true), { wrapper });
|
||||
clipboardBroadcastChannelMock.onmessage({ data: clipboardUnit });
|
||||
|
||||
expect(result.current.showPasteUnit).toBe(true);
|
||||
expect(result.current.showPasteXBlock).toBe(false);
|
||||
|
||||
clipboardBroadcastChannelMock.onmessage({ data: clipboardXBlock });
|
||||
expect(result.current.showPasteUnit).toBe(false);
|
||||
expect(result.current.showPasteXBlock).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,2 +1,2 @@
|
||||
export { default as useCopyToClipboard } from './hooks/useCopyToClipboard';
|
||||
export { default as useClipboard } from './hooks/useClipboard';
|
||||
export { default as PasteComponent } from './paste-component';
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import PropsTypes from 'prop-types';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Button } from '@openedx/paragon';
|
||||
import { ContentCopy as ContentCopyIcon } from '@openedx/paragon/icons';
|
||||
|
||||
const PasteButton = ({ onClick, text, className }) => {
|
||||
const { blockId } = useParams();
|
||||
|
||||
const handlePasteXBlockComponent = () => {
|
||||
onClick({ stagedContent: 'clipboard', parentLocator: blockId }, null, blockId);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={className}
|
||||
iconBefore={ContentCopyIcon}
|
||||
variant="outline-primary"
|
||||
block
|
||||
onClick={handlePasteXBlockComponent}
|
||||
>
|
||||
{text}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
PasteButton.propTypes = {
|
||||
onClick: PropsTypes.func.isRequired,
|
||||
text: PropsTypes.string.isRequired,
|
||||
className: PropsTypes.string,
|
||||
};
|
||||
|
||||
PasteButton.defaultProps = {
|
||||
className: undefined,
|
||||
};
|
||||
|
||||
export default PasteButton;
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Button } from '@openedx/paragon';
|
||||
import { ContentCopy as ContentCopyIcon } from '@openedx/paragon/icons';
|
||||
|
||||
interface PasteButtonProps {
|
||||
onClick: () => void;
|
||||
text: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const PasteButton = ({ onClick, text, className }: PasteButtonProps) => (
|
||||
<Button
|
||||
className={className}
|
||||
iconBefore={ContentCopyIcon}
|
||||
variant="outline-primary"
|
||||
block
|
||||
onClick={onClick}
|
||||
>
|
||||
{text}
|
||||
</Button>
|
||||
);
|
||||
|
||||
export default PasteButton;
|
||||
@@ -1,16 +1,24 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Icon, Popover, Stack } from '@openedx/paragon';
|
||||
import { OpenInNew as OpenInNewIcon } from '@openedx/paragon/icons';
|
||||
|
||||
import type { ClipboardStatus } from '../../../data/api';
|
||||
import messages from '../messages';
|
||||
import { clipboardPropsTypes } from '../constants';
|
||||
|
||||
const PopoverContent = ({ clipboardData }) => {
|
||||
interface PopoverContentProps {
|
||||
clipboardData: ClipboardStatus,
|
||||
}
|
||||
|
||||
const PopoverContent = ({ clipboardData } : PopoverContentProps) => {
|
||||
const intl = useIntl();
|
||||
const { sourceEditUrl, content, sourceContextTitle } = clipboardData;
|
||||
|
||||
// istanbul ignore if: this should never happen
|
||||
if (!content) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover.Title
|
||||
className="clipboard-popover-title"
|
||||
@@ -40,8 +48,4 @@ const PopoverContent = ({ clipboardData }) => {
|
||||
);
|
||||
};
|
||||
|
||||
PopoverContent.propTypes = {
|
||||
clipboardData: PropTypes.shape(clipboardPropsTypes).isRequired,
|
||||
};
|
||||
|
||||
export default PopoverContent;
|
||||
@@ -1,14 +1,19 @@
|
||||
import { useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Icon } from '@openedx/paragon';
|
||||
import { Question as QuestionIcon } from '@openedx/paragon/icons';
|
||||
|
||||
import messages from '../messages';
|
||||
|
||||
interface WhatsInClipboardProps {
|
||||
handlePopoverToggle: (show: boolean) => void;
|
||||
togglePopover: (show: boolean) => void;
|
||||
popoverElementRef: React.RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
const WhatsInClipboard = ({
|
||||
handlePopoverToggle, togglePopover, popoverElementRef,
|
||||
}) => {
|
||||
}: WhatsInClipboardProps) => {
|
||||
const intl = useIntl();
|
||||
const triggerElementRef = useRef(null);
|
||||
|
||||
@@ -46,13 +51,4 @@ const WhatsInClipboard = ({
|
||||
);
|
||||
};
|
||||
|
||||
WhatsInClipboard.propTypes = {
|
||||
handlePopoverToggle: PropTypes.func.isRequired,
|
||||
togglePopover: PropTypes.func.isRequired,
|
||||
popoverElementRef: PropTypes.oneOfType([
|
||||
PropTypes.func,
|
||||
PropTypes.shape({ current: PropTypes.instanceOf(Element) }),
|
||||
]).isRequired,
|
||||
};
|
||||
|
||||
export default WhatsInClipboard;
|
||||
@@ -1,11 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export const clipboardPropsTypes = {
|
||||
sourceEditUrl: PropTypes.string.isRequired,
|
||||
content: PropTypes.shape({
|
||||
displayName: PropTypes.string.isRequired,
|
||||
blockTypeDisplay: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
sourceContextTitle: PropTypes.string.isRequired,
|
||||
};
|
||||
@@ -1,13 +1,19 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { OverlayTrigger, Popover } from '@openedx/paragon';
|
||||
|
||||
import { PopoverContent, PasteButton, WhatsInClipboard } from './components';
|
||||
import { clipboardPropsTypes } from './constants';
|
||||
import type { ClipboardStatus } from '../../data/api';
|
||||
|
||||
interface PasteComponentProps {
|
||||
onClick: () => void;
|
||||
clipboardData: ClipboardStatus;
|
||||
text: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const PasteComponent = ({
|
||||
onClick, clipboardData, text, className,
|
||||
}) => {
|
||||
}: PasteComponentProps) => {
|
||||
const [showPopover, togglePopover] = useState(false);
|
||||
const popoverElementRef = useRef(null);
|
||||
|
||||
@@ -24,9 +30,7 @@ const PasteComponent = ({
|
||||
onBlur={() => handlePopoverToggle(false)}
|
||||
{...props}
|
||||
>
|
||||
{clipboardData && (
|
||||
<PopoverContent clipboardData={clipboardData} />
|
||||
)}
|
||||
<PopoverContent clipboardData={clipboardData} />
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
@@ -48,18 +52,4 @@ const PasteComponent = ({
|
||||
);
|
||||
};
|
||||
|
||||
PasteComponent.propTypes = {
|
||||
onClick: PropTypes.func.isRequired,
|
||||
text: PropTypes.string.isRequired,
|
||||
clipboardData: PropTypes.shape(clipboardPropsTypes),
|
||||
blockType: PropTypes.string,
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
PasteComponent.defaultProps = {
|
||||
clipboardData: null,
|
||||
blockType: null,
|
||||
className: undefined,
|
||||
};
|
||||
|
||||
export default PasteComponent;
|
||||
@@ -223,6 +223,7 @@ const ConfigureModal = ({
|
||||
category={category}
|
||||
isSubsection={isSubsection}
|
||||
showWarning={visibilityState === VisibilityTypes.STAFF_ONLY}
|
||||
isSelfPaced={isSelfPaced}
|
||||
/>
|
||||
</Tab>
|
||||
<Tab eventKey="advanced" title={intl.formatMessage(messages.advancedTabTitle)}>
|
||||
|
||||
@@ -11,6 +11,7 @@ const VisibilityTab = ({
|
||||
category,
|
||||
showWarning,
|
||||
isSubsection,
|
||||
isSelfPaced,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const visibilityTitle = COURSE_BLOCK_NAMES[category]?.name;
|
||||
@@ -53,6 +54,13 @@ const VisibilityTab = ({
|
||||
setFieldValue('showCorrectness', e.target.value);
|
||||
};
|
||||
|
||||
const hideDueMessage = {
|
||||
hideContentLabel: isSelfPaced ? messages.hideContentAfterEnd : messages.hideContentAfterDue,
|
||||
hideContentDescription: (
|
||||
isSelfPaced ? messages.hideContentAfterEndDescription : messages.hideContentAfterDueDescription
|
||||
),
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<h5 className="mt-4 text-gray-700">
|
||||
@@ -72,9 +80,10 @@ const VisibilityTab = ({
|
||||
</Form.Radio>
|
||||
<Form.Text><FormattedMessage {...messages.showEntireSubsectionDescription} /></Form.Text>
|
||||
<Form.Radio value="hideDue">
|
||||
<FormattedMessage {...messages.hideContentAfterDue} />
|
||||
<FormattedMessage {...hideDueMessage.hideContentLabel} />
|
||||
</Form.Radio>
|
||||
<Form.Text><FormattedMessage {...messages.hideContentAfterDueDescription} /></Form.Text>
|
||||
<Form.Text><FormattedMessage {...hideDueMessage.hideContentDescription} />
|
||||
</Form.Text>
|
||||
<Form.Radio value="hide">
|
||||
<FormattedMessage {...messages.hideEntireSubsection} />
|
||||
</Form.Radio>
|
||||
@@ -130,6 +139,11 @@ VisibilityTab.propTypes = {
|
||||
category: PropTypes.string.isRequired,
|
||||
showWarning: PropTypes.bool.isRequired,
|
||||
isSubsection: PropTypes.bool.isRequired,
|
||||
isSelfPaced: PropTypes.bool,
|
||||
};
|
||||
|
||||
VisibilityTab.defaultProps = {
|
||||
isSelfPaced: false,
|
||||
};
|
||||
|
||||
export default injectIntl(VisibilityTab);
|
||||
|
||||
@@ -139,6 +139,14 @@ const messages = defineMessages({
|
||||
id: 'course-authoring.course-outline.configure-modal.visibility-tab.hide-content-after-due-description',
|
||||
defaultMessage: 'After the subsection\'s due date has passed, learners can no longer access its content. The subsection is not included in grade calculations.',
|
||||
},
|
||||
hideContentAfterEnd: {
|
||||
id: 'course-authoring.course-outline.configure-modal.visibility-tab.hide-content-after-end',
|
||||
defaultMessage: 'Hide content after end date',
|
||||
},
|
||||
hideContentAfterEndDescription: {
|
||||
id: 'course-authoring.course-outline.configure-modal.visibility-tab.hide-content-after-end-description',
|
||||
defaultMessage: 'After the course\'s end date has passed, learners can no longer access its content. The subsection is not included in grade calculations.',
|
||||
},
|
||||
hideEntireSubsection: {
|
||||
id: 'course-authoring.course-outline.configure-modal.visibility-tab.hide-entire-subsection',
|
||||
defaultMessage: 'Hide entire subsection',
|
||||
|
||||
@@ -27,12 +27,12 @@ const useCreateOrRerunCourse = (initialValues) => {
|
||||
const allOrganizations = useSelector(getOrganizations);
|
||||
const postErrors = useSelector(getPostErrors);
|
||||
const {
|
||||
allowToCreateNewOrg,
|
||||
canCreateOrganizations,
|
||||
allowedOrganizations,
|
||||
} = useSelector(getStudioHomeData);
|
||||
const [isFormFilled, setFormFilled] = useState(false);
|
||||
const [showErrorBanner, setShowErrorBanner] = useState(false);
|
||||
const organizations = allowToCreateNewOrg ? allOrganizations : allowedOrganizations;
|
||||
const organizations = canCreateOrganizations ? allOrganizations : allowedOrganizations;
|
||||
|
||||
const { specialCharsRule, noSpaceRule } = REGEX_RULES;
|
||||
const validationSchema = Yup.object().shape({
|
||||
@@ -78,7 +78,7 @@ const useCreateOrRerunCourse = (initialValues) => {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (allowToCreateNewOrg) {
|
||||
if (canCreateOrganizations) {
|
||||
dispatch(fetchOrganizationsQuery());
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -5,4 +5,3 @@ export const getCourseData = (state) => state.generic.createOrRerunCourse.course
|
||||
export const getCourseRerunData = (state) => state.generic.createOrRerunCourse.courseRerunData;
|
||||
export const getRedirectUrlObj = (state) => state.generic.createOrRerunCourse.redirectUrlObj;
|
||||
export const getPostErrors = (state) => state.generic.createOrRerunCourse.postErrors;
|
||||
export const getClipboardData = (state) => state.generic.clipboardData;
|
||||
|
||||
@@ -18,7 +18,6 @@ const slice = createSlice({
|
||||
redirectUrlObj: {},
|
||||
postErrors: {},
|
||||
},
|
||||
clipboardData: null,
|
||||
},
|
||||
reducers: {
|
||||
fetchOrganizations: (state, { payload }) => {
|
||||
@@ -42,9 +41,6 @@ const slice = createSlice({
|
||||
updatePostErrors: (state, { payload }) => {
|
||||
state.createOrRerunCourse.postErrors = payload;
|
||||
},
|
||||
updateClipboardData: (state, { payload }) => {
|
||||
state.clipboardData = payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -56,7 +52,6 @@ export const {
|
||||
updateSavingStatus,
|
||||
updateCourseData,
|
||||
updateRedirectUrlObj,
|
||||
updateClipboardData,
|
||||
} = slice.actions;
|
||||
|
||||
export const {
|
||||
|
||||
@@ -1,10 +1,3 @@
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
|
||||
import { CLIPBOARD_STATUS, NOTIFICATION_MESSAGES } from '../../constants';
|
||||
import {
|
||||
hideProcessingNotification,
|
||||
showProcessingNotification,
|
||||
} from '../processing-notification/data/slice';
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import {
|
||||
fetchOrganizations,
|
||||
@@ -13,14 +6,11 @@ import {
|
||||
updateRedirectUrlObj,
|
||||
updateCourseRerunData,
|
||||
updateSavingStatus,
|
||||
updateClipboardData,
|
||||
} from './slice';
|
||||
import {
|
||||
createOrRerunCourse,
|
||||
getOrganizations,
|
||||
getCourseRerun,
|
||||
updateClipboard,
|
||||
getClipboard,
|
||||
} from './api';
|
||||
|
||||
export function fetchOrganizationsQuery() {
|
||||
@@ -63,33 +53,3 @@ export function updateCreateOrRerunCourseQuery(courseData) {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function copyToClipboard(usageKey) {
|
||||
const POLL_INTERVAL_MS = 1000; // Timeout duration for polling in milliseconds
|
||||
|
||||
return async (dispatch) => {
|
||||
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.copying));
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
|
||||
try {
|
||||
let clipboardData = await updateClipboard(usageKey);
|
||||
|
||||
while (clipboardData.content?.status === CLIPBOARD_STATUS.loading) {
|
||||
// eslint-disable-next-line no-await-in-loop,no-promise-executor-return
|
||||
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
||||
clipboardData = await getClipboard(); // eslint-disable-line no-await-in-loop
|
||||
}
|
||||
|
||||
if (clipboardData.content?.status === CLIPBOARD_STATUS.ready) {
|
||||
dispatch(updateClipboardData(clipboardData));
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
} else {
|
||||
throw new Error(`Unexpected clipboard status "${clipboardData.content?.status}" in successful API response.`);
|
||||
}
|
||||
} catch (error) {
|
||||
logError('Error copying to clipboard:', error);
|
||||
} finally {
|
||||
dispatch(hideProcessingNotification());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { capitalize } from 'lodash';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { initializeMocks, render, screen } from '../../testUtils';
|
||||
import { NOTIFICATION_MESSAGES } from '../../constants';
|
||||
import ProcessingNotification from '.';
|
||||
|
||||
const mockUndo = jest.fn();
|
||||
|
||||
const props = {
|
||||
title: NOTIFICATION_MESSAGES.saving,
|
||||
title: 'ThIs IS a Test. OK?',
|
||||
isShow: true,
|
||||
action: {
|
||||
label: 'Undo',
|
||||
@@ -22,16 +20,16 @@ describe('<ProcessingNotification />', () => {
|
||||
|
||||
it('renders successfully', () => {
|
||||
render(<ProcessingNotification {...props} close={() => {}} />);
|
||||
expect(screen.getByText(capitalize(props.title))).toBeInTheDocument();
|
||||
expect(screen.getByText(props.title)).toBeInTheDocument();
|
||||
expect(screen.getByText('Undo')).toBeInTheDocument();
|
||||
expect(screen.getByRole('alert').querySelector('.processing-notification-hide-close-button')).not.toBeInTheDocument();
|
||||
userEvent.click(screen.getByText('Undo'));
|
||||
expect(mockUndo).toBeCalled();
|
||||
expect(mockUndo).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('add hide-close-button class if no close action is passed', () => {
|
||||
render(<ProcessingNotification {...props} />);
|
||||
expect(screen.getByText(capitalize(props.title))).toBeInTheDocument();
|
||||
expect(screen.getByText(props.title)).toBeInTheDocument();
|
||||
expect(screen.getByRole('alert').querySelector('.processing-notification-hide-close-button')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
Icon, Toast,
|
||||
} from '@openedx/paragon';
|
||||
import { Settings as IconSettings } from '@openedx/paragon/icons';
|
||||
import { capitalize } from 'lodash';
|
||||
import classNames from 'classnames';
|
||||
|
||||
const ProcessingNotification = ({
|
||||
@@ -18,7 +17,7 @@ const ProcessingNotification = ({
|
||||
>
|
||||
<span className="d-flex align-items-center">
|
||||
<Icon className="processing-notification-icon mb-0 mr-2" src={IconSettings} />
|
||||
<span className="font-weight-bold h4 mb-0 text-white">{capitalize(title)}</span>
|
||||
<span className="font-weight-bold h4 mb-0 text-white">{title}</span>
|
||||
</span>
|
||||
</Toast>
|
||||
);
|
||||
|
||||
@@ -1,21 +1,15 @@
|
||||
import React from 'react';
|
||||
import { render, unmountComponentAtNode } from 'react-dom';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import PromptIfDirty from './PromptIfDirty';
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
import usePromptIfDirty from './usePromptIfDirty';
|
||||
|
||||
describe('PromptIfDirty', () => {
|
||||
let container = null;
|
||||
describe('usePromptIfDirty', () => {
|
||||
let mockEvent = null;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
mockEvent = new Event('beforeunload');
|
||||
jest.spyOn(window, 'addEventListener');
|
||||
jest.spyOn(window, 'removeEventListener');
|
||||
jest.spyOn(mockEvent, 'preventDefault');
|
||||
Object.defineProperty(mockEvent, 'returnValue', { writable: true });
|
||||
mockEvent.returnValue = '';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -23,49 +17,32 @@ describe('PromptIfDirty', () => {
|
||||
window.removeEventListener.mockRestore();
|
||||
mockEvent.preventDefault.mockRestore();
|
||||
mockEvent = null;
|
||||
unmountComponentAtNode(container);
|
||||
container.remove();
|
||||
container = null;
|
||||
});
|
||||
|
||||
it('should add event listener on mount', () => {
|
||||
act(() => {
|
||||
render(<PromptIfDirty dirty />, container);
|
||||
});
|
||||
renderHook(() => usePromptIfDirty(() => true));
|
||||
|
||||
expect(window.addEventListener).toHaveBeenCalledWith('beforeunload', expect.any(Function));
|
||||
});
|
||||
|
||||
it('should remove event listener on unmount', () => {
|
||||
act(() => {
|
||||
render(<PromptIfDirty dirty />, container);
|
||||
});
|
||||
act(() => {
|
||||
unmountComponentAtNode(container);
|
||||
});
|
||||
const { unmount } = renderHook(() => usePromptIfDirty(() => true));
|
||||
unmount();
|
||||
|
||||
expect(window.removeEventListener).toHaveBeenCalledWith('beforeunload', expect.any(Function));
|
||||
});
|
||||
|
||||
it('should call preventDefault and set returnValue when dirty is true', () => {
|
||||
act(() => {
|
||||
render(<PromptIfDirty dirty />, container);
|
||||
});
|
||||
act(() => {
|
||||
window.dispatchEvent(mockEvent);
|
||||
});
|
||||
renderHook(() => usePromptIfDirty(() => true));
|
||||
window.dispatchEvent(mockEvent);
|
||||
|
||||
expect(mockEvent.preventDefault).toHaveBeenCalled();
|
||||
expect(mockEvent.returnValue).toBe('');
|
||||
expect(mockEvent.returnValue).toBe(true);
|
||||
});
|
||||
|
||||
it('should not call preventDefault when dirty is false', () => {
|
||||
act(() => {
|
||||
render(<PromptIfDirty dirty={false} />, container);
|
||||
});
|
||||
act(() => {
|
||||
window.dispatchEvent(mockEvent);
|
||||
});
|
||||
renderHook(() => usePromptIfDirty(() => false));
|
||||
window.dispatchEvent(mockEvent);
|
||||
|
||||
expect(mockEvent.preventDefault).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const PromptIfDirty = ({ dirty }) => {
|
||||
const usePromptIfDirty = (checkIfDirty : () => boolean) => {
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line consistent-return
|
||||
const handleBeforeUnload = (event) => {
|
||||
if (dirty) {
|
||||
if (checkIfDirty()) {
|
||||
event.preventDefault();
|
||||
// Included for legacy support, e.g. Chrome/Edge < 119
|
||||
event.returnValue = true; // eslint-disable-line no-param-reassign
|
||||
}
|
||||
};
|
||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||
@@ -14,11 +15,9 @@ const PromptIfDirty = ({ dirty }) => {
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
};
|
||||
}, [dirty]);
|
||||
}, [checkIfDirty]);
|
||||
|
||||
return null;
|
||||
};
|
||||
PromptIfDirty.propTypes = {
|
||||
dirty: PropTypes.bool.isRequired,
|
||||
};
|
||||
export default PromptIfDirty;
|
||||
|
||||
export default usePromptIfDirty;
|
||||
@@ -13,7 +13,7 @@ const TestComponentToShow = () => {
|
||||
const { showToast } = React.useContext(ToastContext);
|
||||
|
||||
React.useEffect(() => {
|
||||
showToast('This is the toast!');
|
||||
showToast('This is the Toast!');
|
||||
}, [showToast]);
|
||||
|
||||
return <div>Content</div>;
|
||||
@@ -23,7 +23,7 @@ const TestComponentToClose = () => {
|
||||
const { showToast, closeToast } = React.useContext(ToastContext);
|
||||
|
||||
React.useEffect(() => {
|
||||
showToast('This is the toast!');
|
||||
showToast('This is the Toast!');
|
||||
closeToast();
|
||||
}, [showToast]);
|
||||
|
||||
@@ -59,19 +59,19 @@ describe('<ToastProvider />', () => {
|
||||
|
||||
it('should show toast', async () => {
|
||||
render(<RootWrapper><TestComponentToShow /></RootWrapper>);
|
||||
expect(await screen.findByText('This is the toast!')).toBeInTheDocument();
|
||||
expect(await screen.findByText('This is the Toast!')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should close toast after 5000ms', async () => {
|
||||
render(<RootWrapper><TestComponentToShow /></RootWrapper>);
|
||||
expect(await screen.findByText('This is the toast!')).toBeInTheDocument();
|
||||
expect(await screen.findByText('This is the Toast!')).toBeInTheDocument();
|
||||
jest.advanceTimersByTime(6000);
|
||||
expect(screen.queryByText('This is the toast!')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('This is the Toast!')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should close toast', async () => {
|
||||
render(<RootWrapper><TestComponentToClose /></RootWrapper>);
|
||||
expect(await screen.findByText('Content')).toBeInTheDocument();
|
||||
expect(screen.queryByText('This is the toast!')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('This is the Toast!')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -90,7 +90,7 @@ export const useToolsMenuItems = courseId => {
|
||||
},
|
||||
...(getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true'
|
||||
? [{
|
||||
href: '#export-tags',
|
||||
href: `${studioBaseUrl}/course/${courseId}#export-tags`,
|
||||
title: intl.formatMessage(messages['header.links.exportTags']),
|
||||
}] : []
|
||||
),
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
}
|
||||
|
||||
.library-authoring-sidebar {
|
||||
z-index: 1001; // to appear over header
|
||||
z-index: 1000; // same as header
|
||||
flex: 450px 0 0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
@@ -21,6 +21,10 @@
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
z-index: 1001; // over the sidebar
|
||||
}
|
||||
|
||||
// Reduce breadcrumb bottom margin
|
||||
ol.list-inline {
|
||||
margin-bottom: 0;
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
mockXBlockFields,
|
||||
} from './data/api.mocks';
|
||||
import { mockContentSearchConfig } from '../search-manager/data/api.mock';
|
||||
import { studioHomeMock } from '../studio-home/__mocks__';
|
||||
import { getStudioHomeApiUrl } from '../studio-home/data/api';
|
||||
import { mockBroadcastChannel } from '../generic/data/api.mock';
|
||||
import { LibraryLayout } from '.';
|
||||
import { getLibraryCollectionsApiUrl } from './data/api';
|
||||
@@ -40,46 +42,19 @@ const returnEmptyResult = (_url, req) => {
|
||||
// We have to replace the query (search keywords) in the mock results with the actual query,
|
||||
// because otherwise we may have an inconsistent state that causes more queries and unexpected results.
|
||||
mockEmptyResult.results[0].query = query;
|
||||
mockEmptyResult.results[2].query = query;
|
||||
// And fake the required '_formatted' fields; it contains the highlighting <mark>...</mark> around matched words
|
||||
// eslint-disable-next-line no-underscore-dangle, no-param-reassign
|
||||
mockEmptyResult.results[0]?.hits.forEach((hit) => { hit._formatted = { ...hit }; });
|
||||
// eslint-disable-next-line no-underscore-dangle, no-param-reassign
|
||||
mockEmptyResult.results[2]?.hits.forEach((hit) => { hit._formatted = { ...hit }; });
|
||||
return mockEmptyResult;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns 2 components from the search query.
|
||||
* This lets us test that the StudioHome "View All" button is hidden when a
|
||||
* low number of search results are shown (<=4 by default).
|
||||
*/
|
||||
const returnLowNumberResults = (_url, req) => {
|
||||
const requestData = JSON.parse(req.body?.toString() ?? '');
|
||||
const query = requestData?.queries[0]?.q ?? '';
|
||||
const newMockResult = { ...mockResult };
|
||||
// We have to replace the query (search keywords) in the mock results with the actual query,
|
||||
// because otherwise we may have an inconsistent state that causes more queries and unexpected results.
|
||||
newMockResult.results[0].query = query;
|
||||
// Limit number of results to just 2
|
||||
newMockResult.results[0].hits = mockResult.results[0]?.hits.slice(0, 2);
|
||||
newMockResult.results[2].hits = mockResult.results[2]?.hits.slice(0, 2);
|
||||
newMockResult.results[0].estimatedTotalHits = 2;
|
||||
newMockResult.results[2].estimatedTotalHits = 2;
|
||||
// And fake the required '_formatted' fields; it contains the highlighting <mark>...</mark> around matched words
|
||||
// eslint-disable-next-line no-underscore-dangle, no-param-reassign
|
||||
newMockResult.results[0]?.hits.forEach((hit) => { hit._formatted = { ...hit }; });
|
||||
// eslint-disable-next-line no-underscore-dangle, no-param-reassign
|
||||
newMockResult.results[2]?.hits.forEach((hit) => { hit._formatted = { ...hit }; });
|
||||
return newMockResult;
|
||||
};
|
||||
|
||||
const path = '/library/:libraryId/*';
|
||||
const libraryTitle = mockContentLibrary.libraryData.title;
|
||||
|
||||
describe('<LibraryAuthoringPage />', () => {
|
||||
beforeEach(() => {
|
||||
initializeMocks();
|
||||
const { axiosMock } = initializeMocks();
|
||||
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, studioHomeMock);
|
||||
|
||||
// The Meilisearch client-side API uses fetch, not Axios.
|
||||
fetchMock.mockReset();
|
||||
@@ -133,35 +108,25 @@ describe('<LibraryAuthoringPage />', () => {
|
||||
|
||||
expect(screen.queryByText('You have not added any content to this library yet.')).not.toBeInTheDocument();
|
||||
|
||||
// "Recently Modified" header + sort shown
|
||||
expect(screen.getAllByText('Recently Modified').length).toEqual(2);
|
||||
expect(screen.getByText('Collections (6)')).toBeInTheDocument();
|
||||
expect(screen.getByText('Components (10)')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Recently Modified').length).toEqual(1);
|
||||
expect((await screen.findAllByText('Introduction to Testing'))[0]).toBeInTheDocument();
|
||||
|
||||
// Navigate to the components tab
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Components' }));
|
||||
// "Recently Modified" default sort shown
|
||||
expect(screen.getAllByText('Recently Modified').length).toEqual(1);
|
||||
expect(screen.queryByText('Collections (6)')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Components (10)')).not.toBeInTheDocument();
|
||||
|
||||
// Navigate to the collections tab
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Collections' }));
|
||||
// "Recently Modified" default sort shown
|
||||
expect(screen.getAllByText('Recently Modified').length).toEqual(1);
|
||||
expect(screen.queryByText('Collections (6)')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Components (10)')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('There are 10 components in this library')).not.toBeInTheDocument();
|
||||
expect((await screen.findAllByText('Collection 1'))[0]).toBeInTheDocument();
|
||||
|
||||
// Go back to Home tab
|
||||
// This step is necessary to avoid the url change leak to other tests
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Home' }));
|
||||
// "Recently Modified" header + sort shown
|
||||
expect(screen.getAllByText('Recently Modified').length).toEqual(2);
|
||||
expect(screen.getByText('Collections (6)')).toBeInTheDocument();
|
||||
expect(screen.getByText('Components (10)')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'All Content' }));
|
||||
expect(screen.getAllByText('Recently Modified').length).toEqual(1);
|
||||
});
|
||||
|
||||
it('shows a library without components and collections', async () => {
|
||||
@@ -185,7 +150,7 @@ describe('<LibraryAuthoringPage />', () => {
|
||||
fireEvent.click(cancelButton);
|
||||
expect(collectionModalHeading).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Home' }));
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'All Content' }));
|
||||
expect(screen.getByText('You have not added any content to this library yet.')).toBeInTheDocument();
|
||||
|
||||
const addComponentButton = screen.getByRole('button', { name: /add component/i });
|
||||
@@ -243,7 +208,7 @@ describe('<LibraryAuthoringPage />', () => {
|
||||
|
||||
// Go back to Home tab
|
||||
// This step is necessary to avoid the url change leak to other tests
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Home' }));
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'All Content' }));
|
||||
});
|
||||
|
||||
it('should open and close new content sidebar', async () => {
|
||||
@@ -325,68 +290,6 @@ describe('<LibraryAuthoringPage />', () => {
|
||||
expect(manageAccess).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('show the "View All" button when viewing library with many components', async () => {
|
||||
await renderLibraryPage();
|
||||
|
||||
expect(screen.getByText('Content library')).toBeInTheDocument();
|
||||
expect((await screen.findAllByText(libraryTitle))[0]).toBeInTheDocument();
|
||||
|
||||
// "Recently Modified" header + sort shown
|
||||
await waitFor(() => { expect(screen.getAllByText('Recently Modified').length).toEqual(2); });
|
||||
expect(screen.getByText('Collections (6)')).toBeInTheDocument();
|
||||
expect(screen.getByText('Components (10)')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Introduction to Testing')[0]).toBeInTheDocument();
|
||||
expect(screen.queryByText('You have not added any content to this library yet.')).not.toBeInTheDocument();
|
||||
|
||||
// There should be two "View All" button, since the Components and Collections count
|
||||
// are above the preview limit (4)
|
||||
expect(screen.getAllByText('View All').length).toEqual(2);
|
||||
|
||||
// Clicking on first "View All" button should navigate to the Collections tab
|
||||
fireEvent.click(screen.getAllByText('View All')[0]);
|
||||
// "Recently Modified" default sort shown
|
||||
expect(screen.getAllByText('Recently Modified').length).toEqual(1);
|
||||
expect(screen.queryByText('Collections (6)')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Components (10)')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Collection 1')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Home' }));
|
||||
// Clicking on second "View All" button should navigate to the Components tab
|
||||
fireEvent.click(screen.getAllByText('View All')[1]);
|
||||
// "Recently Modified" default sort shown
|
||||
expect(screen.getAllByText('Recently Modified').length).toEqual(1);
|
||||
expect(screen.queryByText('Collections (6)')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Components (10)')).not.toBeInTheDocument();
|
||||
expect(screen.getAllByText('Introduction to Testing')[0]).toBeInTheDocument();
|
||||
|
||||
// Go back to Home tab
|
||||
// This step is necessary to avoid the url change leak to other tests
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Home' }));
|
||||
// "Recently Modified" header + sort shown
|
||||
expect(screen.getAllByText('Recently Modified').length).toEqual(2);
|
||||
expect(screen.getByText('Collections (6)')).toBeInTheDocument();
|
||||
expect(screen.getByText('Components (10)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show the "View All" button when viewing library with low number of components', async () => {
|
||||
fetchMock.post(searchEndpoint, returnLowNumberResults, { overwriteRoutes: true });
|
||||
await renderLibraryPage();
|
||||
|
||||
expect(screen.getByText('Content library')).toBeInTheDocument();
|
||||
expect((await screen.findAllByText(libraryTitle))[0]).toBeInTheDocument();
|
||||
|
||||
// "Recently Modified" header + sort shown
|
||||
await waitFor(() => { expect(screen.getAllByText('Recently Modified').length).toEqual(2); });
|
||||
expect(screen.getByText('Collections (2)')).toBeInTheDocument();
|
||||
expect(screen.getByText('Components (2)')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Introduction to Testing')[0]).toBeInTheDocument();
|
||||
expect(screen.queryByText('You have not added any content to this library yet.')).not.toBeInTheDocument();
|
||||
|
||||
// There should not be any "View All" button on page since Components count
|
||||
// is less than the preview limit (4)
|
||||
expect(screen.queryByText('View All')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sorts library components', async () => {
|
||||
await renderLibraryPage();
|
||||
|
||||
@@ -441,7 +344,7 @@ describe('<LibraryAuthoringPage />', () => {
|
||||
|
||||
// Re-selecting the previous sort option resets sort to default "Recently Modified"
|
||||
await testSortOption('Recently Published', 'modified:desc', true);
|
||||
expect(screen.getAllByText('Recently Modified').length).toEqual(3);
|
||||
expect(screen.getAllByText('Recently Modified').length).toEqual(2);
|
||||
|
||||
// Enter a keyword into the search box
|
||||
const searchBox = screen.getByRole('searchbox');
|
||||
@@ -464,7 +367,6 @@ describe('<LibraryAuthoringPage />', () => {
|
||||
expect(mockResult0.display_name).toStrictEqual(displayName);
|
||||
await renderLibraryPage();
|
||||
|
||||
// Click on the first component. It should appear twice, in both "Recently Modified" and "Components"
|
||||
fireEvent.click((await screen.findAllByText(displayName))[0]);
|
||||
|
||||
const sidebar = screen.getByTestId('library-sidebar');
|
||||
@@ -576,7 +478,7 @@ describe('<LibraryAuthoringPage />', () => {
|
||||
}
|
||||
|
||||
// Validate click on Problem type
|
||||
const problemMenu = screen.getByText('Problem');
|
||||
const problemMenu = screen.getAllByText('Problem')[0];
|
||||
expect(problemMenu).toBeInTheDocument();
|
||||
fireEvent.click(problemMenu);
|
||||
await waitFor(() => {
|
||||
@@ -644,7 +546,8 @@ describe('<LibraryAuthoringPage />', () => {
|
||||
expect(screen.getByText(/add content/i)).toBeInTheDocument();
|
||||
|
||||
// Open New collection Modal
|
||||
const newCollectionButton = screen.getAllByRole('button', { name: /collection/i })[4];
|
||||
const sidebar = screen.getByTestId('library-sidebar');
|
||||
const newCollectionButton = within(sidebar).getAllByRole('button', { name: /collection/i })[0];
|
||||
fireEvent.click(newCollectionButton);
|
||||
const collectionModalHeading = await screen.findByRole('heading', { name: /new collection/i });
|
||||
expect(collectionModalHeading).toBeInTheDocument();
|
||||
@@ -688,7 +591,8 @@ describe('<LibraryAuthoringPage />', () => {
|
||||
expect(screen.getByText(/add content/i)).toBeInTheDocument();
|
||||
|
||||
// Open New collection Modal
|
||||
const newCollectionButton = screen.getAllByRole('button', { name: /collection/i })[4];
|
||||
const sidebar = screen.getByTestId('library-sidebar');
|
||||
const newCollectionButton = within(sidebar).getAllByRole('button', { name: /collection/i })[0];
|
||||
fireEvent.click(newCollectionButton);
|
||||
const collectionModalHeading = await screen.findByRole('heading', { name: /new collection/i });
|
||||
expect(collectionModalHeading).toBeInTheDocument();
|
||||
@@ -721,7 +625,8 @@ describe('<LibraryAuthoringPage />', () => {
|
||||
expect(screen.getByText(/add content/i)).toBeInTheDocument();
|
||||
|
||||
// Open New collection Modal
|
||||
const newCollectionButton = screen.getAllByRole('button', { name: /collection/i })[4];
|
||||
const sidebar = screen.getByTestId('library-sidebar');
|
||||
const newCollectionButton = within(sidebar).getAllByRole('button', { name: /collection/i })[0];
|
||||
fireEvent.click(newCollectionButton);
|
||||
const collectionModalHeading = await screen.findByRole('heading', { name: /new collection/i });
|
||||
expect(collectionModalHeading).toBeInTheDocument();
|
||||
@@ -736,22 +641,6 @@ describe('<LibraryAuthoringPage />', () => {
|
||||
fireEvent.click(createButton);
|
||||
});
|
||||
|
||||
it('shows both components and collections in recently modified section', async () => {
|
||||
await renderLibraryPage();
|
||||
|
||||
expect(await screen.findByText('Content library')).toBeInTheDocument();
|
||||
expect((await screen.findAllByText(libraryTitle))[0]).toBeInTheDocument();
|
||||
|
||||
// "Recently Modified" header + sort shown
|
||||
expect(screen.getAllByText('Recently Modified').length).toEqual(2);
|
||||
const recentModifiedContainer = (await screen.findAllByText('Recently Modified'))[1].parentElement?.parentElement?.parentElement;
|
||||
expect(recentModifiedContainer).toBeTruthy();
|
||||
|
||||
const container = within(recentModifiedContainer!);
|
||||
expect(container.queryAllByText('Text').length).toBeGreaterThan(0);
|
||||
expect(container.queryAllByText('Collection').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('shows a single block when usageKey query param is set', async () => {
|
||||
render(<LibraryLayout />, {
|
||||
path,
|
||||
@@ -787,4 +676,16 @@ describe('<LibraryAuthoringPage />', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('Shows an error if libraries V2 is disabled', async () => {
|
||||
const { axiosMock } = initializeMocks();
|
||||
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, {
|
||||
...studioHomeMock,
|
||||
libraries_v2_enabled: false,
|
||||
});
|
||||
|
||||
render(<LibraryLayout />, { path, params: { libraryId: mockContentLibrary.libraryId } });
|
||||
await waitFor(() => { expect(axiosMock.history.get.length).toBe(1); });
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('This page cannot be shown: Libraries v2 are disabled.');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import classNames from 'classnames';
|
||||
import { StudioFooter } from '@edx/frontend-component-footer';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Breadcrumb,
|
||||
Button,
|
||||
@@ -25,6 +26,7 @@ import Loading from '../generic/Loading';
|
||||
import SubHeader from '../generic/sub-header/SubHeader';
|
||||
import Header from '../header';
|
||||
import NotFoundAlert from '../generic/NotFoundAlert';
|
||||
import { useStudioHome } from '../studio-home/hooks';
|
||||
import {
|
||||
ClearFiltersButton,
|
||||
FilterByBlockType,
|
||||
@@ -33,35 +35,11 @@ import {
|
||||
SearchKeywordsField,
|
||||
SearchSortWidget,
|
||||
} from '../search-manager';
|
||||
import LibraryComponents from './components/LibraryComponents';
|
||||
import LibraryCollections from './collections/LibraryCollections';
|
||||
import LibraryHome from './LibraryHome';
|
||||
import LibraryContent, { ContentType } from './LibraryContent';
|
||||
import { LibrarySidebar } from './library-sidebar';
|
||||
import { SidebarBodyComponentId, useLibraryContext } from './common/context';
|
||||
import messages from './messages';
|
||||
|
||||
enum TabList {
|
||||
home = '',
|
||||
components = 'components',
|
||||
collections = 'collections',
|
||||
}
|
||||
|
||||
interface TabContentProps {
|
||||
eventKey: string;
|
||||
handleTabChange: (key: string) => void;
|
||||
}
|
||||
|
||||
const TabContent = ({ eventKey, handleTabChange }: TabContentProps) => {
|
||||
switch (eventKey) {
|
||||
case TabList.components:
|
||||
return <LibraryComponents variant="full" />;
|
||||
case TabList.collections:
|
||||
return <LibraryCollections variant="full" />;
|
||||
default:
|
||||
return <LibraryHome tabList={TabList} handleTabChange={handleTabChange} />;
|
||||
}
|
||||
};
|
||||
|
||||
const HeaderActions = () => {
|
||||
const intl = useIntl();
|
||||
const {
|
||||
@@ -143,6 +121,12 @@ const LibraryAuthoringPage = ({ returnToLibrarySelection }: LibraryAuthoringPage
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const {
|
||||
isLoadingPage: isLoadingStudioHome,
|
||||
isFailedLoadingPage: isFailedLoadingStudioHome,
|
||||
librariesV2Enabled,
|
||||
} = useStudioHome();
|
||||
|
||||
const {
|
||||
libraryId,
|
||||
libraryData,
|
||||
@@ -154,17 +138,17 @@ const LibraryAuthoringPage = ({ returnToLibrarySelection }: LibraryAuthoringPage
|
||||
openInfoSidebar,
|
||||
} = useLibraryContext();
|
||||
|
||||
const [activeKey, setActiveKey] = useState<string | undefined>('');
|
||||
const [activeKey, setActiveKey] = useState<ContentType | undefined>(ContentType.home);
|
||||
|
||||
useEffect(() => {
|
||||
const currentPath = location.pathname.split('/').pop();
|
||||
|
||||
if (componentPickerMode || currentPath === libraryId || currentPath === '') {
|
||||
setActiveKey(TabList.home);
|
||||
} else if (currentPath && currentPath in TabList) {
|
||||
setActiveKey(TabList[currentPath]);
|
||||
setActiveKey(ContentType.home);
|
||||
} else if (currentPath && currentPath in ContentType) {
|
||||
setActiveKey(ContentType[currentPath]);
|
||||
}
|
||||
}, [location.pathname]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!componentPickerMode) {
|
||||
@@ -178,6 +162,14 @@ const LibraryAuthoringPage = ({ returnToLibrarySelection }: LibraryAuthoringPage
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (!isLoadingStudioHome && (!librariesV2Enabled || isFailedLoadingStudioHome)) {
|
||||
return (
|
||||
<Alert variant="danger">
|
||||
{intl.formatMessage(messages.librariesV2DisabledError)}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
// istanbul ignore if: this should never happen
|
||||
if (activeKey === undefined) {
|
||||
return <NotFoundAlert />;
|
||||
@@ -187,7 +179,7 @@ const LibraryAuthoringPage = ({ returnToLibrarySelection }: LibraryAuthoringPage
|
||||
return <NotFoundAlert />;
|
||||
}
|
||||
|
||||
const handleTabChange = (key: string) => {
|
||||
const handleTabChange = (key: ContentType) => {
|
||||
setActiveKey(key);
|
||||
if (!componentPickerMode) {
|
||||
navigate({
|
||||
@@ -219,6 +211,14 @@ const LibraryAuthoringPage = ({ returnToLibrarySelection }: LibraryAuthoringPage
|
||||
extraFilter.push('last_published IS NOT NULL');
|
||||
}
|
||||
|
||||
const activeTypeFilters = {
|
||||
components: 'NOT type = "collection"',
|
||||
collections: 'type = "collection"',
|
||||
};
|
||||
if (activeKey !== ContentType.home) {
|
||||
extraFilter.push(activeTypeFilters[activeKey]);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="d-flex">
|
||||
<div className="flex-grow-1">
|
||||
@@ -259,11 +259,11 @@ const LibraryAuthoringPage = ({ returnToLibrarySelection }: LibraryAuthoringPage
|
||||
onSelect={handleTabChange}
|
||||
className="my-3"
|
||||
>
|
||||
<Tab eventKey={TabList.home} title={intl.formatMessage(messages.homeTab)} />
|
||||
<Tab eventKey={TabList.components} title={intl.formatMessage(messages.componentsTab)} />
|
||||
<Tab eventKey={TabList.collections} title={intl.formatMessage(messages.collectionsTab)} />
|
||||
<Tab eventKey={ContentType.home} title={intl.formatMessage(messages.homeTab)} />
|
||||
<Tab eventKey={ContentType.components} title={intl.formatMessage(messages.componentsTab)} />
|
||||
<Tab eventKey={ContentType.collections} title={intl.formatMessage(messages.collectionsTab)} />
|
||||
</Tabs>
|
||||
<TabContent eventKey={activeKey} handleTabChange={handleTabChange} />
|
||||
<LibraryContent contentType={activeKey} />
|
||||
</SearchContextProvider>
|
||||
</Container>
|
||||
{!componentPickerMode && <StudioFooter containerProps={{ size: undefined }} />}
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import fetchMock from 'fetch-mock-jest';
|
||||
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
initializeMocks,
|
||||
} from '../../testUtils';
|
||||
import { getContentSearchConfigUrl } from '../../search-manager/data/api';
|
||||
import { mockContentLibrary } from '../data/api.mocks';
|
||||
import mockEmptyResult from '../../search-modal/__mocks__/empty-search-result.json';
|
||||
import { LibraryProvider } from '../common/context';
|
||||
import LibraryCollections from './LibraryCollections';
|
||||
} from '../testUtils';
|
||||
import { getContentSearchConfigUrl } from '../search-manager/data/api';
|
||||
import { mockContentLibrary } from './data/api.mocks';
|
||||
import mockEmptyResult from '../search-modal/__mocks__/empty-search-result.json';
|
||||
import { LibraryProvider } from './common/context';
|
||||
import LibraryContent from './LibraryContent';
|
||||
import { libraryComponentsMock } from './__mocks__';
|
||||
|
||||
const searchEndpoint = 'http://mock.meilisearch.local/multi-search';
|
||||
|
||||
@@ -18,8 +20,8 @@ const mockFetchNextPage = jest.fn();
|
||||
const mockUseSearchContext = jest.fn();
|
||||
|
||||
const data = {
|
||||
totalHits: 1,
|
||||
hits: [],
|
||||
totalContentAndCollectionHits: 0,
|
||||
contentAndCollectionHits: [],
|
||||
isFetchingNextPage: false,
|
||||
hasNextPage: false,
|
||||
fetchNextPage: mockFetchNextPage,
|
||||
@@ -40,8 +42,8 @@ const returnEmptyResult = (_url: string, req) => {
|
||||
return mockEmptyResult;
|
||||
};
|
||||
|
||||
jest.mock('../../search-manager', () => ({
|
||||
...jest.requireActual('../../search-manager'),
|
||||
jest.mock('../search-manager', () => ({
|
||||
...jest.requireActual('../search-manager'),
|
||||
useSearchContext: () => mockUseSearchContext(),
|
||||
}));
|
||||
|
||||
@@ -58,7 +60,7 @@ const withLibraryId = (libraryId: string) => ({
|
||||
),
|
||||
});
|
||||
|
||||
describe('<LibraryCollections />', () => {
|
||||
describe('<LibraryHome />', () => {
|
||||
beforeEach(() => {
|
||||
const { axiosMock } = initializeMocks();
|
||||
|
||||
@@ -83,7 +85,31 @@ describe('<LibraryCollections />', () => {
|
||||
isLoading: true,
|
||||
});
|
||||
|
||||
render(<LibraryCollections variant="full" />, withLibraryId(mockContentLibrary.libraryId));
|
||||
render(<LibraryContent />, withLibraryId(mockContentLibrary.libraryId));
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render an empty state when there are no results', async () => {
|
||||
mockUseSearchContext.mockReturnValue({
|
||||
...data,
|
||||
totalHits: 0,
|
||||
});
|
||||
render(<LibraryContent />, withLibraryId(mockContentLibrary.libraryId));
|
||||
expect(screen.getByText('You have not added any content to this library yet.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should load more results when the user scrolls to the bottom', async () => {
|
||||
mockUseSearchContext.mockReturnValue({
|
||||
...data,
|
||||
hits: libraryComponentsMock,
|
||||
hasNextPage: true,
|
||||
});
|
||||
render(<LibraryContent />, withLibraryId(mockContentLibrary.libraryId));
|
||||
|
||||
Object.defineProperty(window, 'innerHeight', { value: 800 });
|
||||
Object.defineProperty(document.body, 'scrollHeight', { value: 1600 });
|
||||
|
||||
fireEvent.scroll(window, { target: { scrollY: 1000 } });
|
||||
expect(mockFetchNextPage).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
92
src/library-authoring/LibraryContent.tsx
Normal file
92
src/library-authoring/LibraryContent.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useEffect } from 'react';
|
||||
import { LoadingSpinner } from '../generic/Loading';
|
||||
import { useSearchContext } from '../search-manager';
|
||||
import { NoComponents, NoSearchResults } from './EmptyStates';
|
||||
import { useLibraryContext } from './common/context';
|
||||
import CollectionCard from './components/CollectionCard';
|
||||
import ComponentCard from './components/ComponentCard';
|
||||
import { useLoadOnScroll } from '../hooks';
|
||||
import messages from './collections/messages';
|
||||
|
||||
export enum ContentType {
|
||||
home = '',
|
||||
components = 'components',
|
||||
collections = 'collections',
|
||||
}
|
||||
|
||||
/**
|
||||
* Library Content to show content grid
|
||||
*
|
||||
* Use content to:
|
||||
* - 'collections': Suggest to create a collection on empty state.
|
||||
* - Anything else to suggest to add content on empty state.
|
||||
*/
|
||||
|
||||
type LibraryContentProps = {
|
||||
contentType?: ContentType;
|
||||
};
|
||||
|
||||
const LibraryContent = ({ contentType = ContentType.home }: LibraryContentProps) => {
|
||||
const {
|
||||
hits,
|
||||
totalHits,
|
||||
isFetchingNextPage,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
isLoading,
|
||||
isFiltered,
|
||||
usageKey,
|
||||
} = useSearchContext();
|
||||
const { openAddContentSidebar, openComponentInfoSidebar, openCreateCollectionModal } = useLibraryContext();
|
||||
|
||||
useEffect(() => {
|
||||
if (usageKey) {
|
||||
openComponentInfoSidebar(usageKey);
|
||||
}
|
||||
}, [usageKey]);
|
||||
|
||||
useLoadOnScroll(
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
true,
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
if (totalHits === 0) {
|
||||
if (contentType === ContentType.collections) {
|
||||
return isFiltered
|
||||
? <NoSearchResults infoText={messages.noSearchResultsCollections} />
|
||||
: (
|
||||
<NoComponents
|
||||
infoText={messages.noCollections}
|
||||
addBtnText={messages.addCollection}
|
||||
handleBtnClick={openCreateCollectionModal}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return isFiltered ? <NoSearchResults /> : <NoComponents handleBtnClick={openAddContentSidebar} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="library-cards-grid">
|
||||
{hits.map((contentHit) => (
|
||||
contentHit.type === 'collection' ? (
|
||||
<CollectionCard
|
||||
key={contentHit.id}
|
||||
collectionHit={contentHit}
|
||||
/>
|
||||
) : (
|
||||
<ComponentCard
|
||||
key={contentHit.id}
|
||||
contentHit={contentHit}
|
||||
/>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LibraryContent;
|
||||
@@ -1,67 +0,0 @@
|
||||
import { Stack } from '@openedx/paragon';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { LoadingSpinner } from '../generic/Loading';
|
||||
import { useSearchContext } from '../search-manager';
|
||||
import { NoComponents, NoSearchResults } from './EmptyStates';
|
||||
import LibraryCollections from './collections/LibraryCollections';
|
||||
import { LibraryComponents } from './components';
|
||||
import LibrarySection from './components/LibrarySection';
|
||||
import LibraryRecentlyModified from './LibraryRecentlyModified';
|
||||
import messages from './messages';
|
||||
import { useLibraryContext } from './common/context';
|
||||
|
||||
type LibraryHomeProps = {
|
||||
tabList: { home: string, components: string, collections: string },
|
||||
handleTabChange: (key: string) => void,
|
||||
};
|
||||
|
||||
const LibraryHome = ({ tabList, handleTabChange } : LibraryHomeProps) => {
|
||||
const intl = useIntl();
|
||||
const {
|
||||
totalHits: componentCount,
|
||||
totalCollectionHits: collectionCount,
|
||||
isLoading,
|
||||
isFiltered,
|
||||
} = useSearchContext();
|
||||
const { openAddContentSidebar } = useLibraryContext();
|
||||
|
||||
const renderEmptyState = () => {
|
||||
if (isLoading) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
if (componentCount === 0 && collectionCount === 0) {
|
||||
return isFiltered ? <NoSearchResults /> : <NoComponents handleBtnClick={openAddContentSidebar} />;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap={3}>
|
||||
<LibraryRecentlyModified />
|
||||
{
|
||||
renderEmptyState()
|
||||
|| (
|
||||
<>
|
||||
<LibrarySection
|
||||
title={intl.formatMessage(messages.collectionsTitle, { collectionCount })}
|
||||
contentCount={collectionCount}
|
||||
viewAllAction={() => handleTabChange(tabList.collections)}
|
||||
>
|
||||
<LibraryCollections variant="preview" />
|
||||
</LibrarySection>
|
||||
<LibrarySection
|
||||
title={intl.formatMessage(messages.componentsTitle, { componentCount })}
|
||||
contentCount={componentCount}
|
||||
viewAllAction={() => handleTabChange(tabList.components)}
|
||||
>
|
||||
<LibraryComponents variant="preview" />
|
||||
</LibrarySection>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default LibraryHome;
|
||||
@@ -1,80 +0,0 @@
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { orderBy } from 'lodash';
|
||||
|
||||
import { SearchContextProvider, useSearchContext } from '../search-manager';
|
||||
import { type CollectionHit, type ContentHit, SearchSortOption } from '../search-manager/data/api';
|
||||
import LibrarySection, { LIBRARY_SECTION_PREVIEW_LIMIT } from './components/LibrarySection';
|
||||
import messages from './messages';
|
||||
import ComponentCard from './components/ComponentCard';
|
||||
import CollectionCard from './components/CollectionCard';
|
||||
import { useLibraryContext } from './common/context';
|
||||
|
||||
const RecentlyModified: React.FC<Record<never, never>> = () => {
|
||||
const intl = useIntl();
|
||||
const {
|
||||
hits,
|
||||
collectionHits,
|
||||
totalHits,
|
||||
totalCollectionHits,
|
||||
} = useSearchContext();
|
||||
|
||||
const componentCount = totalHits + totalCollectionHits;
|
||||
// Since we only display a fixed number of items in preview,
|
||||
// only these number of items are use in sort step below
|
||||
const componentList = hits.slice(0, LIBRARY_SECTION_PREVIEW_LIMIT);
|
||||
const collectionList = collectionHits.slice(0, LIBRARY_SECTION_PREVIEW_LIMIT);
|
||||
// Sort them by `modified` field in reverse and display them
|
||||
const recentItems = orderBy([
|
||||
...componentList,
|
||||
...collectionList,
|
||||
], ['modified'], ['desc']).slice(0, LIBRARY_SECTION_PREVIEW_LIMIT);
|
||||
|
||||
return componentCount > 0
|
||||
? (
|
||||
<LibrarySection
|
||||
title={intl.formatMessage(messages.recentlyModifiedTitle)}
|
||||
contentCount={componentCount}
|
||||
>
|
||||
<div className="library-cards-grid">
|
||||
{recentItems.map((contentHit) => (
|
||||
contentHit.type === 'collection' ? (
|
||||
<CollectionCard
|
||||
key={contentHit.id}
|
||||
collectionHit={contentHit as CollectionHit}
|
||||
/>
|
||||
) : (
|
||||
<ComponentCard
|
||||
key={contentHit.id}
|
||||
contentHit={contentHit as ContentHit}
|
||||
/>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
</LibrarySection>
|
||||
)
|
||||
: null;
|
||||
};
|
||||
|
||||
const LibraryRecentlyModified: React.FC<Record<never, never>> = () => {
|
||||
const { libraryId, showOnlyPublished } = useLibraryContext();
|
||||
|
||||
const extraFilter = [`context_key = "${libraryId}"`];
|
||||
if (showOnlyPublished) {
|
||||
extraFilter.push('last_published IS NOT NULL');
|
||||
}
|
||||
|
||||
return (
|
||||
<SearchContextProvider
|
||||
extraFilter={extraFilter}
|
||||
overrideSearchSortOrder={
|
||||
showOnlyPublished
|
||||
? SearchSortOption.RECENTLY_PUBLISHED
|
||||
: SearchSortOption.RECENTLY_MODIFIED
|
||||
}
|
||||
>
|
||||
<RecentlyModified />
|
||||
</SearchContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default LibraryRecentlyModified;
|
||||
@@ -180,34 +180,7 @@
|
||||
"display_name": "Blank Problem",
|
||||
"description": "Problem"
|
||||
}
|
||||
}
|
||||
],
|
||||
"query": "",
|
||||
"processingTimeMs": 1,
|
||||
"limit": 20,
|
||||
"offset": 0,
|
||||
"estimatedTotalHits": 5
|
||||
},
|
||||
{
|
||||
"indexUid": "studio_content",
|
||||
"hits": [],
|
||||
"query": "",
|
||||
"processingTimeMs": 0,
|
||||
"limit": 0,
|
||||
"offset": 0,
|
||||
"estimatedTotalHits": 5,
|
||||
"facetDistribution": {
|
||||
"block_type": {
|
||||
"html": 4,
|
||||
"problem": 1
|
||||
},
|
||||
"content.problem_types": {}
|
||||
},
|
||||
"facetStats": {}
|
||||
},
|
||||
{
|
||||
"indexUid": "studio_content",
|
||||
"hits": [
|
||||
{
|
||||
"display_name": "My first collection",
|
||||
"block_id": "my-first-collection",
|
||||
@@ -246,12 +219,30 @@
|
||||
"access_id": 16,
|
||||
"num_children": 1
|
||||
}
|
||||
|
||||
],
|
||||
"query": "",
|
||||
"processingTimeMs": 0,
|
||||
"limit": 1,
|
||||
"processingTimeMs": 1,
|
||||
"limit": 20,
|
||||
"offset": 0,
|
||||
"estimatedTotalHits": 1
|
||||
"estimatedTotalHits": 5
|
||||
},
|
||||
{
|
||||
"indexUid": "studio_content",
|
||||
"hits": [],
|
||||
"query": "",
|
||||
"processingTimeMs": 0,
|
||||
"limit": 0,
|
||||
"offset": 0,
|
||||
"estimatedTotalHits": 5,
|
||||
"facetDistribution": {
|
||||
"block_type": {
|
||||
"html": 4,
|
||||
"problem": 1
|
||||
},
|
||||
"content.problem_types": {}
|
||||
},
|
||||
"facetStats": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -32,6 +32,199 @@
|
||||
"description": "Testing"
|
||||
}
|
||||
},
|
||||
{
|
||||
"display_name": "Collection 1",
|
||||
"block_id": "col1",
|
||||
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet vitae at odio. Vivamus tempor nec lorem eget lacinia. Vivamus efficitur lacus non dapibus porta. Nulla venenatis luctus nisi id posuere. Sed sollicitudin magna a sem ultrices accumsan. Praesent volutpat tortor vitae luctus rutrum. Integer.",
|
||||
"id": 1,
|
||||
"type": "collection",
|
||||
"breadcrumbs": [
|
||||
{
|
||||
"display_name": "CS problems 2"
|
||||
}
|
||||
],
|
||||
"created": 1725534795.628254,
|
||||
"modified": 1725878053.420395,
|
||||
"context_key": "lib:OpenedX:CSPROB2",
|
||||
"org": "OpenedX",
|
||||
"access_id": 16,
|
||||
"_formatted": {
|
||||
"display_name": "Collection 1",
|
||||
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet…",
|
||||
"id": "1",
|
||||
"type": "collection",
|
||||
"breadcrumbs": [
|
||||
{
|
||||
"display_name": "CS problems 2"
|
||||
}
|
||||
],
|
||||
"created": "1725534795.628254",
|
||||
"modified": "1725534795.628266",
|
||||
"context_key": "lib:OpenedX:CSPROB2",
|
||||
"org": "OpenedX",
|
||||
"access_id": "16"
|
||||
}
|
||||
},
|
||||
{
|
||||
"display_name": "Collection 2",
|
||||
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet vitae at odio. Vivamus tempor nec lorem eget lacinia. Vivamus efficitur lacus non dapibus porta. Nulla venenatis luctus nisi id posuere. Sed sollicitudin magna a sem ultrices accumsan. Praesent volutpat tortor vitae luctus rutrum. Integer. Descrition 58",
|
||||
"id": 2,
|
||||
"type": "collection",
|
||||
"breadcrumbs": [
|
||||
{
|
||||
"display_name": "CS problems 2"
|
||||
}
|
||||
],
|
||||
"created": 1725534795.619101,
|
||||
"modified": 1725534795.619113,
|
||||
"context_key": "lib:OpenedX:CSPROB2",
|
||||
"org": "OpenedX",
|
||||
"access_id": 16,
|
||||
"_formatted": {
|
||||
"display_name": "Collection 2",
|
||||
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet…",
|
||||
"id": "2",
|
||||
"type": "collection",
|
||||
"breadcrumbs": [
|
||||
{
|
||||
"display_name": "CS problems 2"
|
||||
}
|
||||
],
|
||||
"created": "1725534795.619101",
|
||||
"modified": "1725534795.619113",
|
||||
"context_key": "lib:OpenedX:CSPROB2",
|
||||
"org": "OpenedX",
|
||||
"access_id": "16"
|
||||
}
|
||||
},
|
||||
{
|
||||
"display_name": "Collection 3",
|
||||
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet vitae at odio. Vivamus tempor nec lorem eget lacinia. Vivamus efficitur lacus non dapibus porta. Nulla venenatis luctus nisi id posuere. Sed sollicitudin magna a sem ultrices accumsan. Praesent volutpat tortor vitae luctus rutrum. Integer. Descrition 57",
|
||||
"id": 3,
|
||||
"type": "collection",
|
||||
"breadcrumbs": [
|
||||
{
|
||||
"display_name": "CS problems 2"
|
||||
}
|
||||
],
|
||||
"created": 1725534795.609781,
|
||||
"modified": 1725534795.609794,
|
||||
"context_key": "lib:OpenedX:CSPROB2",
|
||||
"org": "OpenedX",
|
||||
"access_id": 16,
|
||||
"_formatted": {
|
||||
"display_name": "Collection 3",
|
||||
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet…",
|
||||
"id": "3",
|
||||
"type": "collection",
|
||||
"breadcrumbs": [
|
||||
{
|
||||
"display_name": "CS problems 2"
|
||||
}
|
||||
],
|
||||
"created": "1725534795.609781",
|
||||
"modified": "1725534795.609794",
|
||||
"context_key": "lib:OpenedX:CSPROB2",
|
||||
"org": "OpenedX",
|
||||
"access_id": "16"
|
||||
}
|
||||
},
|
||||
{
|
||||
"display_name": "Collection 4",
|
||||
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet vitae at odio. Vivamus tempor nec lorem eget lacinia. Vivamus efficitur lacus non dapibus porta. Nulla venenatis luctus nisi id posuere. Sed sollicitudin magna a sem ultrices accumsan. Praesent volutpat tortor vitae luctus rutrum. Integer. Descrition 56",
|
||||
"id": 4,
|
||||
"type": "collection",
|
||||
"breadcrumbs": [
|
||||
{
|
||||
"display_name": "CS problems 2"
|
||||
}
|
||||
],
|
||||
"created": 1725534795.596287,
|
||||
"modified": 1725534795.5963,
|
||||
"context_key": "lib:OpenedX:CSPROB2",
|
||||
"org": "OpenedX",
|
||||
"access_id": 16,
|
||||
"_formatted": {
|
||||
"display_name": "Collection 4",
|
||||
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet…",
|
||||
"id": "4",
|
||||
"type": "collection",
|
||||
"breadcrumbs": [
|
||||
{
|
||||
"display_name": "CS problems 2"
|
||||
}
|
||||
],
|
||||
"created": "1725534795.596287",
|
||||
"modified": "1725534795.5963",
|
||||
"context_key": "lib:OpenedX:CSPROB2",
|
||||
"org": "OpenedX",
|
||||
"access_id": "16"
|
||||
}
|
||||
},
|
||||
{
|
||||
"display_name": "Collection 5",
|
||||
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet vitae at odio. Vivamus tempor nec lorem eget lacinia. Vivamus efficitur lacus non dapibus porta. Nulla venenatis luctus nisi id posuere. Sed sollicitudin magna a sem ultrices accumsan. Praesent volutpat tortor vitae luctus rutrum. Integer. Descrition 55",
|
||||
"id": 5,
|
||||
"type": "collection",
|
||||
"breadcrumbs": [
|
||||
{
|
||||
"display_name": "CS problems 2"
|
||||
}
|
||||
],
|
||||
"created": 1725534795.583068,
|
||||
"modified": 1725534795.583082,
|
||||
"context_key": "lib:OpenedX:CSPROB2",
|
||||
"org": "OpenedX",
|
||||
"access_id": 16,
|
||||
"_formatted": {
|
||||
"display_name": "Collection 5",
|
||||
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet…",
|
||||
"id": "5",
|
||||
"type": "collection",
|
||||
"breadcrumbs": [
|
||||
{
|
||||
"display_name": "CS problems 2"
|
||||
}
|
||||
],
|
||||
"created": "1725534795.583068",
|
||||
"modified": "1725534795.583082",
|
||||
"context_key": "lib:OpenedX:CSPROB2",
|
||||
"org": "OpenedX",
|
||||
"access_id": "16"
|
||||
}
|
||||
},
|
||||
{
|
||||
"display_name": "Collection 6",
|
||||
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet vitae at odio. Vivamus tempor nec lorem eget lacinia. Vivamus efficitur lacus non dapibus porta. Nulla venenatis luctus nisi id posuere. Sed sollicitudin magna a sem ultrices accumsan. Praesent volutpat tortor vitae luctus rutrum. Integer. Descrition 54",
|
||||
"id": 6,
|
||||
"type": "collection",
|
||||
"breadcrumbs": [
|
||||
{
|
||||
"display_name": "CS problems 2"
|
||||
}
|
||||
],
|
||||
"created": 1725534795.573794,
|
||||
"modified": 1725534795.573808,
|
||||
"context_key": "lib:OpenedX:CSPROB2",
|
||||
"org": "OpenedX",
|
||||
"access_id": 16,
|
||||
"_formatted": {
|
||||
"display_name": "Collection 6",
|
||||
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet…",
|
||||
"id": "6",
|
||||
"type": "collection",
|
||||
"breadcrumbs": [
|
||||
{
|
||||
"display_name": "CS problems 2"
|
||||
}
|
||||
],
|
||||
"created": "1725534795.573794",
|
||||
"modified": "1725534795.573808",
|
||||
"context_key": "lib:OpenedX:CSPROB2",
|
||||
"org": "OpenedX",
|
||||
"access_id": "16"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lbaximtesthtml73a22298-bcd9-4f4c-ae34-0bc2b0612480-46b4a7f2",
|
||||
"display_name": "Second Text Component",
|
||||
@@ -318,209 +511,6 @@
|
||||
}
|
||||
},
|
||||
"facetStats": {}
|
||||
},
|
||||
{
|
||||
"indexUid": "studio",
|
||||
"hits": [
|
||||
{
|
||||
"display_name": "Collection 1",
|
||||
"block_id": "col1",
|
||||
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet vitae at odio. Vivamus tempor nec lorem eget lacinia. Vivamus efficitur lacus non dapibus porta. Nulla venenatis luctus nisi id posuere. Sed sollicitudin magna a sem ultrices accumsan. Praesent volutpat tortor vitae luctus rutrum. Integer.",
|
||||
"id": 1,
|
||||
"type": "collection",
|
||||
"breadcrumbs": [
|
||||
{
|
||||
"display_name": "CS problems 2"
|
||||
}
|
||||
],
|
||||
"created": 1725534795.628254,
|
||||
"modified": 1725878053.420395,
|
||||
"context_key": "lib:OpenedX:CSPROB2",
|
||||
"org": "OpenedX",
|
||||
"access_id": 16,
|
||||
"_formatted": {
|
||||
"display_name": "Collection 1",
|
||||
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet…",
|
||||
"id": "1",
|
||||
"type": "collection",
|
||||
"breadcrumbs": [
|
||||
{
|
||||
"display_name": "CS problems 2"
|
||||
}
|
||||
],
|
||||
"created": "1725534795.628254",
|
||||
"modified": "1725534795.628266",
|
||||
"context_key": "lib:OpenedX:CSPROB2",
|
||||
"org": "OpenedX",
|
||||
"access_id": "16"
|
||||
}
|
||||
},
|
||||
{
|
||||
"display_name": "Collection 2",
|
||||
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet vitae at odio. Vivamus tempor nec lorem eget lacinia. Vivamus efficitur lacus non dapibus porta. Nulla venenatis luctus nisi id posuere. Sed sollicitudin magna a sem ultrices accumsan. Praesent volutpat tortor vitae luctus rutrum. Integer. Descrition 58",
|
||||
"id": 2,
|
||||
"type": "collection",
|
||||
"breadcrumbs": [
|
||||
{
|
||||
"display_name": "CS problems 2"
|
||||
}
|
||||
],
|
||||
"created": 1725534795.619101,
|
||||
"modified": 1725534795.619113,
|
||||
"context_key": "lib:OpenedX:CSPROB2",
|
||||
"org": "OpenedX",
|
||||
"access_id": 16,
|
||||
"_formatted": {
|
||||
"display_name": "Collection 2",
|
||||
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet…",
|
||||
"id": "2",
|
||||
"type": "collection",
|
||||
"breadcrumbs": [
|
||||
{
|
||||
"display_name": "CS problems 2"
|
||||
}
|
||||
],
|
||||
"created": "1725534795.619101",
|
||||
"modified": "1725534795.619113",
|
||||
"context_key": "lib:OpenedX:CSPROB2",
|
||||
"org": "OpenedX",
|
||||
"access_id": "16"
|
||||
}
|
||||
},
|
||||
{
|
||||
"display_name": "Collection 3",
|
||||
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet vitae at odio. Vivamus tempor nec lorem eget lacinia. Vivamus efficitur lacus non dapibus porta. Nulla venenatis luctus nisi id posuere. Sed sollicitudin magna a sem ultrices accumsan. Praesent volutpat tortor vitae luctus rutrum. Integer. Descrition 57",
|
||||
"id": 3,
|
||||
"type": "collection",
|
||||
"breadcrumbs": [
|
||||
{
|
||||
"display_name": "CS problems 2"
|
||||
}
|
||||
],
|
||||
"created": 1725534795.609781,
|
||||
"modified": 1725534795.609794,
|
||||
"context_key": "lib:OpenedX:CSPROB2",
|
||||
"org": "OpenedX",
|
||||
"access_id": 16,
|
||||
"_formatted": {
|
||||
"display_name": "Collection 3",
|
||||
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet…",
|
||||
"id": "3",
|
||||
"type": "collection",
|
||||
"breadcrumbs": [
|
||||
{
|
||||
"display_name": "CS problems 2"
|
||||
}
|
||||
],
|
||||
"created": "1725534795.609781",
|
||||
"modified": "1725534795.609794",
|
||||
"context_key": "lib:OpenedX:CSPROB2",
|
||||
"org": "OpenedX",
|
||||
"access_id": "16"
|
||||
}
|
||||
},
|
||||
{
|
||||
"display_name": "Collection 4",
|
||||
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet vitae at odio. Vivamus tempor nec lorem eget lacinia. Vivamus efficitur lacus non dapibus porta. Nulla venenatis luctus nisi id posuere. Sed sollicitudin magna a sem ultrices accumsan. Praesent volutpat tortor vitae luctus rutrum. Integer. Descrition 56",
|
||||
"id": 4,
|
||||
"type": "collection",
|
||||
"breadcrumbs": [
|
||||
{
|
||||
"display_name": "CS problems 2"
|
||||
}
|
||||
],
|
||||
"created": 1725534795.596287,
|
||||
"modified": 1725534795.5963,
|
||||
"context_key": "lib:OpenedX:CSPROB2",
|
||||
"org": "OpenedX",
|
||||
"access_id": 16,
|
||||
"_formatted": {
|
||||
"display_name": "Collection 4",
|
||||
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet…",
|
||||
"id": "4",
|
||||
"type": "collection",
|
||||
"breadcrumbs": [
|
||||
{
|
||||
"display_name": "CS problems 2"
|
||||
}
|
||||
],
|
||||
"created": "1725534795.596287",
|
||||
"modified": "1725534795.5963",
|
||||
"context_key": "lib:OpenedX:CSPROB2",
|
||||
"org": "OpenedX",
|
||||
"access_id": "16"
|
||||
}
|
||||
},
|
||||
{
|
||||
"display_name": "Collection 5",
|
||||
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet vitae at odio. Vivamus tempor nec lorem eget lacinia. Vivamus efficitur lacus non dapibus porta. Nulla venenatis luctus nisi id posuere. Sed sollicitudin magna a sem ultrices accumsan. Praesent volutpat tortor vitae luctus rutrum. Integer. Descrition 55",
|
||||
"id": 5,
|
||||
"type": "collection",
|
||||
"breadcrumbs": [
|
||||
{
|
||||
"display_name": "CS problems 2"
|
||||
}
|
||||
],
|
||||
"created": 1725534795.583068,
|
||||
"modified": 1725534795.583082,
|
||||
"context_key": "lib:OpenedX:CSPROB2",
|
||||
"org": "OpenedX",
|
||||
"access_id": 16,
|
||||
"_formatted": {
|
||||
"display_name": "Collection 5",
|
||||
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet…",
|
||||
"id": "5",
|
||||
"type": "collection",
|
||||
"breadcrumbs": [
|
||||
{
|
||||
"display_name": "CS problems 2"
|
||||
}
|
||||
],
|
||||
"created": "1725534795.583068",
|
||||
"modified": "1725534795.583082",
|
||||
"context_key": "lib:OpenedX:CSPROB2",
|
||||
"org": "OpenedX",
|
||||
"access_id": "16"
|
||||
}
|
||||
},
|
||||
{
|
||||
"display_name": "Collection 6",
|
||||
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet vitae at odio. Vivamus tempor nec lorem eget lacinia. Vivamus efficitur lacus non dapibus porta. Nulla venenatis luctus nisi id posuere. Sed sollicitudin magna a sem ultrices accumsan. Praesent volutpat tortor vitae luctus rutrum. Integer. Descrition 54",
|
||||
"id": 6,
|
||||
"type": "collection",
|
||||
"breadcrumbs": [
|
||||
{
|
||||
"display_name": "CS problems 2"
|
||||
}
|
||||
],
|
||||
"created": 1725534795.573794,
|
||||
"modified": 1725534795.573808,
|
||||
"context_key": "lib:OpenedX:CSPROB2",
|
||||
"org": "OpenedX",
|
||||
"access_id": 16,
|
||||
"_formatted": {
|
||||
"display_name": "Collection 6",
|
||||
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet…",
|
||||
"id": "6",
|
||||
"type": "collection",
|
||||
"breadcrumbs": [
|
||||
{
|
||||
"display_name": "CS problems 2"
|
||||
}
|
||||
],
|
||||
"created": "1725534795.573794",
|
||||
"modified": "1725534795.573808",
|
||||
"context_key": "lib:OpenedX:CSPROB2",
|
||||
"org": "OpenedX",
|
||||
"access_id": "16"
|
||||
}
|
||||
}
|
||||
],
|
||||
"query": "learn",
|
||||
"processingTimeMs": 1,
|
||||
"limit": 6,
|
||||
"offset": 0,
|
||||
"estimatedTotalHits": 6
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import MockAdapter from 'axios-mock-adapter/types';
|
||||
import { snakeCaseObject } from '@edx/frontend-platform';
|
||||
import {
|
||||
fireEvent,
|
||||
render as baseRender,
|
||||
@@ -6,23 +8,55 @@ import {
|
||||
initializeMocks,
|
||||
} from '../../testUtils';
|
||||
import { mockContentLibrary } from '../data/api.mocks';
|
||||
import { getCreateLibraryBlockUrl, getLibraryPasteClipboardUrl } from '../data/api';
|
||||
import {
|
||||
getContentLibraryApiUrl, getCreateLibraryBlockUrl, getLibraryCollectionComponentApiUrl, getLibraryPasteClipboardUrl,
|
||||
} from '../data/api';
|
||||
import { mockBroadcastChannel, mockClipboardEmpty, mockClipboardHtml } from '../../generic/data/api.mock';
|
||||
import { LibraryProvider } from '../common/context';
|
||||
import AddContentContainer from './AddContentContainer';
|
||||
import { ComponentEditorModal } from '../components/ComponentEditorModal';
|
||||
import editorCmsApi from '../../editors/data/services/cms/api';
|
||||
import { ToastActionData } from '../../generic/toast-context';
|
||||
|
||||
mockBroadcastChannel();
|
||||
|
||||
// Mocks for ComponentEditorModal to work in tests.
|
||||
jest.mock('frontend-components-tinymce-advanced-plugins', () => ({ a11ycheckerCss: '' }));
|
||||
|
||||
const { libraryId } = mockContentLibrary;
|
||||
const render = () => baseRender(<AddContentContainer />, {
|
||||
path: '/library/:libraryId/*',
|
||||
params: { libraryId },
|
||||
extraWrapper: ({ children }) => <LibraryProvider libraryId={libraryId}>{ children }</LibraryProvider>,
|
||||
});
|
||||
const render = (collectionId?: string) => {
|
||||
const params: { libraryId: string, collectionId?: string } = { libraryId };
|
||||
if (collectionId) {
|
||||
params.collectionId = collectionId;
|
||||
}
|
||||
return baseRender(<AddContentContainer />, {
|
||||
path: '/library/:libraryId/*',
|
||||
params,
|
||||
extraWrapper: ({ children }) => (
|
||||
<LibraryProvider
|
||||
libraryId={libraryId}
|
||||
collectionId={collectionId}
|
||||
>
|
||||
{ children }
|
||||
<ComponentEditorModal />
|
||||
</LibraryProvider>
|
||||
),
|
||||
});
|
||||
};
|
||||
let axiosMock: MockAdapter;
|
||||
let mockShowToast: (message: string, action?: ToastActionData | undefined) => void;
|
||||
|
||||
describe('<AddContentContainer />', () => {
|
||||
beforeEach(() => {
|
||||
const mocks = initializeMocks();
|
||||
axiosMock = mocks.axiosMock;
|
||||
mockShowToast = mocks.mockShowToast;
|
||||
axiosMock.onGet(getContentLibraryApiUrl(libraryId)).reply(200, {});
|
||||
});
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
it('should render content buttons', () => {
|
||||
initializeMocks();
|
||||
mockClipboardEmpty.applyMock();
|
||||
render();
|
||||
expect(screen.queryByRole('button', { name: /collection/i })).toBeInTheDocument();
|
||||
@@ -36,7 +70,6 @@ describe('<AddContentContainer />', () => {
|
||||
});
|
||||
|
||||
it('should create a content', async () => {
|
||||
const { axiosMock } = initializeMocks();
|
||||
mockClipboardEmpty.applyMock();
|
||||
const url = getCreateLibraryBlockUrl(libraryId);
|
||||
axiosMock.onPost(url).reply(200);
|
||||
@@ -47,10 +80,82 @@ describe('<AddContentContainer />', () => {
|
||||
fireEvent.click(textButton);
|
||||
|
||||
await waitFor(() => expect(axiosMock.history.post[0].url).toEqual(url));
|
||||
await waitFor(() => expect(axiosMock.history.patch.length).toEqual(0));
|
||||
});
|
||||
|
||||
it('should create a content in a collection for non-editable blocks', async () => {
|
||||
mockClipboardEmpty.applyMock();
|
||||
const collectionId = 'some-collection-id';
|
||||
const url = getCreateLibraryBlockUrl(libraryId);
|
||||
const collectionComponentUrl = getLibraryCollectionComponentApiUrl(
|
||||
libraryId,
|
||||
collectionId,
|
||||
);
|
||||
// having id of block which is not video, html or problem will not trigger editor.
|
||||
axiosMock.onPost(url).reply(200, { id: 'some-component-id' });
|
||||
axiosMock.onPatch(collectionComponentUrl).reply(200);
|
||||
|
||||
render(collectionId);
|
||||
|
||||
const textButton = screen.getByRole('button', { name: /text/i });
|
||||
fireEvent.click(textButton);
|
||||
|
||||
await waitFor(() => expect(axiosMock.history.post[0].url).toEqual(url));
|
||||
await waitFor(() => expect(axiosMock.history.patch.length).toEqual(1));
|
||||
await waitFor(() => expect(axiosMock.history.patch[0].url).toEqual(collectionComponentUrl));
|
||||
});
|
||||
|
||||
it('should create a content in a collection for editable blocks', async () => {
|
||||
mockClipboardEmpty.applyMock();
|
||||
const collectionId = 'some-collection-id';
|
||||
const url = getCreateLibraryBlockUrl(libraryId);
|
||||
const collectionComponentUrl = getLibraryCollectionComponentApiUrl(
|
||||
libraryId,
|
||||
collectionId,
|
||||
);
|
||||
// Mocks for ComponentEditorModal to work in tests.
|
||||
jest.spyOn(editorCmsApi, 'fetchImages').mockImplementation(async () => ( // eslint-disable-next-line
|
||||
{ data: { assets: [], start: 0, end: 0, page: 0, pageSize: 50, totalCount: 0 } }
|
||||
));
|
||||
jest.spyOn(editorCmsApi, 'fetchByUnitId').mockImplementation(async () => ({
|
||||
status: 200,
|
||||
data: {
|
||||
ancestors: [{
|
||||
id: 'block-v1:Org+TS100+24+type@vertical+block@parent',
|
||||
display_name: 'You-Knit? The Test Unit',
|
||||
category: 'vertical',
|
||||
has_children: true,
|
||||
}],
|
||||
},
|
||||
}));
|
||||
|
||||
axiosMock.onPost(url).reply(200, {
|
||||
id: 'lb:OpenedX:CSPROB2:html:1a5efd56-4ee5-4df0-b466-44f08fbbf567',
|
||||
});
|
||||
const fieldsHtml = {
|
||||
displayName: 'Introduction to Testing',
|
||||
data: '<p>This is a text component which uses <strong>HTML</strong>.</p>',
|
||||
metadata: { displayName: 'Introduction to Testing' },
|
||||
};
|
||||
jest.spyOn(editorCmsApi, 'fetchBlockById').mockImplementationOnce(async () => (
|
||||
{ status: 200, data: snakeCaseObject(fieldsHtml) }
|
||||
));
|
||||
axiosMock.onPatch(collectionComponentUrl).reply(200);
|
||||
|
||||
render(collectionId);
|
||||
|
||||
const textButton = screen.getByRole('button', { name: /text/i });
|
||||
fireEvent.click(textButton);
|
||||
|
||||
// Component should be linked to Collection on closing editor.
|
||||
const closeButton = await screen.findByRole('button', { name: 'Exit the editor' });
|
||||
fireEvent.click(closeButton);
|
||||
await waitFor(() => expect(axiosMock.history.post[0].url).toEqual(url));
|
||||
await waitFor(() => expect(axiosMock.history.patch.length).toEqual(1));
|
||||
await waitFor(() => expect(axiosMock.history.patch[0].url).toEqual(collectionComponentUrl));
|
||||
});
|
||||
|
||||
it('should render paste button if clipboard contains pastable xblock', async () => {
|
||||
initializeMocks();
|
||||
// Simulate having an HTML block in the clipboard:
|
||||
const getClipboardSpy = mockClipboardHtml.applyMock();
|
||||
render();
|
||||
@@ -59,7 +164,6 @@ describe('<AddContentContainer />', () => {
|
||||
});
|
||||
|
||||
it('should paste content', async () => {
|
||||
const { axiosMock } = initializeMocks();
|
||||
// Simulate having an HTML block in the clipboard:
|
||||
const getClipboardSpy = mockClipboardHtml.applyMock();
|
||||
|
||||
@@ -76,54 +180,58 @@ describe('<AddContentContainer />', () => {
|
||||
await waitFor(() => expect(axiosMock.history.post[0].url).toEqual(pasteUrl));
|
||||
});
|
||||
|
||||
it('should handle failure to paste content', async () => {
|
||||
const { axiosMock, mockShowToast } = initializeMocks();
|
||||
it('should paste content inside a collection', async () => {
|
||||
// Simulate having an HTML block in the clipboard:
|
||||
mockClipboardHtml.applyMock();
|
||||
const getClipboardSpy = mockClipboardHtml.applyMock();
|
||||
|
||||
const pasteUrl = getLibraryPasteClipboardUrl(libraryId);
|
||||
axiosMock.onPost(pasteUrl).reply(400);
|
||||
const collectionId = 'some-collection-id';
|
||||
const collectionComponentUrl = getLibraryCollectionComponentApiUrl(
|
||||
libraryId,
|
||||
collectionId,
|
||||
);
|
||||
axiosMock.onPatch(collectionComponentUrl).reply(200);
|
||||
axiosMock.onPost(pasteUrl).reply(200, { id: 'some-component-id' });
|
||||
|
||||
render();
|
||||
render(collectionId);
|
||||
|
||||
expect(getClipboardSpy).toHaveBeenCalled(); // Hmm, this is getting called four times! Refactor to use react-query.
|
||||
|
||||
const pasteButton = await screen.findByRole('button', { name: /paste from clipboard/i });
|
||||
fireEvent.click(pasteButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(axiosMock.history.post[0].url).toEqual(pasteUrl);
|
||||
expect(mockShowToast).toHaveBeenCalledWith('There was an error pasting the content.');
|
||||
});
|
||||
await waitFor(() => expect(axiosMock.history.post[0].url).toEqual(pasteUrl));
|
||||
await waitFor(() => expect(axiosMock.history.patch.length).toEqual(1));
|
||||
await waitFor(() => expect(axiosMock.history.patch[0].url).toEqual(collectionComponentUrl));
|
||||
});
|
||||
|
||||
it('should handle failure to paste content and show server error if available', async () => {
|
||||
const { axiosMock, mockShowToast } = initializeMocks();
|
||||
it('should show error toast on linking failure', async () => {
|
||||
// Simulate having an HTML block in the clipboard:
|
||||
mockClipboardHtml.applyMock();
|
||||
const getClipboardSpy = mockClipboardHtml.applyMock();
|
||||
|
||||
const errMsg = 'Libraries do not support this type of content yet.';
|
||||
const pasteUrl = getLibraryPasteClipboardUrl(libraryId);
|
||||
const collectionId = 'some-collection-id';
|
||||
const collectionComponentUrl = getLibraryCollectionComponentApiUrl(
|
||||
libraryId,
|
||||
collectionId,
|
||||
);
|
||||
axiosMock.onPatch(collectionComponentUrl).reply(500);
|
||||
axiosMock.onPost(pasteUrl).reply(200, { id: 'some-component-id' });
|
||||
|
||||
// eslint-disable-next-line prefer-promise-reject-errors
|
||||
axiosMock.onPost(pasteUrl).reply(() => Promise.reject({
|
||||
customAttributes: {
|
||||
httpErrorStatus: 400,
|
||||
httpErrorResponseData: JSON.stringify({ block_type: errMsg }),
|
||||
},
|
||||
}));
|
||||
render(collectionId);
|
||||
|
||||
render();
|
||||
expect(getClipboardSpy).toHaveBeenCalled(); // Hmm, this is getting called four times! Refactor to use react-query.
|
||||
|
||||
const pasteButton = await screen.findByRole('button', { name: /paste from clipboard/i });
|
||||
fireEvent.click(pasteButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(axiosMock.history.post[0].url).toEqual(pasteUrl);
|
||||
expect(mockShowToast).toHaveBeenCalledWith(errMsg);
|
||||
});
|
||||
await waitFor(() => expect(axiosMock.history.post[0].url).toEqual(pasteUrl));
|
||||
await waitFor(() => expect(axiosMock.history.patch.length).toEqual(1));
|
||||
await waitFor(() => expect(axiosMock.history.patch[0].url).toEqual(collectionComponentUrl));
|
||||
expect(mockShowToast).toHaveBeenCalledWith('There was an error linking the content to this collection.');
|
||||
});
|
||||
|
||||
it('should stop user from pasting unsupported blocks and show toast', async () => {
|
||||
const { axiosMock, mockShowToast } = initializeMocks();
|
||||
// Simulate having an HTML block in the clipboard:
|
||||
mockClipboardHtml.applyMock('openassessment');
|
||||
|
||||
@@ -139,4 +247,52 @@ describe('<AddContentContainer />', () => {
|
||||
expect(mockShowToast).toHaveBeenCalledWith(errMsg);
|
||||
});
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
label: 'should handle failure to paste content',
|
||||
mockUrl: getLibraryPasteClipboardUrl(libraryId),
|
||||
mockResponse: undefined,
|
||||
expectedError: 'There was an error pasting the content.',
|
||||
buttonName: /paste from clipboard/i,
|
||||
},
|
||||
{
|
||||
label: 'should show detailed error in toast on paste failure',
|
||||
mockUrl: getLibraryPasteClipboardUrl(libraryId),
|
||||
mockResponse: ['library cannot have more than 100000 components'],
|
||||
expectedError: 'There was an error pasting the content: library cannot have more than 100000 components',
|
||||
buttonName: /paste from clipboard/i,
|
||||
},
|
||||
{
|
||||
label: 'should handle failure to create content',
|
||||
mockUrl: getCreateLibraryBlockUrl(libraryId),
|
||||
mockResponse: undefined,
|
||||
expectedError: 'There was an error creating the content.',
|
||||
buttonName: /text/i,
|
||||
},
|
||||
{
|
||||
label: 'should show detailed error in toast on create failure',
|
||||
mockUrl: getCreateLibraryBlockUrl(libraryId),
|
||||
mockResponse: 'library cannot have more than 100000 components',
|
||||
expectedError: 'There was an error creating the content: library cannot have more than 100000 components',
|
||||
buttonName: /text/i,
|
||||
},
|
||||
])('$label', async ({
|
||||
mockUrl, mockResponse, buttonName, expectedError,
|
||||
}) => {
|
||||
axiosMock.onPost(mockUrl).reply(400, mockResponse);
|
||||
|
||||
// Simulate having an HTML block in the clipboard:
|
||||
mockClipboardHtml.applyMock();
|
||||
|
||||
render();
|
||||
const button = await screen.findByRole('button', { name: buttonName });
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(axiosMock.history.post.length).toEqual(1);
|
||||
expect(axiosMock.history.post[0].url).toEqual(mockUrl);
|
||||
expect(mockShowToast).toHaveBeenCalledWith(expectedError);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useContext } from 'react';
|
||||
import type { MessageDescriptor } from 'react-intl';
|
||||
import { useSelector } from 'react-redux';
|
||||
import {
|
||||
Stack,
|
||||
@@ -21,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';
|
||||
@@ -76,19 +77,25 @@ 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();
|
||||
|
||||
const parsePasteErrorMsg = (error: any) => {
|
||||
let errMsg: string;
|
||||
const parseErrorMsg = (
|
||||
error: any,
|
||||
detailedMessage: MessageDescriptor,
|
||||
defaultMessage: MessageDescriptor,
|
||||
) => {
|
||||
try {
|
||||
const { customAttributes: { httpErrorResponseData } } = error;
|
||||
errMsg = JSON.parse(httpErrorResponseData).block_type;
|
||||
const { response: { data } } = error;
|
||||
const detail = data && (Array.isArray(data) ? data.join() : String(data));
|
||||
if (detail) {
|
||||
return intl.formatMessage(detailedMessage, { detail });
|
||||
}
|
||||
} catch (_err) {
|
||||
errMsg = intl.formatMessage(messages.errorPasteClipboardMessage);
|
||||
// ignore
|
||||
}
|
||||
return errMsg;
|
||||
return intl.formatMessage(defaultMessage);
|
||||
};
|
||||
|
||||
const isBlockTypeEnabled = (blockType: string) => getConfig().LIBRARY_SUPPORTED_BLOCKS.includes(blockType);
|
||||
@@ -158,18 +165,36 @@ const AddContentContainer = () => {
|
||||
contentTypes.push(pasteButton);
|
||||
}
|
||||
|
||||
const linkComponent = (usageKey: string) => {
|
||||
updateComponentsMutation.mutateAsync([usageKey]).catch(() => {
|
||||
showToast(intl.formatMessage(messages.errorAssociateComponentMessage));
|
||||
});
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
pasteClipboardMutation.mutateAsync({
|
||||
libraryId,
|
||||
blockId: `${uuid4()}`,
|
||||
}).then(() => {
|
||||
}).then((data) => {
|
||||
linkComponent(data.id);
|
||||
showToast(intl.formatMessage(messages.successPasteClipboardMessage));
|
||||
}).catch((error) => {
|
||||
showToast(parsePasteErrorMsg(error));
|
||||
showToast(parseErrorMsg(
|
||||
error,
|
||||
messages.errorPasteClipboardMessageWithDetail,
|
||||
messages.errorPasteClipboardMessage,
|
||||
));
|
||||
});
|
||||
};
|
||||
|
||||
@@ -180,17 +205,20 @@ const AddContentContainer = () => {
|
||||
definitionId: `${uuid4()}`,
|
||||
}).then((data) => {
|
||||
const hasEditor = canEditComponent(data.id);
|
||||
updateComponentsMutation.mutateAsync([data.id]).catch(() => {
|
||||
showToast(intl.formatMessage(messages.errorAssociateComponentMessage));
|
||||
});
|
||||
if (hasEditor) {
|
||||
openComponentEditor(data.id);
|
||||
// linkComponent on editor close.
|
||||
openComponentEditor(data.id, () => linkComponent(data.id));
|
||||
} else {
|
||||
// We can't start editing this right away so just show a toast message:
|
||||
showToast(intl.formatMessage(messages.successCreateMessage));
|
||||
linkComponent(data.id);
|
||||
}
|
||||
}).catch(() => {
|
||||
showToast(intl.formatMessage(messages.errorCreateMessage));
|
||||
}).catch((error) => {
|
||||
showToast(parseErrorMsg(
|
||||
error,
|
||||
messages.errorCreateMessageWithDetail,
|
||||
messages.errorCreateMessage,
|
||||
));
|
||||
});
|
||||
};
|
||||
|
||||
@@ -206,6 +234,7 @@ const AddContentContainer = () => {
|
||||
}
|
||||
};
|
||||
|
||||
/* istanbul ignore next */
|
||||
if (pasteClipboardMutation.isLoading) {
|
||||
showToast(intl.formatMessage(messages.pastingClipboardMessage));
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
} from '../data/api.mocks';
|
||||
import { mockBroadcastChannel, mockClipboardEmpty } from '../../generic/data/api.mock';
|
||||
import { mockContentSearchConfig, mockSearchResult } from '../../search-manager/data/api.mock';
|
||||
import { studioHomeMock } from '../../studio-home/__mocks__';
|
||||
import { getStudioHomeApiUrl } from '../../studio-home/data/api';
|
||||
import LibraryLayout from '../LibraryLayout';
|
||||
|
||||
mockContentSearchConfig.applyMock();
|
||||
@@ -46,8 +48,12 @@ const renderOpts = {
|
||||
};
|
||||
|
||||
describe('AddContentWorkflow test', () => {
|
||||
beforeEach(() => {
|
||||
const { axiosMock } = initializeMocks();
|
||||
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, studioHomeMock);
|
||||
});
|
||||
|
||||
it('can create an HTML component', async () => {
|
||||
initializeMocks();
|
||||
render(<LibraryLayout />, renderOpts);
|
||||
|
||||
// Click "New [Component]"
|
||||
@@ -84,7 +90,6 @@ describe('AddContentWorkflow test', () => {
|
||||
});
|
||||
|
||||
it('can create a Problem component', async () => {
|
||||
initializeMocks();
|
||||
render(<LibraryLayout />, renderOpts);
|
||||
|
||||
// Click "New [Component]"
|
||||
@@ -119,7 +124,6 @@ describe('AddContentWorkflow test', () => {
|
||||
});
|
||||
|
||||
it('can create a Video component', async () => {
|
||||
initializeMocks();
|
||||
render(<LibraryLayout />, renderOpts);
|
||||
|
||||
// Click "New [Component]"
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
screen,
|
||||
initializeMocks,
|
||||
} from '../../testUtils';
|
||||
import { studioHomeMock } from '../../studio-home/__mocks__';
|
||||
import { getStudioHomeApiUrl } from '../../studio-home/data/api';
|
||||
import mockResult from '../__mocks__/library-search.json';
|
||||
import { LibraryProvider } from '../common/context';
|
||||
import { ComponentPickerModal } from '../component-picker';
|
||||
@@ -16,7 +18,6 @@ import {
|
||||
} from '../data/api.mocks';
|
||||
import { PickLibraryContentModal } from './PickLibraryContentModal';
|
||||
|
||||
initializeMocks();
|
||||
mockContentSearchConfig.applyMock();
|
||||
mockContentLibrary.applyMock();
|
||||
mockGetCollectionMetadata.applyMock();
|
||||
@@ -45,6 +46,7 @@ describe('<PickLibraryContentModal />', () => {
|
||||
beforeEach(() => {
|
||||
const mocks = initializeMocks();
|
||||
mockShowToast = mocks.mockShowToast;
|
||||
mocks.axiosMock.onGet(getStudioHomeApiUrl()).reply(200, studioHomeMock);
|
||||
});
|
||||
|
||||
it('can pick components from the modal', async () => {
|
||||
|
||||
@@ -64,7 +64,15 @@ const messages = defineMessages({
|
||||
errorCreateMessage: {
|
||||
id: 'course-authoring.library-authoring.add-content.error.text',
|
||||
defaultMessage: 'There was an error creating the content.',
|
||||
description: 'Message when creation of content in library is on error',
|
||||
description: 'Message when creation of content in library is on error.',
|
||||
},
|
||||
errorCreateMessageWithDetail: {
|
||||
id: 'course-authoring.library-authoring.add-content.error.text-detail',
|
||||
defaultMessage: 'There was an error creating the content: {detail}',
|
||||
description: (
|
||||
'Message when creation of content in library is on error.'
|
||||
+ ' The {detail} text provides more information about the error.'
|
||||
),
|
||||
},
|
||||
successAssociateComponentMessage: {
|
||||
id: 'course-authoring.library-authoring.associate-collection-content.success.text',
|
||||
@@ -91,6 +99,14 @@ const messages = defineMessages({
|
||||
defaultMessage: 'There was an error pasting the content.',
|
||||
description: 'Message when pasting clipboard in library errors',
|
||||
},
|
||||
errorPasteClipboardMessageWithDetail: {
|
||||
id: 'course-authoring.library-authoring.paste-clipboard.error.text-detail',
|
||||
defaultMessage: 'There was an error pasting the content: {detail}',
|
||||
description: (
|
||||
'Message when pasting clipboard in library errors.'
|
||||
+ ' The {detail} text provides more information about the error.'
|
||||
),
|
||||
},
|
||||
pastingClipboardMessage: {
|
||||
id: 'course-authoring.library-authoring.paste-clipboard.loading.text',
|
||||
defaultMessage: 'Pasting content from clipboard...',
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Stack } from '@openedx/paragon';
|
||||
import { NoComponents, NoSearchResults } from '../EmptyStates';
|
||||
import { useSearchContext } from '../../search-manager';
|
||||
import { LibraryComponents } from '../components';
|
||||
import messages from './messages';
|
||||
import { useLibraryContext } from '../common/context';
|
||||
import LibraryContent, { ContentType } from '../LibraryContent';
|
||||
|
||||
const LibraryCollectionComponents = () => {
|
||||
const { totalHits: componentCount, isFiltered } = useSearchContext();
|
||||
@@ -24,7 +24,7 @@ const LibraryCollectionComponents = () => {
|
||||
return (
|
||||
<Stack direction="vertical" gap={3}>
|
||||
<h3 className="text-gray">Content ({componentCount})</h3>
|
||||
<LibraryComponents variant="full" />
|
||||
<LibraryContent contentType={ContentType.collections} />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -37,7 +37,7 @@ const searchEndpoint = 'http://mock.meilisearch.local/multi-search';
|
||||
const path = '/library/:libraryId/*';
|
||||
const libraryTitle = mockContentLibrary.libraryData.title;
|
||||
const mockCollection = {
|
||||
collectionId: mockResult.results[2].hits[0].block_id,
|
||||
collectionId: mockResult.results[0].hits[5].block_id,
|
||||
collectionNeverLoads: mockGetCollectionMetadata.collectionIdLoading,
|
||||
collectionNoComponents: 'collection-no-components',
|
||||
collectionEmpty: mockGetCollectionMetadata.collectionIdError,
|
||||
@@ -62,23 +62,21 @@ describe('<LibraryCollectionPage />', () => {
|
||||
// because otherwise Instantsearch will update the UI and change the query,
|
||||
// leading to unexpected results in the test cases.
|
||||
mockResultCopy.results[0].query = query;
|
||||
mockResultCopy.results[2].query = query;
|
||||
// And fake the required '_formatted' fields; it contains the highlighting <mark>...</mark> around matched words
|
||||
// eslint-disable-next-line no-underscore-dangle, no-param-reassign
|
||||
mockResultCopy.results[0]?.hits.forEach((hit) => { hit._formatted = { ...hit }; });
|
||||
const collectionQueryId = requestData?.queries[0]?.filter?.[3]?.split('collections.key = "')[1].split('"')[0];
|
||||
const collectionQueryId = requestData?.queries[0]?.filter?.[2]?.split('collections.key = "')[1].split('"')[0];
|
||||
switch (collectionQueryId) {
|
||||
case mockCollection.collectionNeverLoads:
|
||||
return new Promise<any>(() => {});
|
||||
case mockCollection.collectionEmpty:
|
||||
mockResultCopy.results[2].hits = [];
|
||||
mockResultCopy.results[2].estimatedTotalHits = 0;
|
||||
mockResultCopy.results[0].hits = [];
|
||||
mockResultCopy.results[0].totalHits = 0;
|
||||
break;
|
||||
case mockCollection.collectionNoComponents:
|
||||
mockResultCopy.results[0].hits = [];
|
||||
mockResultCopy.results[0].estimatedTotalHits = 0;
|
||||
mockResultCopy.results[0].totalHits = 0;
|
||||
mockResultCopy.results[1].facetDistribution.block_type = {};
|
||||
mockResultCopy.results[2].hits[0].num_children = 0;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
@@ -181,7 +179,7 @@ describe('<LibraryCollectionPage />', () => {
|
||||
// should not be impacted by the search
|
||||
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); });
|
||||
|
||||
expect(screen.queryByText('No matching components found in this collections.')).toBeInTheDocument();
|
||||
expect(screen.queryByText('No matching components found in this collection.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should open and close new content sidebar', async () => {
|
||||
|
||||
@@ -196,7 +196,6 @@ const LibraryCollectionPage = () => {
|
||||
<Container size="xl" className="px-4 mt-4 mb-5 library-authoring-page">
|
||||
<SearchContextProvider
|
||||
extraFilter={extraFilter}
|
||||
overrideQueries={{ collections: { limit: 0 } }}
|
||||
>
|
||||
<SubHeader
|
||||
title={(
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import { LoadingSpinner } from '../../generic/Loading';
|
||||
import { useLoadOnScroll } from '../../hooks';
|
||||
import { useSearchContext } from '../../search-manager';
|
||||
import { NoComponents, NoSearchResults } from '../EmptyStates';
|
||||
import CollectionCard from '../components/CollectionCard';
|
||||
import { LIBRARY_SECTION_PREVIEW_LIMIT } from '../components/LibrarySection';
|
||||
import messages from './messages';
|
||||
import { useLibraryContext } from '../common/context';
|
||||
|
||||
type LibraryCollectionsProps = {
|
||||
variant: 'full' | 'preview',
|
||||
};
|
||||
|
||||
/**
|
||||
* Library Collections to show collections grid
|
||||
*
|
||||
* Use style to:
|
||||
* - 'full': Show all collections with Infinite scroll pagination.
|
||||
* - 'preview': Show first 4 collections without pagination.
|
||||
*/
|
||||
const LibraryCollections = ({ variant }: LibraryCollectionsProps) => {
|
||||
const {
|
||||
collectionHits,
|
||||
totalCollectionHits,
|
||||
isFetchingNextPage,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
isLoading,
|
||||
isFiltered,
|
||||
} = useSearchContext();
|
||||
|
||||
const { openCreateCollectionModal } = useLibraryContext();
|
||||
|
||||
const collectionList = variant === 'preview' ? collectionHits.slice(0, LIBRARY_SECTION_PREVIEW_LIMIT) : collectionHits;
|
||||
|
||||
useLoadOnScroll(
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
variant === 'full',
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
if (totalCollectionHits === 0) {
|
||||
return isFiltered
|
||||
? <NoSearchResults infoText={messages.noSearchResultsCollections} />
|
||||
: (
|
||||
<NoComponents
|
||||
infoText={messages.noCollections}
|
||||
addBtnText={messages.addCollection}
|
||||
handleBtnClick={openCreateCollectionModal}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="library-cards-grid">
|
||||
{collectionList.map((collectionHit) => (
|
||||
<CollectionCard
|
||||
key={collectionHit.id}
|
||||
collectionHit={collectionHit}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LibraryCollections;
|
||||
@@ -63,7 +63,7 @@ const messages = defineMessages({
|
||||
},
|
||||
noSearchResultsInCollection: {
|
||||
id: 'course-authoring.library-authoring.collections-pag.no-search-results.text',
|
||||
defaultMessage: 'No matching components found in this collections.',
|
||||
defaultMessage: 'No matching components found in this collection.',
|
||||
description: 'Message displayed when no matching components are found in collection',
|
||||
},
|
||||
newContentButton: {
|
||||
|
||||
@@ -68,6 +68,11 @@ export interface SidebarComponentInfo {
|
||||
additionalAction?: SidebarAdditionalActions;
|
||||
}
|
||||
|
||||
export interface ComponentEditorInfo {
|
||||
usageKey: string;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export enum SidebarAdditionalActions {
|
||||
JumpToAddCollections = 'jump-to-add-collections',
|
||||
}
|
||||
@@ -99,9 +104,10 @@ export type LibraryContextData = {
|
||||
// Current collection
|
||||
openCollectionInfoSidebar: (collectionId: string, additionalAction?: SidebarAdditionalActions) => void;
|
||||
// Editor modal - for editing some component
|
||||
/** If the editor is open and the user is editing some component, this is its usageKey */
|
||||
componentBeingEdited: string | undefined;
|
||||
openComponentEditor: (usageKey: string) => void;
|
||||
/** If the editor is open and the user is editing some component, this is the component being edited. */
|
||||
componentBeingEdited: ComponentEditorInfo | undefined;
|
||||
/** If an onClose callback is provided, it will be called when the editor is closed. */
|
||||
openComponentEditor: (usageKey: string, onClose?: () => void) => void;
|
||||
closeComponentEditor: () => void;
|
||||
resetSidebarAdditionalActions: () => void;
|
||||
} & ComponentPickerType;
|
||||
@@ -174,8 +180,16 @@ export const LibraryProvider = ({
|
||||
);
|
||||
const [isLibraryTeamModalOpen, openLibraryTeamModal, closeLibraryTeamModal] = useToggle(false);
|
||||
const [isCreateCollectionModalOpen, openCreateCollectionModal, closeCreateCollectionModal] = useToggle(false);
|
||||
const [componentBeingEdited, openComponentEditor] = useState<string | undefined>();
|
||||
const closeComponentEditor = useCallback(() => openComponentEditor(undefined), []);
|
||||
const [componentBeingEdited, setComponentBeingEdited] = useState<ComponentEditorInfo | undefined>();
|
||||
const closeComponentEditor = useCallback(() => {
|
||||
setComponentBeingEdited((prev) => {
|
||||
prev?.onClose?.();
|
||||
return undefined;
|
||||
});
|
||||
}, []);
|
||||
const openComponentEditor = useCallback((usageKey: string, onClose?: () => void) => {
|
||||
setComponentBeingEdited({ usageKey, onClose });
|
||||
}, []);
|
||||
|
||||
const [selectedComponents, setSelectedComponents] = useState<SelectedComponent[]>([]);
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
mockXBlockAssets,
|
||||
mockXBlockOLX,
|
||||
} from '../data/api.mocks';
|
||||
import * as apiHooks from '../data/apiHooks';
|
||||
import { LibraryProvider, SidebarBodyComponentId } from '../common/context';
|
||||
import { ComponentAdvancedInfo } from './ComponentAdvancedInfo';
|
||||
import { getXBlockAssetsApiUrl } from '../data/api';
|
||||
@@ -25,6 +26,7 @@ const setOLXspy = mockSetXBlockOLX.applyMock();
|
||||
const render = (
|
||||
usageKey: string = mockLibraryBlockMetadata.usageKeyPublished,
|
||||
libraryId: string = mockContentLibrary.libraryId,
|
||||
showOnlyPublished: boolean = false,
|
||||
) => baseRender(
|
||||
<ComponentAdvancedInfo />,
|
||||
{
|
||||
@@ -35,6 +37,7 @@ const render = (
|
||||
id: usageKey,
|
||||
type: SidebarBodyComponentId.ComponentInfo,
|
||||
}}
|
||||
showOnlyPublished={showOnlyPublished}
|
||||
>
|
||||
{children}
|
||||
</LibraryProvider>
|
||||
@@ -124,13 +127,31 @@ describe('<ComponentAdvancedInfo />', () => {
|
||||
});
|
||||
|
||||
it('should display the OLX source of the block (when expanded)', async () => {
|
||||
const usageKey = mockXBlockOLX.usageKeyHtml;
|
||||
const spy = jest.spyOn(apiHooks, 'useXBlockOLX');
|
||||
|
||||
render(mockXBlockOLX.usageKeyHtml);
|
||||
const expandButton = await screen.findByRole('button', { name: /Advanced details/ });
|
||||
fireEvent.click(expandButton);
|
||||
// Because of syntax highlighting, the OLX will be borken up by many different tags so we need to search for
|
||||
// Because of syntax highlighting, the OLX will be broken up by many different tags so we need to search for
|
||||
// just a substring:
|
||||
const olxPart = /This is a text component which uses/;
|
||||
await waitFor(() => expect(screen.getByText(olxPart)).toBeInTheDocument());
|
||||
expect(spy).toHaveBeenCalledWith(usageKey, 'draft');
|
||||
});
|
||||
|
||||
it('should display the published OLX source of the block (when expanded)', async () => {
|
||||
const usageKey = mockXBlockOLX.usageKeyHtml;
|
||||
const spy = jest.spyOn(apiHooks, 'useXBlockOLX');
|
||||
|
||||
render(usageKey, undefined, true);
|
||||
const expandButton = await screen.findByRole('button', { name: /Advanced details/ });
|
||||
fireEvent.click(expandButton);
|
||||
// Because of syntax highlighting, the OLX will be broken up by many different tags so we need to search for
|
||||
// just a substring:
|
||||
const olxPart = /This is a text component which uses/;
|
||||
await waitFor(() => expect(screen.getByText(olxPart)).toBeInTheDocument());
|
||||
expect(spy).toHaveBeenCalledWith(usageKey, 'published');
|
||||
});
|
||||
|
||||
it('does not display "Edit OLX" button and assets dropzone when the library is read-only', async () => {
|
||||
|
||||
@@ -22,7 +22,11 @@ import { ComponentAdvancedAssets } from './ComponentAdvancedAssets';
|
||||
|
||||
const ComponentAdvancedInfoInner: React.FC<Record<never, never>> = () => {
|
||||
const intl = useIntl();
|
||||
const { readOnly, sidebarComponentInfo } = useLibraryContext();
|
||||
const {
|
||||
readOnly,
|
||||
sidebarComponentInfo,
|
||||
showOnlyPublished,
|
||||
} = useLibraryContext();
|
||||
|
||||
const usageKey = sidebarComponentInfo?.id;
|
||||
// istanbul ignore if: this should never happen in production
|
||||
@@ -30,7 +34,10 @@ const ComponentAdvancedInfoInner: React.FC<Record<never, never>> = () => {
|
||||
throw new Error('sidebarComponentUsageKey is required to render ComponentAdvancedInfo');
|
||||
}
|
||||
|
||||
const { data: olx, isLoading: isOLXLoading } = useXBlockOLX(usageKey);
|
||||
const { data: olx, isLoading: isOLXLoading } = useXBlockOLX(
|
||||
usageKey,
|
||||
showOnlyPublished ? 'published' : 'draft',
|
||||
);
|
||||
const editorRef = React.useRef<EditorAccessor | undefined>(undefined);
|
||||
const [isEditingOLX, setEditingOLX] = React.useState(false);
|
||||
const olxUpdater = useUpdateXBlockOLX(usageKey);
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from '@openedx/paragon/icons';
|
||||
|
||||
import { SidebarAdditionalActions, useLibraryContext } from '../common/context';
|
||||
import { ComponentMenu } from '../components';
|
||||
import ComponentMenu from '../components';
|
||||
import { canEditComponent } from '../components/ComponentEditorModal';
|
||||
import ComponentDetails from './ComponentDetails';
|
||||
import ComponentManagement from './ComponentManagement';
|
||||
|
||||
@@ -55,7 +55,7 @@ const ComponentPreview = () => {
|
||||
variant="light"
|
||||
iconBefore={OpenInFull}
|
||||
onClick={openModal}
|
||||
className="position-absolute right-0 zindex-10 m-1"
|
||||
className="position-absolute right-0 zindex-1 m-1"
|
||||
>
|
||||
{intl.formatMessage(messages.previewExpandButtonTitle)}
|
||||
</Button>
|
||||
|
||||
@@ -39,14 +39,14 @@ describe('<ManageCollections />', () => {
|
||||
fetchMock.mockReset();
|
||||
fetchMock.post(searchEndpoint, (_url, req) => {
|
||||
const requestData = JSON.parse(req.body?.toString() ?? '');
|
||||
const query = requestData?.queries[2]?.q ?? '';
|
||||
const query = requestData?.queries[0]?.q ?? '';
|
||||
// We have to replace the query (search keywords) in the mock results with the actual query,
|
||||
// because otherwise Instantsearch will update the UI and change the query,
|
||||
// leading to unexpected results in the test cases.
|
||||
mockCollectionsResults.results[2].query = query;
|
||||
mockCollectionsResults.results[0].query = query;
|
||||
// And fake the required '_formatted' fields; it contains the highlighting <mark>...</mark> around matched words
|
||||
// eslint-disable-next-line no-underscore-dangle, no-param-reassign
|
||||
mockCollectionsResults.results[2]?.hits.forEach((hit) => { hit._formatted = { ...hit }; });
|
||||
mockCollectionsResults.results[0]?.hits.forEach((hit) => { hit._formatted = { ...hit }; });
|
||||
return mockCollectionsResults;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,7 +29,7 @@ interface CollectionsDrawerProps extends ManageCollectionsProps {
|
||||
const CollectionsSelectableBox = ({ usageKey, collections, onClose }: CollectionsDrawerProps) => {
|
||||
const type = 'checkbox';
|
||||
const intl = useIntl();
|
||||
const { collectionHits } = useSearchContext();
|
||||
const { hits } = useSearchContext();
|
||||
const { showToast } = useContext(ToastContext);
|
||||
const collectionKeys = collections.map((collection) => collection.key);
|
||||
const [selectedCollections, {
|
||||
@@ -67,7 +67,7 @@ const CollectionsSelectableBox = ({ usageKey, collections, onClose }: Collection
|
||||
columns={1}
|
||||
ariaLabelledby={intl.formatMessage(messages.manageCollectionsSelectionLabel)}
|
||||
>
|
||||
{collectionHits.map((collectionHit) => (
|
||||
{hits.map((collectionHit) => (
|
||||
<SelectableBox
|
||||
className="d-inline-flex align-items-center shadow-none border border-gray-100"
|
||||
value={collectionHit.blockId}
|
||||
@@ -112,12 +112,9 @@ const AddToCollectionsDrawer = ({ usageKey, collections, onClose }: CollectionsD
|
||||
|
||||
return (
|
||||
<SearchContextProvider
|
||||
overrideQueries={{
|
||||
components: { limit: 0 },
|
||||
blockTypes: { limit: 0 },
|
||||
}}
|
||||
extraFilter={`context_key = "${libraryId}"`}
|
||||
extraFilter={[`context_key = "${libraryId}"`, 'type = "collection"']}
|
||||
skipUrlUpdate
|
||||
skipBlockTypeFetch
|
||||
>
|
||||
<Stack className="mt-2" gap={3}>
|
||||
<FormattedMessage
|
||||
|
||||
@@ -27,6 +27,13 @@ jest.mock('react-router-dom', () => ({
|
||||
},
|
||||
}),
|
||||
}));
|
||||
jest.mock('../../studio-home/hooks', () => ({
|
||||
useStudioHome: () => ({
|
||||
isLoadingPage: false,
|
||||
isFailedLoadingPage: false,
|
||||
librariesV2Enabled: true,
|
||||
}),
|
||||
}));
|
||||
mockContentLibrary.applyMock();
|
||||
mockContentSearchConfig.applyMock();
|
||||
mockGetCollectionMetadata.applyMock();
|
||||
@@ -201,9 +208,8 @@ describe('<ComponentPicker />', () => {
|
||||
|
||||
onChange.mockClear();
|
||||
|
||||
// Select another component (the second "Select" button is the same component as the first,
|
||||
// but in the "Components" section instead of the "Recently Changed" section)
|
||||
fireEvent.click(screen.queryAllByRole('button', { name: 'Select' })[2]);
|
||||
// Select another component
|
||||
fireEvent.click(screen.queryAllByRole('button', { name: 'Select' })[1]);
|
||||
await waitFor(() => expect(onChange).toHaveBeenCalledWith([
|
||||
{
|
||||
usageKey: 'lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd',
|
||||
@@ -260,4 +266,14 @@ describe('<ComponentPicker />', () => {
|
||||
|
||||
await waitFor(() => expect(onChange).toHaveBeenCalledWith([]));
|
||||
});
|
||||
|
||||
it('should display an alert banner when showOnlyPublished is true', async () => {
|
||||
render(<ComponentPicker />);
|
||||
|
||||
expect(await screen.findByText('Test Library 1')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByDisplayValue(/lib:sampletaxonomyorg1:tl1/i));
|
||||
|
||||
// Wait for the content library to load
|
||||
await screen.findByText(/Only published content is visible and available for reuse./i);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { Stepper } from '@openedx/paragon';
|
||||
import { Alert, Stepper } from '@openedx/paragon';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import {
|
||||
type ComponentSelectedEvent,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
import LibraryAuthoringPage from '../LibraryAuthoringPage';
|
||||
import LibraryCollectionPage from '../collections/LibraryCollectionPage';
|
||||
import SelectLibrary from './SelectLibrary';
|
||||
import messages from './messages';
|
||||
|
||||
interface LibraryComponentPickerProps {
|
||||
returnToLibrarySelection: () => void;
|
||||
@@ -65,6 +67,7 @@ export const ComponentPicker: React.FC<ComponentPickerProps> = ({
|
||||
|
||||
const queryParams = new URLSearchParams(location.search);
|
||||
const variant = queryParams.get('variant') || 'draft';
|
||||
const showOnlyPublished = variant === 'published';
|
||||
|
||||
const handleLibrarySelection = (library: string) => {
|
||||
setCurrentStep('pick-components');
|
||||
@@ -99,9 +102,15 @@ export const ComponentPicker: React.FC<ComponentPickerProps> = ({
|
||||
<Stepper.Step eventKey="pick-components" title="Pick some components">
|
||||
<LibraryProvider
|
||||
libraryId={selectedLibrary}
|
||||
showOnlyPublished={variant === 'published'}
|
||||
showOnlyPublished={showOnlyPublished}
|
||||
{...libraryProviderProps}
|
||||
>
|
||||
{ showOnlyPublished
|
||||
&& (
|
||||
<Alert variant="info" className="m-2">
|
||||
<FormattedMessage {...messages.pickerInfoBanner} />
|
||||
</Alert>
|
||||
)}
|
||||
<InnerComponentPicker returnToLibrarySelection={returnToLibrarySelection} />
|
||||
</LibraryProvider>
|
||||
</Stepper.Step>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user