feat: edit Text components within content libraries [FC-0062] (#1240)
This commit is contained in:
@@ -88,7 +88,7 @@ const CourseAuthoringRoutes = () => {
|
||||
/>
|
||||
<Route
|
||||
path="editor/:blockType/:blockId?"
|
||||
element={<PageWrap><EditorContainer courseId={courseId} /></PageWrap>}
|
||||
element={<PageWrap><EditorContainer learningContextId={courseId} /></PageWrap>}
|
||||
/>
|
||||
<Route
|
||||
path="settings/details"
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
import EditorPage from './EditorPage';
|
||||
|
||||
const EditorContainer = ({
|
||||
courseId,
|
||||
}) => {
|
||||
const { blockType, blockId } = useParams();
|
||||
return (
|
||||
<div className="editor-page">
|
||||
<EditorPage
|
||||
courseId={courseId}
|
||||
blockType={blockType}
|
||||
blockId={blockId}
|
||||
studioEndpointUrl={getConfig().STUDIO_BASE_URL}
|
||||
lmsEndpointUrl={getConfig().LMS_BASE_URL}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
EditorContainer.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default EditorContainer;
|
||||
@@ -10,7 +10,7 @@ jest.mock('react-router', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const props = { courseId: 'cOuRsEId' };
|
||||
const props = { learningContextId: 'cOuRsEId' };
|
||||
|
||||
describe('Editor Container', () => {
|
||||
describe('snapshots', () => {
|
||||
|
||||
51
src/editors/EditorContainer.tsx
Normal file
51
src/editors/EditorContainer.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
import EditorPage from './EditorPage';
|
||||
|
||||
interface Props {
|
||||
/** Course ID or Library ID */
|
||||
learningContextId: string;
|
||||
/** Event handler for when user cancels out of the editor page */
|
||||
onClose?: () => void;
|
||||
/** Event handler called after when user saves their changes using an editor */
|
||||
afterSave?: () => (newData: Record<string, any>) => void;
|
||||
}
|
||||
|
||||
const EditorContainer: React.FC<Props> = ({
|
||||
learningContextId,
|
||||
onClose,
|
||||
afterSave,
|
||||
}) => {
|
||||
const { blockType, blockId } = useParams();
|
||||
if (blockType === undefined || blockId === undefined) {
|
||||
// istanbul ignore next - This shouldn't be possible; it's just here to satisfy the type checker.
|
||||
return <div>Error: missing URL parameters</div>;
|
||||
}
|
||||
if (!!onClose !== !!afterSave) {
|
||||
/* istanbul ignore next */
|
||||
throw new Error('You must specify both onClose and afterSave or neither.');
|
||||
// These parameters are a bit messy so I'm trying to help make it more
|
||||
// consistent here. For example, if you specify onClose, then returnFunction
|
||||
// is only called if the save is successful. But if you leave onClose
|
||||
// undefined, then returnFunction is called in either case, and with
|
||||
// different arguments. The underlying EditorPage should be refactored to
|
||||
// have more clear events like onCancel and onSaveSuccess
|
||||
}
|
||||
return (
|
||||
<div className="editor-page">
|
||||
<EditorPage
|
||||
courseId={learningContextId}
|
||||
blockType={blockType}
|
||||
blockId={blockId}
|
||||
studioEndpointUrl={getConfig().STUDIO_BASE_URL}
|
||||
lmsEndpointUrl={getConfig().LMS_BASE_URL}
|
||||
onClose={onClose}
|
||||
returnFunction={afterSave}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditorContainer;
|
||||
@@ -51,6 +51,7 @@ exports[`EditorContainer component render snapshot: initialized. enable save and
|
||||
/>
|
||||
</h2>
|
||||
<IconButton
|
||||
alt="Exit the editor"
|
||||
iconAs="Icon"
|
||||
onClick={[MockFunction openCancelConfirmModal]}
|
||||
src={[MockFunction icons.Close]}
|
||||
@@ -132,6 +133,7 @@ exports[`EditorContainer component render snapshot: not initialized. disable sav
|
||||
/>
|
||||
</h2>
|
||||
<IconButton
|
||||
alt="Exit the editor"
|
||||
iconAs="Icon"
|
||||
onClick={[MockFunction openCancelConfirmModal]}
|
||||
src={[MockFunction icons.Close]}
|
||||
|
||||
@@ -63,6 +63,7 @@ const EditorContainer = ({
|
||||
src={Close}
|
||||
iconAs={Icon}
|
||||
onClick={openCancelConfirmModal}
|
||||
alt={intl.formatMessage(messages.exitButtonAlt)}
|
||||
/>
|
||||
</div>
|
||||
</ModalDialog.Header>
|
||||
|
||||
@@ -12,6 +12,11 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Are you sure you want to exit the editor? Any unsaved changes will be lost.',
|
||||
description: 'Description text for modal confirming cancellation',
|
||||
},
|
||||
exitButtonAlt: {
|
||||
id: 'authoring.editorContainer.exitButton.alt',
|
||||
defaultMessage: 'Exit the editor',
|
||||
description: 'Alt text for the Exit button',
|
||||
},
|
||||
okButtonLabel: {
|
||||
id: 'authoring.editorContainer.okButton.label',
|
||||
defaultMessage: 'OK',
|
||||
|
||||
@@ -24,11 +24,7 @@ exports[`TextEditor snapshots block failed to load, Toast is shown 1`] = `
|
||||
onClose={[MockFunction hooks.nullMethod]}
|
||||
show={true}
|
||||
>
|
||||
<FormattedMessage
|
||||
defaultMessage="Error: Could Not Load Text Content"
|
||||
description="Error Message Dispayed When HTML content fails to Load"
|
||||
id="authoring.texteditor.load.error"
|
||||
/>
|
||||
Error: Could Not Load Text Content
|
||||
</Toast>
|
||||
<TinyMceWidget
|
||||
disabled={false}
|
||||
@@ -81,11 +77,7 @@ exports[`TextEditor snapshots loaded, raw editor 1`] = `
|
||||
onClose={[MockFunction hooks.nullMethod]}
|
||||
show={false}
|
||||
>
|
||||
<FormattedMessage
|
||||
defaultMessage="Error: Could Not Load Text Content"
|
||||
description="Error Message Dispayed When HTML content fails to Load"
|
||||
id="authoring.texteditor.load.error"
|
||||
/>
|
||||
Error: Could Not Load Text Content
|
||||
</Toast>
|
||||
<RawEditor
|
||||
content={
|
||||
@@ -132,11 +124,7 @@ exports[`TextEditor snapshots not yet loaded, Spinner appears 1`] = `
|
||||
onClose={[MockFunction hooks.nullMethod]}
|
||||
show={false}
|
||||
>
|
||||
<FormattedMessage
|
||||
defaultMessage="Error: Could Not Load Text Content"
|
||||
description="Error Message Dispayed When HTML content fails to Load"
|
||||
id="authoring.texteditor.load.error"
|
||||
/>
|
||||
Error: Could Not Load Text Content
|
||||
</Toast>
|
||||
<div
|
||||
className="text-center p-6"
|
||||
@@ -175,11 +163,7 @@ exports[`TextEditor snapshots renders as expected with default behavior 1`] = `
|
||||
onClose={[MockFunction hooks.nullMethod]}
|
||||
show={false}
|
||||
>
|
||||
<FormattedMessage
|
||||
defaultMessage="Error: Could Not Load Text Content"
|
||||
description="Error Message Dispayed When HTML content fails to Load"
|
||||
id="authoring.texteditor.load.error"
|
||||
/>
|
||||
Error: Could Not Load Text Content
|
||||
</Toast>
|
||||
<TinyMceWidget
|
||||
disabled={false}
|
||||
@@ -232,11 +216,7 @@ exports[`TextEditor snapshots renders static images with relative paths 1`] = `
|
||||
onClose={[MockFunction hooks.nullMethod]}
|
||||
show={false}
|
||||
>
|
||||
<FormattedMessage
|
||||
defaultMessage="Error: Could Not Load Text Content"
|
||||
description="Error Message Dispayed When HTML content fails to Load"
|
||||
id="authoring.texteditor.load.error"
|
||||
/>
|
||||
Error: Could Not Load Text Content
|
||||
</Toast>
|
||||
<TinyMceWidget
|
||||
disabled={false}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
Spinner,
|
||||
Toast,
|
||||
} from '@openedx/paragon';
|
||||
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { actions, selectors } from '../../data/redux';
|
||||
import { RequestKeys } from '../../data/constants/requests';
|
||||
@@ -78,7 +78,7 @@ const TextEditor = ({
|
||||
>
|
||||
<div className="editor-body h-75 overflow-auto">
|
||||
<Toast show={blockFailed} onClose={hooks.nullMethod}>
|
||||
<FormattedMessage {...messages.couldNotLoadTextContext} />
|
||||
{ intl.formatMessage(messages.couldNotLoadTextContext) }
|
||||
</Toast>
|
||||
|
||||
{(!blockFinished)
|
||||
@@ -111,7 +111,7 @@ TextEditor.propTypes = {
|
||||
initializeEditor: PropTypes.func.isRequired,
|
||||
showRawEditor: PropTypes.bool.isRequired,
|
||||
blockFinished: PropTypes.bool,
|
||||
learningContextId: PropTypes.string.isRequired,
|
||||
learningContextId: PropTypes.string, // This should be required but is NULL when the store is in initial state :/
|
||||
images: PropTypes.shape({}).isRequired,
|
||||
isLibrary: PropTypes.bool.isRequired,
|
||||
// inject
|
||||
|
||||
@@ -42,10 +42,9 @@ export const returnUrl = createSelector(
|
||||
|
||||
export const isInitialized = createSelector(
|
||||
[
|
||||
module.simpleSelectors.unitUrl,
|
||||
module.simpleSelectors.blockValue,
|
||||
],
|
||||
(unitUrl, blockValue) => !!(unitUrl && blockValue),
|
||||
(blockValue) => !!(blockValue),
|
||||
);
|
||||
|
||||
export const displayTitle = createSelector(
|
||||
|
||||
@@ -78,23 +78,20 @@ describe('app selectors unit tests', () => {
|
||||
});
|
||||
});
|
||||
describe('isInitialized selector', () => {
|
||||
it('is memoized based on unitUrl, editorInitialized, and blockValue', () => {
|
||||
it('is memoized based on editorInitialized and blockValue', () => {
|
||||
expect(selectors.isInitialized.preSelectors).toEqual([
|
||||
simpleSelectors.unitUrl,
|
||||
simpleSelectors.blockValue,
|
||||
]);
|
||||
});
|
||||
it('returns true iff unitUrl, blockValue, and editorInitialized are all truthy', () => {
|
||||
it('returns true iff blockValue and editorInitialized are truthy', () => {
|
||||
const { cb } = selectors.isInitialized;
|
||||
const truthy = {
|
||||
url: { url: 'data' },
|
||||
blockValue: { block: 'value' },
|
||||
};
|
||||
|
||||
[
|
||||
[[null, truthy.blockValue], false],
|
||||
[[truthy.url, null], false],
|
||||
[[truthy.url, truthy.blockValue], true],
|
||||
[[truthy.blockValue], true],
|
||||
[[null], false],
|
||||
].map(([args, expected]) => expect(cb(...args)).toEqual(expected));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -89,7 +89,9 @@ export const initialize = (data) => (dispatch) => {
|
||||
const editorType = data.blockType;
|
||||
dispatch(actions.app.initialize(data));
|
||||
dispatch(module.fetchBlock());
|
||||
dispatch(module.fetchUnit());
|
||||
if (data.blockId?.startsWith('block-v1:')) {
|
||||
dispatch(module.fetchUnit());
|
||||
}
|
||||
switch (editorType) {
|
||||
case 'problem':
|
||||
dispatch(module.fetchImages({ pageNumber: 0 }));
|
||||
@@ -100,7 +102,12 @@ export const initialize = (data) => (dispatch) => {
|
||||
dispatch(module.fetchCourseDetails());
|
||||
break;
|
||||
case 'html':
|
||||
dispatch(module.fetchImages({ pageNumber: 0 }));
|
||||
if (data.learningContextId?.startsWith('lib:')) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Not fetching image assets - not implemented yet for content libraries.');
|
||||
} else {
|
||||
dispatch(module.fetchImages({ pageNumber: 0 }));
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
|
||||
@@ -187,7 +187,6 @@ describe('app thunkActions', () => {
|
||||
expect(dispatch.mock.calls).toEqual([
|
||||
[actions.app.initialize(testValue)],
|
||||
[thunkActions.fetchBlock()],
|
||||
[thunkActions.fetchUnit()],
|
||||
]);
|
||||
thunkActions.fetchBlock = fetchBlock;
|
||||
thunkActions.fetchUnit = fetchUnit;
|
||||
@@ -216,6 +215,8 @@ describe('app thunkActions', () => {
|
||||
const data = {
|
||||
...testValue,
|
||||
blockType: 'html',
|
||||
blockId: 'block-v1:UniversityX+PHYS+1+type@problem+block@123',
|
||||
learningContextId: 'course-v1:UniversityX+PHYS+1',
|
||||
};
|
||||
thunkActions.initialize(data)(dispatch);
|
||||
expect(dispatch.mock.calls).toEqual([
|
||||
@@ -251,6 +252,8 @@ describe('app thunkActions', () => {
|
||||
const data = {
|
||||
...testValue,
|
||||
blockType: 'problem',
|
||||
blockId: 'block-v1:UniversityX+PHYS+1+type@problem+block@123',
|
||||
learningContextId: 'course-v1:UniversityX+PHYS+1',
|
||||
};
|
||||
thunkActions.initialize(data)(dispatch);
|
||||
expect(dispatch.mock.calls).toEqual([
|
||||
@@ -286,6 +289,8 @@ describe('app thunkActions', () => {
|
||||
const data = {
|
||||
...testValue,
|
||||
blockType: 'video',
|
||||
blockId: 'block-v1:UniversityX+PHYS+1+type@problem+block@123',
|
||||
learningContextId: 'course-v1:UniversityX+PHYS+1',
|
||||
};
|
||||
thunkActions.initialize(data)(dispatch);
|
||||
expect(dispatch.mock.calls).toEqual([
|
||||
|
||||
@@ -38,11 +38,7 @@ export const blockAncestor = ({ studioEndpointUrl, blockId }) => {
|
||||
if (blockId.includes('block-v1')) {
|
||||
return `${block({ studioEndpointUrl, blockId })}?fields=ancestorInfo`;
|
||||
}
|
||||
// this url only need to get info to build the return url, which isn't used by V2 blocks
|
||||
// (temporary) don't throw error, just return empty url. it will fail it's network connection but otherwise
|
||||
// the app will run
|
||||
// throw new Error('Block ancestor not available (and not needed) for V2 blocks');
|
||||
return '';
|
||||
throw new Error('Block ancestor not available (and not needed) for V2 blocks');
|
||||
};
|
||||
|
||||
export const blockStudioView = ({ studioEndpointUrl, blockId }) => (
|
||||
|
||||
@@ -95,14 +95,9 @@ describe('cms url methods', () => {
|
||||
expect(blockAncestor({ studioEndpointUrl, blockId }))
|
||||
.toEqual(`${block({ studioEndpointUrl, blockId })}?fields=ancestorInfo`);
|
||||
});
|
||||
// This test will probably be used in the future
|
||||
// it('throws error with studioEndpointUrl, v2 blockId and ancestor query', () => {
|
||||
// expect(() => { blockAncestor({ studioEndpointUrl, blockId: v2BlockId }); })
|
||||
// .toThrow('Block ancestor not available (and not needed) for V2 blocks');
|
||||
// });
|
||||
it('returns blank url with studioEndpointUrl, v2 blockId and ancestor query', () => {
|
||||
expect(blockAncestor({ studioEndpointUrl, blockId: v2BlockId }))
|
||||
.toEqual('');
|
||||
it('throws error with studioEndpointUrl, v2 blockId and ancestor query', () => {
|
||||
expect(() => { blockAncestor({ studioEndpointUrl, blockId: v2BlockId }); })
|
||||
.toThrow('Block ancestor not available (and not needed) for V2 blocks');
|
||||
});
|
||||
});
|
||||
describe('blockStudioView', () => {
|
||||
|
||||
49
src/generic/data/api.mock.ts
Normal file
49
src/generic/data/api.mock.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/* istanbul ignore file */
|
||||
import * as api from './api';
|
||||
|
||||
/**
|
||||
* Mock for `getClipboard()` that simulates an empty clipboard
|
||||
*/
|
||||
export async function mockClipboardEmpty(): Promise<api.ClipboardStatus> {
|
||||
return {
|
||||
content: null,
|
||||
sourceUsageKey: '',
|
||||
sourceContextTitle: '',
|
||||
sourceEditUrl: '',
|
||||
};
|
||||
}
|
||||
mockClipboardEmpty.applyMock = () => jest.spyOn(api, 'getClipboard').mockImplementation(mockClipboardEmpty);
|
||||
mockClipboardEmpty.applyMockOnce = () => jest.spyOn(api, 'getClipboard').mockImplementationOnce(mockClipboardEmpty);
|
||||
|
||||
/**
|
||||
* Mock for `getClipboard()` that simulates a copied HTML component
|
||||
*/
|
||||
export async function mockClipboardHtml(): Promise<api.ClipboardStatus> {
|
||||
return {
|
||||
content: {
|
||||
id: 69,
|
||||
userId: 3,
|
||||
created: '2024-01-16T13:33:21.314439Z',
|
||||
purpose: 'clipboard',
|
||||
status: 'ready',
|
||||
blockType: 'html',
|
||||
blockTypeDisplay: 'Text',
|
||||
olxUrl: 'http://localhost:18010/api/content-staging/v1/staged-content/69/olx',
|
||||
displayName: 'Blank HTML Page',
|
||||
},
|
||||
sourceUsageKey: 'block-v1:edX+DemoX+Demo_Course+type@html+block@html1',
|
||||
sourceContextTitle: 'Demonstration Course',
|
||||
sourceEditUrl: 'http://localhost:18010/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical1',
|
||||
};
|
||||
}
|
||||
mockClipboardHtml.applyMock = () => jest.spyOn(api, 'getClipboard').mockImplementation(mockClipboardHtml);
|
||||
mockClipboardHtml.applyMockOnce = () => jest.spyOn(api, 'getClipboard').mockImplementationOnce(mockClipboardHtml);
|
||||
|
||||
/** Mock the DOM `BroadcastChannel` API which the clipboard code uses */
|
||||
export function mockBroadcastChannel() {
|
||||
const clipboardBroadcastChannelMock = {
|
||||
postMessage: jest.fn(),
|
||||
close: jest.fn(),
|
||||
};
|
||||
(global as any).BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
|
||||
}
|
||||
@@ -47,10 +47,27 @@ export async function createOrRerunCourse(courseData: Object): Promise<unknown>
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
export interface ClipboardStatus {
|
||||
content: {
|
||||
id: number;
|
||||
userId: number;
|
||||
created: string; // e.g. '2024-08-28T19:02:08.272192Z'
|
||||
purpose: 'clipboard';
|
||||
status: 'ready' | 'loading' | 'expired' | 'error';
|
||||
blockType: string;
|
||||
blockTypeDisplay: string;
|
||||
olxUrl: string;
|
||||
displayName: string;
|
||||
} | null;
|
||||
sourceUsageKey: string; // May be an empty string
|
||||
sourceContextTitle: string; // May be an empty string
|
||||
sourceEditUrl: string; // May be an empty string
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves user's clipboard.
|
||||
*/
|
||||
export async function getClipboard(): Promise<unknown> {
|
||||
export async function getClipboard(): Promise<ClipboardStatus> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(getClipboardUrl());
|
||||
|
||||
@@ -60,7 +77,7 @@ export async function getClipboard(): Promise<unknown> {
|
||||
/**
|
||||
* Updates user's clipboard.
|
||||
*/
|
||||
export async function updateClipboard(usageKey: string): Promise<unknown> {
|
||||
export async function updateClipboard(usageKey: string): Promise<ClipboardStatus> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getClipboardUrl(), { usage_key: usageKey });
|
||||
|
||||
|
||||
@@ -16,11 +16,11 @@ export interface ToastProviderProps {
|
||||
* Global context to keep track of popup message(s) that appears to user after
|
||||
* they take an action like creating or deleting something.
|
||||
*/
|
||||
export const ToastContext = React.createContext({
|
||||
export const ToastContext = React.createContext<ToastContextData>({
|
||||
toastMessage: null,
|
||||
showToast: () => {},
|
||||
closeToast: () => {},
|
||||
} as ToastContextData);
|
||||
});
|
||||
|
||||
/**
|
||||
* React component to provide `ToastContext` to the app
|
||||
|
||||
@@ -14,4 +14,9 @@
|
||||
min-width: 300px;
|
||||
max-width: map-get($grid-breakpoints, "sm");
|
||||
z-index: 1001; // to appear over header
|
||||
position: sticky;
|
||||
top: 0;
|
||||
right: 0;
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@@ -7,15 +7,18 @@ import {
|
||||
waitFor,
|
||||
within,
|
||||
} from '../testUtils';
|
||||
import { getContentSearchConfigUrl } from '../search-manager/data/api';
|
||||
import mockResult from './__mocks__/library-search.json';
|
||||
import mockEmptyResult from '../search-modal/__mocks__/empty-search-result.json';
|
||||
import { mockContentLibrary, mockLibraryBlockTypes, mockXBlockFields } from './data/api.mocks';
|
||||
import { mockContentSearchConfig } from '../search-manager/data/api.mock';
|
||||
import { mockBroadcastChannel } from '../generic/data/api.mock';
|
||||
import { LibraryLayout } from '.';
|
||||
|
||||
mockContentSearchConfig.applyMock();
|
||||
mockContentLibrary.applyMock();
|
||||
mockLibraryBlockTypes.applyMock();
|
||||
mockXBlockFields.applyMock();
|
||||
mockBroadcastChannel();
|
||||
|
||||
const searchEndpoint = 'http://mock.meilisearch.local/multi-search';
|
||||
|
||||
@@ -62,26 +65,12 @@ const returnLowNumberResults = (_url, req) => {
|
||||
return newMockResult;
|
||||
};
|
||||
|
||||
const clipboardBroadcastChannelMock = {
|
||||
postMessage: jest.fn(),
|
||||
close: jest.fn(),
|
||||
};
|
||||
|
||||
(global as any).BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
|
||||
|
||||
const path = '/library/:libraryId/*';
|
||||
const libraryTitle = mockContentLibrary.libraryData.title;
|
||||
|
||||
describe('<LibraryAuthoringPage />', () => {
|
||||
beforeEach(() => {
|
||||
const { axiosMock } = initializeMocks();
|
||||
|
||||
// The API method to get the Meilisearch connection details uses Axios:
|
||||
axiosMock.onGet(getContentSearchConfigUrl()).reply(200, {
|
||||
url: 'http://mock.meilisearch.local',
|
||||
index_name: 'studio',
|
||||
api_key: 'test-key',
|
||||
});
|
||||
initializeMocks();
|
||||
|
||||
// The Meilisearch client-side API uses fetch, not Axios.
|
||||
fetchMock.post(searchEndpoint, (_url, req) => {
|
||||
@@ -432,8 +421,7 @@ describe('<LibraryAuthoringPage />', () => {
|
||||
expect(mockResult0.display_name).toStrictEqual(displayName);
|
||||
await renderLibraryPage();
|
||||
|
||||
// Click on the first component
|
||||
expect((await screen.findAllByText(displayName))[0]).toBeInTheDocument();
|
||||
// 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');
|
||||
|
||||
@@ -120,6 +120,7 @@ const LibraryAuthoringPage = () => {
|
||||
|
||||
const { libraryId } = useParams();
|
||||
if (!libraryId) {
|
||||
// istanbul ignore next - This shouldn't be possible; it's just here to satisfy the type checker.
|
||||
throw new Error('Rendered without libraryId URL parameter');
|
||||
}
|
||||
const { data: libraryData, isLoading } = useContentLibrary(libraryId);
|
||||
@@ -153,8 +154,8 @@ const LibraryAuthoringPage = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="d-flex overflow-auto">
|
||||
<div className="flex-grow-1 align-content-center">
|
||||
<div className="d-flex">
|
||||
<div className="flex-grow-1">
|
||||
<Header
|
||||
number={libraryData.slug}
|
||||
title={libraryData.title}
|
||||
|
||||
@@ -1,11 +1,56 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Route,
|
||||
Routes,
|
||||
useNavigate,
|
||||
useParams,
|
||||
} from 'react-router-dom';
|
||||
import { PageWrap } from '@edx/frontend-platform/react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import EditorContainer from '../editors/EditorContainer';
|
||||
import LibraryAuthoringPage from './LibraryAuthoringPage';
|
||||
import { LibraryProvider } from './common/context';
|
||||
import { invalidateComponentData } from './data/apiHooks';
|
||||
|
||||
const LibraryLayout = () => (
|
||||
<LibraryProvider>
|
||||
<LibraryAuthoringPage />
|
||||
</LibraryProvider>
|
||||
);
|
||||
const LibraryLayout = () => {
|
||||
const { libraryId } = useParams();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
if (libraryId === undefined) {
|
||||
// istanbul ignore next - This shouldn't be possible; it's just here to satisfy the type checker.
|
||||
throw new Error('Error: route is missing libraryId.');
|
||||
}
|
||||
|
||||
const navigate = useNavigate();
|
||||
const goBack = React.useCallback(() => {
|
||||
// Go back to the library
|
||||
navigate(`/library/${libraryId}`);
|
||||
// The following function is called only if changes are saved:
|
||||
return ({ id: usageKey }) => {
|
||||
// invalidate any queries that involve this XBlock:
|
||||
invalidateComponentData(queryClient, libraryId, usageKey);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<LibraryProvider>
|
||||
<Routes>
|
||||
<Route
|
||||
path="editor/:blockType/:blockId?"
|
||||
element={(
|
||||
<PageWrap>
|
||||
<EditorContainer learningContextId={libraryId} onClose={goBack} afterSave={goBack} />
|
||||
</PageWrap>
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
path="*"
|
||||
element={<LibraryAuthoringPage />}
|
||||
/>
|
||||
</Routes>
|
||||
</LibraryProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default LibraryLayout;
|
||||
|
||||
@@ -1,77 +1,25 @@
|
||||
import React from 'react';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import {
|
||||
render, screen, fireEvent, waitFor,
|
||||
} from '@testing-library/react';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import AddContentContainer from './AddContentContainer';
|
||||
import initializeStore from '../../store';
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
initializeMocks,
|
||||
} from '../../testUtils';
|
||||
import { mockContentLibrary } from '../data/api.mocks';
|
||||
import { getCreateLibraryBlockUrl, getLibraryPasteClipboardUrl } from '../data/api';
|
||||
import { getClipboardUrl } from '../../generic/data/api';
|
||||
import { mockBroadcastChannel, mockClipboardEmpty, mockClipboardHtml } from '../../generic/data/api.mock';
|
||||
import AddContentContainer from './AddContentContainer';
|
||||
|
||||
import { clipboardXBlock } from '../../__mocks__';
|
||||
mockBroadcastChannel();
|
||||
|
||||
const mockUseParams = jest.fn();
|
||||
let axiosMock;
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'), // use actual for all non-hook parts
|
||||
useParams: () => mockUseParams(),
|
||||
}));
|
||||
|
||||
const libraryId = '1';
|
||||
let store;
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const clipboardBroadcastChannelMock = {
|
||||
postMessage: jest.fn(),
|
||||
close: jest.fn(),
|
||||
};
|
||||
|
||||
(global as any).BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
|
||||
|
||||
const RootWrapper = () => (
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AddContentContainer />
|
||||
</QueryClientProvider>
|
||||
</IntlProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
const { libraryId } = mockContentLibrary;
|
||||
const renderOpts = { path: '/library/:libraryId/*', params: { libraryId } };
|
||||
|
||||
describe('<AddContentContainer />', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
mockUseParams.mockReturnValue({ libraryId });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render content buttons', () => {
|
||||
render(<RootWrapper />);
|
||||
initializeMocks();
|
||||
mockClipboardEmpty.applyMock();
|
||||
render(<AddContentContainer />);
|
||||
expect(screen.getByRole('button', { name: /collection/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /text/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /problem/i })).toBeInTheDocument();
|
||||
@@ -83,10 +31,12 @@ describe('<AddContentContainer />', () => {
|
||||
});
|
||||
|
||||
it('should create a content', async () => {
|
||||
const { axiosMock } = initializeMocks();
|
||||
mockClipboardEmpty.applyMock();
|
||||
const url = getCreateLibraryBlockUrl(libraryId);
|
||||
axiosMock.onPost(url).reply(200);
|
||||
|
||||
render(<RootWrapper />);
|
||||
render(<AddContentContainer />, renderOpts);
|
||||
|
||||
const textButton = screen.getByRole('button', { name: /text/i });
|
||||
fireEvent.click(textButton);
|
||||
@@ -95,47 +45,47 @@ describe('<AddContentContainer />', () => {
|
||||
});
|
||||
|
||||
it('should render paste button if clipboard contains pastable xblock', async () => {
|
||||
const url = getClipboardUrl();
|
||||
axiosMock.onGet(url).reply(200, clipboardXBlock);
|
||||
|
||||
render(<RootWrapper />);
|
||||
|
||||
await waitFor(() => expect(axiosMock.history.get[0].url).toEqual(url));
|
||||
|
||||
expect(screen.getByRole('button', { name: /paste from clipboard/i })).toBeInTheDocument();
|
||||
initializeMocks();
|
||||
// Simulate having an HTML block in the clipboard:
|
||||
const getClipboardSpy = mockClipboardHtml.applyMock();
|
||||
const doc = render(<AddContentContainer />, renderOpts);
|
||||
expect(getClipboardSpy).toHaveBeenCalled(); // Hmm, this is getting called three times! Refactor to use react-query.
|
||||
await waitFor(() => expect(doc.queryByRole('button', { name: /paste from clipboard/i })).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('should paste content', async () => {
|
||||
const clipboardUrl = getClipboardUrl();
|
||||
axiosMock.onGet(clipboardUrl).reply(200, clipboardXBlock);
|
||||
const { axiosMock } = initializeMocks();
|
||||
// Simulate having an HTML block in the clipboard:
|
||||
const getClipboardSpy = mockClipboardHtml.applyMock();
|
||||
|
||||
const pasteUrl = getLibraryPasteClipboardUrl(libraryId);
|
||||
axiosMock.onPost(pasteUrl).reply(200);
|
||||
|
||||
render(<RootWrapper />);
|
||||
render(<AddContentContainer />, renderOpts);
|
||||
|
||||
await waitFor(() => expect(axiosMock.history.get[0].url).toEqual(clipboardUrl));
|
||||
expect(getClipboardSpy).toHaveBeenCalled(); // Hmm, this is getting called four times! Refactor to use react-query.
|
||||
|
||||
const pasteButton = screen.getByRole('button', { name: /paste from clipboard/i });
|
||||
const pasteButton = await screen.findByRole('button', { name: /paste from clipboard/i });
|
||||
fireEvent.click(pasteButton);
|
||||
|
||||
await waitFor(() => expect(axiosMock.history.post[0].url).toEqual(pasteUrl));
|
||||
});
|
||||
|
||||
it('should fail pasting content', async () => {
|
||||
const clipboardUrl = getClipboardUrl();
|
||||
axiosMock.onGet(clipboardUrl).reply(200, clipboardXBlock);
|
||||
it('should handle failure to paste content', async () => {
|
||||
const { axiosMock } = initializeMocks();
|
||||
// Simulate having an HTML block in the clipboard:
|
||||
mockClipboardHtml.applyMock();
|
||||
|
||||
const pasteUrl = getLibraryPasteClipboardUrl(libraryId);
|
||||
axiosMock.onPost(pasteUrl).reply(400);
|
||||
|
||||
render(<RootWrapper />);
|
||||
render(<AddContentContainer />, renderOpts);
|
||||
|
||||
await waitFor(() => expect(axiosMock.history.get[0].url).toEqual(clipboardUrl));
|
||||
|
||||
const pasteButton = screen.getByRole('button', { name: /paste from clipboard/i });
|
||||
const pasteButton = await screen.findByRole('button', { name: /paste from clipboard/i });
|
||||
fireEvent.click(pasteButton);
|
||||
|
||||
await waitFor(() => expect(axiosMock.history.post[0].url).toEqual(pasteUrl));
|
||||
|
||||
// TODO: check that an actual error message is shown?!
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,16 +16,19 @@ import {
|
||||
ContentPaste,
|
||||
} from '@openedx/paragon/icons';
|
||||
import { v4 as uuid4 } from 'uuid';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
|
||||
import { ToastContext } from '../../generic/toast-context';
|
||||
import { useCopyToClipboard } from '../../generic/clipboard';
|
||||
import { getCanEdit } from '../../course-unit/data/selectors';
|
||||
import { useCreateLibraryBlock, useLibraryPasteClipboard } from '../data/apiHooks';
|
||||
import { getEditUrl } from '../components/utils';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
const AddContentContainer = () => {
|
||||
const intl = useIntl();
|
||||
const navigate = useNavigate();
|
||||
const { libraryId } = useParams();
|
||||
const createBlockMutation = useCreateLibraryBlock();
|
||||
const pasteClipboardMutation = useLibraryPasteClipboard();
|
||||
@@ -100,8 +103,14 @@ const AddContentContainer = () => {
|
||||
libraryId,
|
||||
blockType,
|
||||
definitionId: `${uuid4()}`,
|
||||
}).then(() => {
|
||||
showToast(intl.formatMessage(messages.successCreateMessage));
|
||||
}).then((data) => {
|
||||
const editUrl = getEditUrl(data.id);
|
||||
if (editUrl) {
|
||||
navigate(editUrl);
|
||||
} else {
|
||||
// We can't start editing this right away so just show a toast message:
|
||||
showToast(intl.formatMessage(messages.successCreateMessage));
|
||||
}
|
||||
}).catch(() => {
|
||||
showToast(intl.formatMessage(messages.errorCreateMessage));
|
||||
});
|
||||
|
||||
104
src/library-authoring/add-content/AddContentWorkflow.test.tsx
Normal file
104
src/library-authoring/add-content/AddContentWorkflow.test.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Test the whole workflow of adding content, editing it, saving it
|
||||
*/
|
||||
import { snakeCaseObject } from '@edx/frontend-platform';
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
waitFor,
|
||||
screen,
|
||||
initializeMocks,
|
||||
} from '../../testUtils';
|
||||
import mockResult from '../__mocks__/library-search.json';
|
||||
import editorCmsApi from '../../editors/data/services/cms/api';
|
||||
import * as textEditorHooks from '../../editors/containers/TextEditor/hooks';
|
||||
import {
|
||||
mockContentLibrary,
|
||||
mockCreateLibraryBlock,
|
||||
mockLibraryBlockTypes,
|
||||
mockXBlockFields,
|
||||
} from '../data/api.mocks';
|
||||
import { mockBroadcastChannel, mockClipboardEmpty } from '../../generic/data/api.mock';
|
||||
import { mockContentSearchConfig, mockSearchResult } from '../../search-manager/data/api.mock';
|
||||
import LibraryLayout from '../LibraryLayout';
|
||||
|
||||
mockContentSearchConfig.applyMock();
|
||||
mockLibraryBlockTypes.applyMock();
|
||||
mockClipboardEmpty.applyMock();
|
||||
mockBroadcastChannel();
|
||||
mockContentLibrary.applyMock();
|
||||
mockCreateLibraryBlock.applyMock();
|
||||
mockSearchResult(mockResult);
|
||||
// Mocking the redux APIs in the src/editors/ folder is a bit more involved:
|
||||
jest.spyOn(editorCmsApi as any, 'fetchBlockById').mockImplementation(
|
||||
async (args: { blockId: string }) => (
|
||||
{ status: 200, data: snakeCaseObject(await mockXBlockFields(args.blockId)) }
|
||||
),
|
||||
);
|
||||
jest.spyOn(textEditorHooks, 'getContent').mockImplementation(() => () => '<p>Edited HTML content</p>');
|
||||
jest.mock('frontend-components-tinymce-advanced-plugins', () => ({ a11ycheckerCss: '' }));
|
||||
|
||||
const { libraryId } = mockContentLibrary;
|
||||
const renderOpts = {
|
||||
// Mount the <LibraryLayout /> on this route, to simulate how it's mounted in the real app:
|
||||
path: '/library/:libraryId/*',
|
||||
// And set the current URL to the following:
|
||||
routerProps: { initialEntries: [`/library/${libraryId}/components`] },
|
||||
};
|
||||
|
||||
describe('AddContentWorkflow test', () => {
|
||||
it('can create an HTML component', async () => {
|
||||
initializeMocks();
|
||||
render(<LibraryLayout />, renderOpts);
|
||||
|
||||
// Click "New [Component]"
|
||||
const newComponentButton = await screen.findByRole('button', { name: /New/ });
|
||||
fireEvent.click(newComponentButton);
|
||||
|
||||
// Click "Text" to create a text component
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Text/ }));
|
||||
|
||||
// Then the editor should open
|
||||
expect(await screen.findByRole('heading', { name: /New Text Component/ })).toBeInTheDocument();
|
||||
|
||||
// Edit the title
|
||||
fireEvent.click(screen.getByRole('button', { name: /Edit Title/ }));
|
||||
const titleInput = screen.getByPlaceholderText('Title');
|
||||
fireEvent.change(titleInput, { target: { value: 'A customized title' } });
|
||||
fireEvent.blur(titleInput);
|
||||
await waitFor(() => expect(screen.queryByRole('heading', { name: /New Text Component/ })).not.toBeInTheDocument());
|
||||
expect(screen.getByRole('heading', { name: /A customized title/ }));
|
||||
|
||||
// Note that TinyMCE doesn't really load properly in our test environment
|
||||
// so we can't really edit the text, but we have getContent() mocked to simulate
|
||||
// using TinyMCE to enter some new HTML.
|
||||
|
||||
// Mock the save() REST API method:
|
||||
const saveSpy = jest.spyOn(editorCmsApi as any, 'saveBlock').mockImplementationOnce(async () => ({
|
||||
status: 200, data: { id: mockXBlockFields.usageKeyNewHtml },
|
||||
}));
|
||||
|
||||
// Click Save
|
||||
const saveButton = screen.getByLabelText('Save changes and return to learning context');
|
||||
fireEvent.click(saveButton);
|
||||
expect(saveSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('can create a Problem component', async () => {
|
||||
const { mockShowToast } = initializeMocks();
|
||||
render(<LibraryLayout />, renderOpts);
|
||||
|
||||
// Click "New [Component]"
|
||||
const newComponentButton = await screen.findByRole('button', { name: /New/ });
|
||||
fireEvent.click(newComponentButton);
|
||||
|
||||
// Pre-condition - this is NOT shown yet:
|
||||
expect(screen.queryByText('Content created successfully.')).not.toBeInTheDocument();
|
||||
|
||||
// Click "Problem" to create a capa problem component
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Problem/ }));
|
||||
|
||||
// We haven't yet implemented the problem editor, so we expect only a toast to appear
|
||||
await waitFor(() => expect(mockShowToast).toHaveBeenCalledWith('Content created successfully.'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
/* istanbul ignore file */
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
// This file doesn't need test coverage nor i18n because it's only seen by devs
|
||||
import React from 'react';
|
||||
import { LoadingSpinner } from '../../generic/Loading';
|
||||
import { useXBlockOLX } from '../data/apiHooks';
|
||||
|
||||
interface Props {
|
||||
usageKey: string;
|
||||
}
|
||||
|
||||
/* istanbul ignore next */
|
||||
export const ComponentDeveloperInfo: React.FC<Props> = ({ usageKey }) => {
|
||||
const { data: olx, isLoading: isOLXLoading } = useXBlockOLX(usageKey);
|
||||
return (
|
||||
<>
|
||||
<hr />
|
||||
<h3 className="h5">Developer Component Details</h3>
|
||||
<p><small>(This panel is only visible in development builds.)</small></p>
|
||||
<dl>
|
||||
<dt>Usage key</dt>
|
||||
<dd><code>{usageKey}</code></dd>
|
||||
<dt>OLX</dt>
|
||||
<dd>
|
||||
{
|
||||
olx ? <code className="micro">{olx}</code> : // eslint-disable-line
|
||||
isOLXLoading ? <LoadingSpinner /> : // eslint-disable-line
|
||||
<span>Error</span>
|
||||
}
|
||||
</dd>
|
||||
</dl>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -6,8 +6,11 @@ import {
|
||||
Tabs,
|
||||
Stack,
|
||||
} from '@openedx/paragon';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { getEditUrl } from '../components/utils';
|
||||
import { ComponentMenu } from '../components';
|
||||
import { ComponentDeveloperInfo } from './ComponentDeveloperInfo';
|
||||
import messages from './messages';
|
||||
|
||||
interface ComponentInfoProps {
|
||||
@@ -16,11 +19,16 @@ interface ComponentInfoProps {
|
||||
|
||||
const ComponentInfo = ({ usageKey } : ComponentInfoProps) => {
|
||||
const intl = useIntl();
|
||||
const editUrl = getEditUrl(usageKey);
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<div className="d-flex flex-wrap">
|
||||
<Button disabled variant="outline-primary" className="m-1 text-nowrap flex-grow-1">
|
||||
<Button
|
||||
{...(editUrl ? { as: Link, to: editUrl } : { disabled: true, to: '#' })}
|
||||
variant="outline-primary"
|
||||
className="m-1 text-nowrap flex-grow-1"
|
||||
>
|
||||
{intl.formatMessage(messages.editComponentButtonTitle)}
|
||||
</Button>
|
||||
<Button disabled variant="outline-primary" className="m-1 text-nowrap flex-grow-1">
|
||||
@@ -41,6 +49,10 @@ const ComponentInfo = ({ usageKey } : ComponentInfoProps) => {
|
||||
</Tab>
|
||||
<Tab eventKey="details" title={intl.formatMessage(messages.detailsTabTitle)}>
|
||||
Details tab placeholder
|
||||
|
||||
{
|
||||
(process.env.NODE_ENV === 'development' ? <ComponentDeveloperInfo usageKey={usageKey} /> : null)
|
||||
}
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</Stack>
|
||||
|
||||
@@ -24,9 +24,9 @@ const ComponentInfoHeader = ({ library, usageKey }: ComponentInfoHeaderProps) =>
|
||||
|
||||
const {
|
||||
data: xblockFields,
|
||||
} = useXBlockFields(library.id, usageKey);
|
||||
} = useXBlockFields(usageKey);
|
||||
|
||||
const updateMutation = useUpdateXBlockFields(library.id, usageKey);
|
||||
const updateMutation = useUpdateXBlockFields(usageKey);
|
||||
const { showToast } = useContext(ToastContext);
|
||||
|
||||
const handleSaveDisplayName = useCallback(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useContext, useState } from 'react';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
ActionRow,
|
||||
Icon,
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Dropdown,
|
||||
} from '@openedx/paragon';
|
||||
import { MoreVert } from '@openedx/paragon/icons';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { updateClipboard } from '../../generic/data/api';
|
||||
import { ToastContext } from '../../generic/toast-context';
|
||||
@@ -14,6 +15,7 @@ import { type ContentHit } from '../../search-manager';
|
||||
import { LibraryContext } from '../common/context';
|
||||
import messages from './messages';
|
||||
import { STUDIO_CLIPBOARD_CHANNEL } from '../../constants';
|
||||
import { getEditUrl } from './utils';
|
||||
import BaseComponentCard from './BaseComponentCard';
|
||||
|
||||
type ComponentCardProps = {
|
||||
@@ -23,6 +25,7 @@ type ComponentCardProps = {
|
||||
|
||||
export const ComponentMenu = ({ usageKey }: { usageKey: string }) => {
|
||||
const intl = useIntl();
|
||||
const editUrl = usageKey && getEditUrl(usageKey);
|
||||
const { showToast } = useContext(ToastContext);
|
||||
const [clipboardBroadcastChannel] = useState(() => new BroadcastChannel(STUDIO_CLIPBOARD_CHANNEL));
|
||||
const updateClipboardClick = () => {
|
||||
@@ -46,14 +49,14 @@ export const ComponentMenu = ({ usageKey }: { usageKey: string }) => {
|
||||
data-testid="component-card-menu-toggle"
|
||||
/>
|
||||
<Dropdown.Menu>
|
||||
<Dropdown.Item disabled>
|
||||
{intl.formatMessage(messages.menuEdit)}
|
||||
<Dropdown.Item {...(editUrl ? { as: Link, to: editUrl } : { disabled: true, to: '#' })}>
|
||||
<FormattedMessage {...messages.menuEdit} />
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item onClick={updateClipboardClick}>
|
||||
{intl.formatMessage(messages.menuCopyToClipboard)}
|
||||
<FormattedMessage {...messages.menuCopyToClipboard} />
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item disabled>
|
||||
{intl.formatMessage(messages.menuAddToCollection)}
|
||||
<FormattedMessage {...messages.menuAddToCollection} />
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
|
||||
63
src/library-authoring/components/utils.test.ts
Normal file
63
src/library-authoring/components/utils.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { getBlockType, getEditUrl, getLibraryId } from './utils';
|
||||
|
||||
describe('component utils', () => {
|
||||
describe('getBlockType', () => {
|
||||
for (const [input, expected] of [
|
||||
['lb:org:lib:html:id', 'html'],
|
||||
['lb:OpenCraftX:ALPHA:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd', 'html'],
|
||||
['lb:Axim:beta:problem:571fe018-f3ce-45c9-8f53-5dafcb422fdd', 'problem'],
|
||||
]) {
|
||||
it(`returns '${expected}' for usage key '${input}'`, () => {
|
||||
expect(getBlockType(input)).toStrictEqual(expected);
|
||||
});
|
||||
}
|
||||
|
||||
for (const input of ['', undefined, null, 'not a key', 'lb:foo']) {
|
||||
it(`throws an exception for usage key '${input}'`, () => {
|
||||
expect(() => getBlockType(input as any)).toThrow(`Invalid usageKey: ${input}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('getLibraryId', () => {
|
||||
for (const [input, expected] of [
|
||||
['lb:org:lib:html:id', 'lib:org:lib'],
|
||||
['lb:OpenCraftX:ALPHA:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd', 'lib:OpenCraftX:ALPHA'],
|
||||
['lb:Axim:beta:problem:571fe018-f3ce-45c9-8f53-5dafcb422fdd', 'lib:Axim:beta'],
|
||||
]) {
|
||||
it(`returns '${expected}' for usage key '${input}'`, () => {
|
||||
expect(getLibraryId(input)).toStrictEqual(expected);
|
||||
});
|
||||
}
|
||||
|
||||
for (const input of ['', undefined, null, 'not a key', 'lb:foo']) {
|
||||
it(`throws an exception for usage key '${input}'`, () => {
|
||||
expect(() => getLibraryId(input as any)).toThrow(`Invalid usageKey: ${input}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('getEditUrl', () => {
|
||||
it('returns the right URL for an HTML (Text) block', () => {
|
||||
const usageKey = 'lb:org:ALPHA:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd';
|
||||
expect(getEditUrl(usageKey)).toStrictEqual(`/library/lib:org:ALPHA/editor/html/${usageKey}`);
|
||||
});
|
||||
it('doesn\'t yet allow editing a problem block', () => {
|
||||
const usageKey = 'lb:org:beta:problem:571fe018-f3ce-45c9-8f53-5dafcb422fdd';
|
||||
expect(getEditUrl(usageKey)).toBeUndefined();
|
||||
});
|
||||
it('doesn\'t yet allow editing a video block', () => {
|
||||
const usageKey = 'lb:org:beta:video:571fe018-f3ce-45c9-8f53-5dafcb422fdd';
|
||||
expect(getEditUrl(usageKey)).toBeUndefined();
|
||||
});
|
||||
it('doesn\'t yet allow editing a drag-and-drop-v2 block', () => {
|
||||
const usageKey = 'lb:org:beta:drag-and-drop-v2:571fe018-f3ce-45c9-8f53-5dafcb422fdd';
|
||||
expect(getEditUrl(usageKey)).toBeUndefined();
|
||||
});
|
||||
it('returns undefined for an invalid key', () => {
|
||||
expect(getEditUrl('foobar')).toBeUndefined();
|
||||
expect(getEditUrl('')).toBeUndefined();
|
||||
expect(getEditUrl('lb:unknown:unknown:unknown')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
47
src/library-authoring/components/utils.ts
Normal file
47
src/library-authoring/components/utils.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Given a usage key like `lb:org:lib:html:id`, get the type (e.g. `html`)
|
||||
* @param usageKey e.g. `lb:org:lib:html:id`
|
||||
* @returns The block type as a string
|
||||
*/
|
||||
export function getBlockType(usageKey: string): string {
|
||||
if (usageKey && usageKey.startsWith('lb:')) {
|
||||
const blockType = usageKey.split(':')[3];
|
||||
if (blockType) {
|
||||
return blockType;
|
||||
}
|
||||
}
|
||||
throw new Error(`Invalid usageKey: ${usageKey}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a usage key like `lb:org:lib:html:id`, get the library key
|
||||
* @param usageKey e.g. `lb:org:lib:html:id`
|
||||
* @returns The library key, e.g. `lib:org:lib`
|
||||
*/
|
||||
export function getLibraryId(usageKey: string): string {
|
||||
if (usageKey && usageKey.startsWith('lb:')) {
|
||||
const org = usageKey.split(':')[1];
|
||||
const lib = usageKey.split(':')[2];
|
||||
if (org && lib) {
|
||||
return `lib:${org}:${lib}`;
|
||||
}
|
||||
}
|
||||
throw new Error(`Invalid usageKey: ${usageKey}`);
|
||||
}
|
||||
|
||||
export function getEditUrl(usageKey: string): string | undefined {
|
||||
let blockType: string;
|
||||
let libraryId: string;
|
||||
try {
|
||||
blockType = getBlockType(usageKey);
|
||||
libraryId = getLibraryId(usageKey);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const mfeEditorTypes = ['html'];
|
||||
if (mfeEditorTypes.includes(blockType)) {
|
||||
return `/library/${libraryId}/editor/${blockType}/${usageKey}`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -104,7 +104,42 @@ mockContentLibrary.libraryIdReadOnly = 'lib:Axim:readOnly';
|
||||
mockContentLibrary.libraryIdThatNeverLoads = 'lib:Axim:infiniteLoading';
|
||||
mockContentLibrary.library404 = 'lib:Axim:error404';
|
||||
mockContentLibrary.library500 = 'lib:Axim:error500';
|
||||
mockContentLibrary.applyMock = () => { jest.spyOn(api, 'getContentLibrary').mockImplementation(mockContentLibrary); };
|
||||
mockContentLibrary.applyMock = () => jest.spyOn(api, 'getContentLibrary').mockImplementation(mockContentLibrary);
|
||||
|
||||
/**
|
||||
* Mock for `createLibraryBlock()`
|
||||
*/
|
||||
export async function mockCreateLibraryBlock(
|
||||
args: api.CreateBlockDataRequest,
|
||||
): ReturnType<typeof api.createLibraryBlock> {
|
||||
if (args.blockType === 'html' && args.libraryId === mockContentLibrary.libraryId) {
|
||||
return mockCreateLibraryBlock.newHtmlData;
|
||||
}
|
||||
if (args.blockType === 'problem' && args.libraryId === mockContentLibrary.libraryId) {
|
||||
return mockCreateLibraryBlock.newProblemData;
|
||||
}
|
||||
throw new Error(`mockCreateLibraryBlock doesn't know how to mock ${JSON.stringify(args)}`);
|
||||
}
|
||||
mockCreateLibraryBlock.newHtmlData = {
|
||||
id: 'lb:Axim:TEST:html:123',
|
||||
defKey: '123',
|
||||
blockType: 'html',
|
||||
displayName: 'New Text Component',
|
||||
hasUnpublishedChanges: true,
|
||||
tagsCount: 0,
|
||||
} satisfies api.CreateBlockDataResponse;
|
||||
mockCreateLibraryBlock.newProblemData = {
|
||||
id: 'lb:Axim:TEST:problem:prob1',
|
||||
defKey: 'prob1',
|
||||
blockType: 'problem',
|
||||
displayName: 'New Problem',
|
||||
hasUnpublishedChanges: true,
|
||||
tagsCount: 0,
|
||||
} satisfies api.CreateBlockDataResponse;
|
||||
/** Apply this mock. Returns a spy object that can tell you if it's been called. */
|
||||
mockCreateLibraryBlock.applyMock = () => (
|
||||
jest.spyOn(api, 'createLibraryBlock').mockImplementation(mockCreateLibraryBlock)
|
||||
);
|
||||
|
||||
/**
|
||||
* Mock for `getXBlockFields()`
|
||||
@@ -117,13 +152,23 @@ export async function mockXBlockFields(usageKey: string): Promise<api.XBlockFiel
|
||||
const thisMock = mockXBlockFields;
|
||||
switch (usageKey) {
|
||||
case thisMock.usageKeyHtml: return thisMock.dataHtml;
|
||||
case thisMock.usageKeyNewHtml: return thisMock.dataNewHtml;
|
||||
default: throw new Error(`No mock has been set up for usageKey "${usageKey}"`);
|
||||
}
|
||||
}
|
||||
// Mock of a "regular" HTML (Text) block:
|
||||
mockXBlockFields.usageKeyHtml = 'lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd';
|
||||
mockXBlockFields.dataHtml = {
|
||||
displayName: 'Introduction to Testing',
|
||||
data: '<p>This is a text component which uses <strong>HTML</strong>.</p>',
|
||||
metadata: { displayName: 'Introduction to Testing' },
|
||||
} satisfies api.XBlockFields;
|
||||
mockXBlockFields.applyMock = () => { jest.spyOn(api, 'getXBlockFields').mockImplementation(mockXBlockFields); };
|
||||
// Mock of a blank/new HTML (Text) block:
|
||||
mockXBlockFields.usageKeyNewHtml = 'lb:Axim:TEST:html:123';
|
||||
mockXBlockFields.dataNewHtml = {
|
||||
displayName: 'New Text Component',
|
||||
data: '',
|
||||
metadata: { displayName: 'New Text Component' },
|
||||
} satisfies api.XBlockFields;
|
||||
/** Apply this mock. Returns a spy object that can tell you if it's been called. */
|
||||
mockXBlockFields.applyMock = () => jest.spyOn(api, 'getXBlockFields').mockImplementation(mockXBlockFields);
|
||||
|
||||
@@ -25,9 +25,13 @@ export const getCommitLibraryChangesUrl = (libraryId: string) => `${getApiBaseUr
|
||||
*/
|
||||
export const getLibraryPasteClipboardUrl = (libraryId: string) => `${getApiBaseUrl()}/api/libraries/v2/${libraryId}/paste_clipboard/`;
|
||||
/**
|
||||
* Get the URL for the xblock metadata API.
|
||||
* Get the URL for the xblock fields/metadata API.
|
||||
*/
|
||||
export const getXBlockFieldsApiUrl = (usageKey: string) => `${getApiBaseUrl()}/api/xblock/v2/xblocks/${usageKey}/fields/`;
|
||||
/**
|
||||
* Get the URL for the xblock OLX API
|
||||
*/
|
||||
export const getXBlockOLXApiUrl = (usageKey: string) => `${getApiBaseUrl()}/api/libraries/v2/blocks/${usageKey}/olx/`;
|
||||
|
||||
export interface ContentLibrary {
|
||||
id: string;
|
||||
@@ -156,7 +160,7 @@ export async function createLibraryBlock({
|
||||
definition_id: definitionId,
|
||||
},
|
||||
);
|
||||
return data;
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -236,3 +240,11 @@ export async function updateXBlockFields(usageKey:string, xblockData: UpdateXBlo
|
||||
const client = getAuthenticatedHttpClient();
|
||||
await client.post(getXBlockFieldsApiUrl(usageKey), xblockData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the OLX for the given XBlock.
|
||||
*/
|
||||
export async function getXBlockOLX(usageKey: string): Promise<string> {
|
||||
const { data } = await getAuthenticatedHttpClient().get(getXBlockOLXApiUrl(usageKey));
|
||||
return data.olx;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { camelCaseObject } from '@edx/frontend-platform';
|
||||
import {
|
||||
useQuery, useMutation, useQueryClient, type Query,
|
||||
useQuery,
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
type Query,
|
||||
type QueryClient,
|
||||
} from '@tanstack/react-query';
|
||||
|
||||
import { getLibraryId } from '../components/utils';
|
||||
import {
|
||||
type GetLibrariesV2CustomParams,
|
||||
type ContentLibrary,
|
||||
@@ -18,6 +23,7 @@ import {
|
||||
libraryPasteClipboard,
|
||||
getXBlockFields,
|
||||
updateXBlockFields,
|
||||
getXBlockOLX,
|
||||
} from './api';
|
||||
|
||||
const libraryQueryPredicate = (query: Query, libraryId: string): boolean => {
|
||||
@@ -52,15 +58,36 @@ export const libraryAuthoringQueryKeys = {
|
||||
'content',
|
||||
'libraryBlockTypes',
|
||||
],
|
||||
xblockFields: (contentLibraryId: string, usageKey: string) => [
|
||||
...libraryAuthoringQueryKeys.all,
|
||||
...libraryAuthoringQueryKeys.contentLibrary(contentLibraryId),
|
||||
'content',
|
||||
'xblockFields',
|
||||
usageKey,
|
||||
],
|
||||
};
|
||||
|
||||
export const xblockQueryKeys = {
|
||||
all: ['xblock'],
|
||||
/**
|
||||
* Base key for data specific to a xblock
|
||||
*/
|
||||
xblock: (usageKey?: string) => [...xblockQueryKeys.all, usageKey],
|
||||
/** Fields (i.e. the content, display name, etc.) of an XBlock */
|
||||
xblockFields: (usageKey: string) => [...xblockQueryKeys.xblock(usageKey), 'fields'],
|
||||
/** OLX (XML representation of the fields/content) */
|
||||
xblockOLX: (usageKey: string) => [...xblockQueryKeys.xblock(usageKey), 'OLX'],
|
||||
};
|
||||
|
||||
/**
|
||||
* Tell react-query to refresh its cache of any data related to the given
|
||||
* component (XBlock).
|
||||
*
|
||||
* Note that technically it's possible to derive the library key from the
|
||||
* usageKey, so we could refactor this to only require the usageKey.
|
||||
*
|
||||
* @param queryClient The query client - get it via useQueryClient()
|
||||
* @param contentLibraryId The ID of library that holds the XBlock ("lib:...")
|
||||
* @param usageKey The usage ID of the XBlock ("lb:...")
|
||||
*/
|
||||
export function invalidateComponentData(queryClient: QueryClient, contentLibraryId: string, usageKey: string) {
|
||||
queryClient.invalidateQueries({ queryKey: xblockQueryKeys.xblockFields(usageKey) });
|
||||
queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, contentLibraryId) });
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch a content library by its ID.
|
||||
*/
|
||||
@@ -168,20 +195,21 @@ export const useLibraryPasteClipboard = () => {
|
||||
});
|
||||
};
|
||||
|
||||
export const useXBlockFields = (contentLibrayId: string, usageKey: string) => (
|
||||
export const useXBlockFields = (usageKey: string) => (
|
||||
useQuery({
|
||||
queryKey: libraryAuthoringQueryKeys.xblockFields(contentLibrayId, usageKey),
|
||||
queryKey: xblockQueryKeys.xblockFields(usageKey),
|
||||
queryFn: () => getXBlockFields(usageKey),
|
||||
enabled: !!usageKey,
|
||||
})
|
||||
);
|
||||
|
||||
export const useUpdateXBlockFields = (contentLibraryId: string, usageKey: string) => {
|
||||
export const useUpdateXBlockFields = (usageKey: string) => {
|
||||
const contentLibraryId = getLibraryId(usageKey);
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: UpdateXBlockFieldsRequest) => updateXBlockFields(usageKey, data),
|
||||
onMutate: async (data) => {
|
||||
const queryKey = libraryAuthoringQueryKeys.xblockFields(contentLibraryId, usageKey);
|
||||
const queryKey = xblockQueryKeys.xblockFields(usageKey);
|
||||
const previousBlockData = queryClient.getQueriesData(queryKey)[0][1] as XBlockFields;
|
||||
const formatedData = camelCaseObject(data);
|
||||
|
||||
@@ -200,13 +228,21 @@ export const useUpdateXBlockFields = (contentLibraryId: string, usageKey: string
|
||||
},
|
||||
onError: (_err, _data, context) => {
|
||||
queryClient.setQueryData(
|
||||
libraryAuthoringQueryKeys.xblockFields(contentLibraryId, usageKey),
|
||||
xblockQueryKeys.xblockFields(usageKey),
|
||||
context?.previousBlockData,
|
||||
);
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.xblockFields(contentLibraryId, usageKey) });
|
||||
queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, contentLibraryId) });
|
||||
invalidateComponentData(queryClient, contentLibraryId, usageKey);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/* istanbul ignore next */ // This is only used in developer builds, and the associated UI doesn't work in test or prod
|
||||
export const useXBlockOLX = (usageKey: string) => (
|
||||
useQuery({
|
||||
queryKey: xblockQueryKeys.xblockOLX(usageKey),
|
||||
queryFn: () => getXBlockOLX(usageKey),
|
||||
enabled: !!usageKey,
|
||||
})
|
||||
);
|
||||
|
||||
42
src/search-manager/data/api.mock.ts
Normal file
42
src/search-manager/data/api.mock.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/* istanbul ignore file */
|
||||
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||
import fetchMock from 'fetch-mock-jest';
|
||||
import type { MultiSearchResponse } from 'meilisearch';
|
||||
import * as api from './api';
|
||||
|
||||
/**
|
||||
* Mock getContentSearchConfig()
|
||||
*/
|
||||
export async function mockContentSearchConfig(): ReturnType<typeof api.getContentSearchConfig> {
|
||||
return {
|
||||
url: 'http://mock.meilisearch.local',
|
||||
indexName: 'studio',
|
||||
apiKey: 'test-key',
|
||||
};
|
||||
}
|
||||
mockContentSearchConfig.searchEndpointUrl = 'http://mock.meilisearch.local/multi-search';
|
||||
mockContentSearchConfig.applyMock = () => (
|
||||
jest.spyOn(api, 'getContentSearchConfig').mockImplementation(mockContentSearchConfig)
|
||||
);
|
||||
|
||||
/**
|
||||
* Mock all future Meilisearch searches with the given response.
|
||||
*
|
||||
* For a given test suite, this mock will stay in effect until you call it with
|
||||
* a different mock response, or you call `fetchMock.mockReset()`
|
||||
*/
|
||||
export function mockSearchResult(mockResponse: MultiSearchResponse) {
|
||||
fetchMock.post(mockContentSearchConfig.searchEndpointUrl, (_url, req) => {
|
||||
const requestData = JSON.parse(req.body?.toString() ?? '');
|
||||
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.
|
||||
const newMockResponse = { ...mockResponse };
|
||||
newMockResponse.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
|
||||
mockResponse.results[0]?.hits.forEach((hit) => { hit._formatted = { ...hit }; });
|
||||
return newMockResponse;
|
||||
}, { overwriteRoutes: true });
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
import React from 'react';
|
||||
import { AxiosError } from 'axios';
|
||||
import { jest } from '@jest/globals';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
Routes,
|
||||
} from 'react-router-dom';
|
||||
|
||||
import { ToastContext, type ToastContextData } from './generic/toast-context';
|
||||
import initializeReduxStore from './store';
|
||||
|
||||
/** @deprecated Use React Query and/or regular React Context instead of redux */
|
||||
@@ -27,6 +29,13 @@ let reduxStore;
|
||||
let queryClient;
|
||||
let axiosMock: MockAdapter;
|
||||
|
||||
/** To use this: `const { mockShowToast } = initializeMocks()` and `expect(mockShowToast).toHaveBeenCalled()` */
|
||||
let mockToastContext: ToastContextData = {
|
||||
showToast: jest.fn(),
|
||||
closeToast: jest.fn(),
|
||||
toastMessage: null,
|
||||
};
|
||||
|
||||
export interface RouteOptions {
|
||||
/** The URL path, like '/libraries/:libraryId' */
|
||||
path?: string;
|
||||
@@ -106,9 +115,11 @@ function makeWrapper({ ...routeArgs }: RouteOptions) {
|
||||
<AppProvider store={reduxStore} wrapWithRouter={false}>
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterAndRoute {...routeArgs}>
|
||||
{children}
|
||||
</RouterAndRoute>
|
||||
<ToastContext.Provider value={mockToastContext}>
|
||||
<RouterAndRoute {...routeArgs}>
|
||||
{children}
|
||||
</RouterAndRoute>
|
||||
</ToastContext.Provider>
|
||||
</QueryClientProvider>
|
||||
</IntlProvider>
|
||||
</AppProvider>
|
||||
@@ -152,9 +163,17 @@ export function initializeMocks({ user = defaultUser } = {}) {
|
||||
});
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
|
||||
// Reset `mockToastContext` for this current test
|
||||
mockToastContext = {
|
||||
showToast: jest.fn(),
|
||||
closeToast: jest.fn(),
|
||||
toastMessage: null,
|
||||
};
|
||||
|
||||
return {
|
||||
reduxStore,
|
||||
axiosMock,
|
||||
mockShowToast: mockToastContext.showToast,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user