feat: Open Editors in a Modal (library components only) [FC-0062] (#1357)

* feat: allow opening editors in modals

* refactor: add an EditorContext

* test: update tests accordingly

* test: make testUtils call clearAllMocks() automatically :)
This commit is contained in:
Braden MacDonald
2024-10-07 19:04:49 -07:00
committed by GitHub
parent 83322e2052
commit 8c125df9aa
51 changed files with 845 additions and 1220 deletions

View File

@@ -55,10 +55,6 @@ describe('<ContentTagsDrawer />', () => {
initializeMocks();
});
afterEach(() => {
jest.clearAllMocks();
});
it('should render page and page title correctly', () => {
renderDrawer(stagedTagsId);
expect(screen.getByText('Manage tags')).toBeInTheDocument();

View File

@@ -1,55 +0,0 @@
import 'CourseAuthoring/editors/setupEditorTest';
import React from 'react';
import { useDispatch } from 'react-redux';
import { shallow } from '@edx/react-unit-test-utils';
import Editor from './Editor';
import supportedEditors from './supportedEditors';
import * as hooks from './hooks';
import { blockTypes } from './data/constants/app';
jest.mock('./hooks', () => ({
initializeApp: jest.fn(),
}));
jest.mock('./containers/TextEditor', () => 'TextEditor');
jest.mock('./containers/VideoEditor', () => 'VideoEditor');
jest.mock('./containers/ProblemEditor', () => 'ProblemEditor');
const initData = {
blockId: 'block-v1:edX+DemoX+Demo_Course+type@html+block@030e35c4756a4ddc8d40b95fbbfff4d4',
blockType: blockTypes.html,
learningContextId: 'course-v1:edX+DemoX+Demo_Course',
lmsEndpointUrl: 'evenfakerurl.com',
studioEndpointUrl: 'fakeurl.com',
};
const props = {
initialize: jest.fn(),
onClose: jest.fn().mockName('props.onClose'),
courseId: 'course-v1:edX+DemoX+Demo_Course',
...initData,
};
let el;
describe('Editor', () => {
describe('render', () => {
test('snapshot: renders correct editor given blockType (html -> TextEditor)', () => {
expect(shallow(<Editor {...props} />).snapshot).toMatchSnapshot();
});
test('presents error message if no relevant editor found and ref ready', () => {
expect(shallow(<Editor {...props} blockType="fAkEBlock" />).snapshot).toMatchSnapshot();
});
test.each(Object.values(blockTypes))('renders %p editor when ref is ready', (blockType) => {
el = shallow(<Editor {...props} blockType={blockType} />);
expect(el.shallowWrapper.props.children.props.children.type).toBe(supportedEditors[blockType]);
});
});
describe('behavior', () => {
it('calls initializeApp hook with dispatch, and passed data', () => {
el = shallow(<Editor {...props} blockType={blockTypes.html} />);
expect(hooks.initializeApp).toHaveBeenCalledWith({
dispatch: useDispatch(),
data: initData,
});
});
});
});

View File

@@ -1,3 +1,5 @@
// Note: there is no Editor.test.tsx. This component only works together with
// <EditorPage> as its parent, so they are tested together in EditorPage.test.tsx
import React from 'react';
import { useDispatch } from 'react-redux';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
@@ -7,6 +9,7 @@ import * as hooks from './hooks';
import supportedEditors from './supportedEditors';
import type { EditorComponent } from './EditorComponent';
import { useEditorContext } from './EditorContext';
export interface Props extends EditorComponent {
blockType: string;
@@ -14,6 +17,7 @@ export interface Props extends EditorComponent {
learningContextId: string | null;
lmsEndpointUrl: string | null;
studioEndpointUrl: string | null;
fullScreen?: boolean;
}
const Editor: React.FC<Props> = ({
@@ -36,23 +40,29 @@ const Editor: React.FC<Props> = ({
studioEndpointUrl,
},
});
const { fullScreen } = useEditorContext();
const EditorComponent = supportedEditors[blockType];
return (
<div
className="d-flex flex-column"
>
const innerEditor = (EditorComponent !== undefined)
? <EditorComponent {...{ onClose, returnFunction }} />
: <FormattedMessage {...messages.couldNotFindEditor} />;
if (fullScreen) {
return (
<div
className="pgn__modal-fullscreen h-100"
role="dialog"
aria-label={blockType}
className="d-flex flex-column"
>
{(EditorComponent !== undefined)
? <EditorComponent {...{ onClose, returnFunction }} />
: <FormattedMessage {...messages.couldNotFindEditor} />}
<div
className="pgn__modal-fullscreen h-100"
role="dialog"
aria-label={blockType}
>
{innerEditor}
</div>
</div>
</div>
);
);
}
return innerEditor;
};
export default Editor;

View File

@@ -0,0 +1,39 @@
import React from 'react';
/**
* Shared context that's used by all our editors.
*
* Note: we're in the process of moving things from redux into this.
*/
export interface EditorContext {
learningContextId: string;
/**
* When editing components in the libraries part of the Authoring MFE, we show
* the editors in a modal (fullScreen = false). This is the preferred approach
* so that authors can see context behind the modal.
* However, when making edits from the legacy course view, we display the
* editors in a fullscreen view. This approach is deprecated.
*/
fullScreen: boolean;
}
const context = React.createContext<EditorContext | undefined>(undefined);
/** Hook to get the editor context (shared context) */
export function useEditorContext() {
const ctx = React.useContext(context);
if (ctx === undefined) {
/* istanbul ignore next */
throw new Error('This component needs to be wrapped in <EditorContextProvider>');
}
return ctx;
}
export const EditorContextProvider: React.FC<{
children: React.ReactNode,
learningContextId: string;
fullScreen: boolean;
}> = ({ children, ...contextData }) => {
const ctx: EditorContext = React.useMemo(() => ({ ...contextData }), []);
return <context.Provider value={ctx}>{children}</context.Provider>;
};

View File

@@ -1,58 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Provider } from 'react-redux';
import store from './data/store';
import Editor from './Editor';
import ErrorBoundary from './sharedComponents/ErrorBoundary';
const EditorPage = ({
courseId,
blockType,
blockId,
lmsEndpointUrl,
studioEndpointUrl,
onClose,
returnFunction,
}) => (
<Provider store={store}>
<ErrorBoundary
{...{
learningContextId: courseId,
studioEndpointUrl,
}}
>
<Editor
{...{
onClose,
learningContextId: courseId,
blockType,
blockId,
lmsEndpointUrl,
studioEndpointUrl,
returnFunction,
}}
/>
</ErrorBoundary>
</Provider>
);
EditorPage.defaultProps = {
blockId: null,
courseId: null,
lmsEndpointUrl: null,
onClose: null,
returnFunction: null,
studioEndpointUrl: null,
};
EditorPage.propTypes = {
blockId: PropTypes.string,
blockType: PropTypes.string.isRequired,
courseId: PropTypes.string,
lmsEndpointUrl: PropTypes.string,
onClose: PropTypes.func,
returnFunction: PropTypes.func,
studioEndpointUrl: PropTypes.string,
};
export default EditorPage;

View File

@@ -1,32 +0,0 @@
import React from 'react';
import { shallow } from '@edx/react-unit-test-utils';
import EditorPage from './EditorPage';
const props = {
courseId: 'course-v1:edX+DemoX+Demo_Course',
blockType: 'html',
blockId: 'block-v1:edX+DemoX+Demo_Course+type@html+block@030e35c4756a4ddc8d40b95fbbfff4d4',
lmsEndpointUrl: 'evenfakerurl.com',
studioEndpointUrl: 'fakeurl.com',
onClose: jest.fn().mockName('props.onClose'),
};
jest.mock('react-redux', () => ({
Provider: 'Provider',
connect: (mapStateToProps, mapDispatchToProps) => (component) => ({
mapStateToProps,
mapDispatchToProps,
component,
}),
}));
jest.mock('./Editor', () => 'Editor');
describe('Editor Page', () => {
describe('snapshots', () => {
test('rendering correctly with expected Input', () => {
expect(shallow(<EditorPage {...props} />).snapshot).toMatchSnapshot();
});
test('props besides blockType default to null', () => {
expect(shallow(<EditorPage blockType={props.blockType} />).snapshot).toMatchSnapshot();
});
});
});

View File

@@ -0,0 +1,98 @@
import { snakeCaseObject } from '@edx/frontend-platform';
import {
render,
screen,
initializeMocks,
} from '../testUtils';
import editorCmsApi from './data/services/cms/api';
import EditorPage from './EditorPage';
// Mock this plugins component:
jest.mock('frontend-components-tinymce-advanced-plugins', () => ({ a11ycheckerCss: '' }));
// Always mock out the "fetch course images" endpoint:
jest.spyOn(editorCmsApi, 'fetchImages').mockImplementation(async () => ( // eslint-disable-next-line
{ data: { assets: [], start: 0, end: 0, page: 0, pageSize: 50, totalCount: 0 } }
));
// Mock out the 'get ancestors' API:
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,
}],
},
}));
const defaultPropsHtml = {
blockId: 'block-v1:Org+TS100+24+type@html+block@123456html',
blockType: 'html',
courseId: 'course-v1:Org+TS100+24',
lmsEndpointUrl: 'http://lms.test.none/',
studioEndpointUrl: 'http://cms.test.none/',
onClose: jest.fn(),
fullScreen: false,
};
const fieldsHtml = {
displayName: 'Introduction to Testing',
data: '<p>This is a text component which uses <strong>HTML</strong>.</p>',
metadata: { displayName: 'Introduction to Testing' },
};
describe('EditorPage', () => {
beforeEach(() => {
initializeMocks();
});
test('it can display the Text (html) editor in a modal', async () => {
jest.spyOn(editorCmsApi, 'fetchBlockById').mockImplementationOnce(async () => (
{ status: 200, data: snakeCaseObject(fieldsHtml) }
));
render(<EditorPage {...defaultPropsHtml} />);
// Then the editor should open
expect(await screen.findByRole('heading', { name: /Introduction to Testing/ })).toBeInTheDocument();
const modalElement = screen.getByRole('dialog');
expect(modalElement.classList).toContain('pgn__modal');
expect(modalElement.classList).toContain('pgn__modal-xl');
expect(modalElement.classList).not.toContain('pgn__modal-fullscreen');
});
test('it can display the Text (html) editor as a full page (when coming from the legacy UI)', async () => {
jest.spyOn(editorCmsApi, 'fetchBlockById').mockImplementationOnce(async () => (
{ status: 200, data: snakeCaseObject(fieldsHtml) }
));
render(<EditorPage {...defaultPropsHtml} fullScreen />);
// Then the editor should open
expect(await screen.findByRole('heading', { name: /Introduction to Testing/ })).toBeInTheDocument();
const modalElement = screen.getByRole('dialog');
expect(modalElement.classList).toContain('pgn__modal-fullscreen');
expect(modalElement.classList).not.toContain('pgn__modal');
expect(modalElement.classList).not.toContain('pgn__modal-xl');
});
test('it shows an error message if there is no corresponding editor', async () => {
// We can edit 'html', 'problem', and 'video' blocks.
// But if we try to edit some other type, say 'fake', we should get an error:
jest.spyOn(editorCmsApi, 'fetchBlockById').mockImplementationOnce(async () => ( // eslint-disable-next-line
{ status: 200, data: { display_name: 'Fake Un-editable Block', category: 'fake', metadata: {}, data: '' } }
));
const defaultPropsFake = {
...defaultPropsHtml,
blockId: 'block-v1:Org+TS100+24+type@fake+block@123456fake',
blockType: 'fake',
};
render(<EditorPage {...defaultPropsFake} />);
expect(await screen.findByText('Error: Could Not find Editor')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,58 @@
import React from 'react';
import { Provider } from 'react-redux';
import store from './data/store';
import Editor from './Editor';
import ErrorBoundary from './sharedComponents/ErrorBoundary';
import { EditorComponent } from './EditorComponent';
import { EditorContextProvider } from './EditorContext';
interface Props extends EditorComponent {
blockId?: string;
blockType: string;
courseId: string;
lmsEndpointUrl?: string;
studioEndpointUrl?: string;
fullScreen?: boolean;
children?: never;
}
/**
* Wraps the editors with the redux state provider.
* TODO: refactor some of this to be React Context and React Query
*/
const EditorPage: React.FC<Props> = ({
courseId,
blockType,
blockId = null,
lmsEndpointUrl = null,
studioEndpointUrl = null,
onClose = null,
returnFunction = null,
fullScreen = true,
}) => (
<Provider store={store}>
<ErrorBoundary
{...{
learningContextId: courseId,
studioEndpointUrl,
}}
>
<EditorContextProvider fullScreen={fullScreen} learningContextId={courseId}>
<Editor
{...{
onClose,
learningContextId: courseId,
blockType,
blockId,
lmsEndpointUrl,
studioEndpointUrl,
returnFunction,
}}
/>
</EditorContextProvider>
</ErrorBoundary>
</Provider>
);
export default EditorPage;

View File

@@ -1,36 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Editor render presents error message if no relevant editor found and ref ready 1`] = `
<div
className="d-flex flex-column"
>
<div
aria-label="fAkEBlock"
className="pgn__modal-fullscreen h-100"
role="dialog"
>
<FormattedMessage
defaultMessage="Error: Could Not find Editor"
description="Error Message Dispayed When An unsopported Editor is desired in V2"
id="authoring.editorpage.selecteditor.error"
/>
</div>
</div>
`;
exports[`Editor render snapshot: renders correct editor given blockType (html -> TextEditor) 1`] = `
<div
className="d-flex flex-column"
>
<div
aria-label="html"
className="pgn__modal-fullscreen h-100"
role="dialog"
>
<TextEditor
onClose={[MockFunction props.onClose]}
returnFunction={null}
/>
</div>
</div>
`;

View File

@@ -1,59 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Editor Page snapshots props besides blockType default to null 1`] = `
<Provider
store={
{
"dispatch": [Function],
"getState": [Function],
"replaceReducer": [Function],
"subscribe": [Function],
Symbol(Symbol.observable): [Function],
}
}
>
<ErrorBoundary
learningContextId={null}
studioEndpointUrl={null}
>
<Editor
blockId={null}
blockType="html"
learningContextId={null}
lmsEndpointUrl={null}
onClose={null}
returnFunction={null}
studioEndpointUrl={null}
/>
</ErrorBoundary>
</Provider>
`;
exports[`Editor Page snapshots rendering correctly with expected Input 1`] = `
<Provider
store={
{
"dispatch": [Function],
"getState": [Function],
"replaceReducer": [Function],
"subscribe": [Function],
Symbol(Symbol.observable): [Function],
}
}
>
<ErrorBoundary
learningContextId="course-v1:edX+DemoX+Demo_Course"
studioEndpointUrl="fakeurl.com"
>
<Editor
blockId="block-v1:edX+DemoX+Demo_Course+type@html+block@030e35c4756a4ddc8d40b95fbbfff4d4"
blockType="html"
learningContextId="course-v1:edX+DemoX+Demo_Course"
lmsEndpointUrl="evenfakerurl.com"
onClose={[MockFunction props.onClose]}
returnFunction={null}
studioEndpointUrl="fakeurl.com"
/>
</ErrorBoundary>
</Provider>
`;

View File

@@ -1,161 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`EditorContainer component render snapshot: initialized. enable save and pass to header 1`] = `
<div
className="editor-container d-flex flex-column position-relative zindex-0"
style={
{
"minHeight": "100%",
}
}
>
<BaseModal
bodyStyle={null}
close={[MockFunction closeCancelConfirmModal]}
confirmAction={
<Button
onClick={[Function]}
variant="primary"
>
<FormattedMessage
defaultMessage="OK"
description="Label for OK button"
id="authoring.editorContainer.okButton.label"
/>
</Button>
}
footerAction={null}
headerComponent={null}
isFullscreenScroll={true}
isOpen={false}
size="md"
title="Exit the editor?"
>
<FormattedMessage
defaultMessage="Are you sure you want to exit the editor? Any unsaved changes will be lost."
description="Description text for modal confirming cancellation"
id="authoring.editorContainer.cancelConfirm.description"
/>
</BaseModal>
<ModalDialog.Header
className="shadow-sm zindex-10"
>
<div
className="d-flex flex-row justify-content-between"
>
<h2
className="h3 col pl-0"
>
<injectIntl(ShimmedIntlComponent)
isInitialized={true}
/>
</h2>
<IconButton
alt="Exit the editor"
iconAs="Icon"
onClick={[MockFunction openCancelConfirmModal]}
src={[MockFunction icons.Close]}
/>
</div>
</ModalDialog.Header>
<ModalDialog.Body
className="pb-0 mb-6"
>
<h1>
My test content
</h1>
</ModalDialog.Body>
<injectIntl(ShimmedIntlComponent)
disableSave={false}
onCancel={[MockFunction openCancelConfirmModal]}
onSave={
{
"handleSaveClicked": {
"dispatch": [MockFunction react-redux.dispatch],
"getContent": [MockFunction props.getContent],
"returnFunction": [MockFunction props.returnFunction],
"validateEntry": [MockFunction props.validateEntry],
},
}
}
/>
</div>
`;
exports[`EditorContainer component render snapshot: not initialized. disable save and pass to header 1`] = `
<div
className="editor-container d-flex flex-column position-relative zindex-0"
style={
{
"minHeight": "100%",
}
}
>
<BaseModal
bodyStyle={null}
close={[MockFunction closeCancelConfirmModal]}
confirmAction={
<Button
onClick={[Function]}
variant="primary"
>
<FormattedMessage
defaultMessage="OK"
description="Label for OK button"
id="authoring.editorContainer.okButton.label"
/>
</Button>
}
footerAction={null}
headerComponent={null}
isFullscreenScroll={true}
isOpen={false}
size="md"
title="Exit the editor?"
>
<FormattedMessage
defaultMessage="Are you sure you want to exit the editor? Any unsaved changes will be lost."
description="Description text for modal confirming cancellation"
id="authoring.editorContainer.cancelConfirm.description"
/>
</BaseModal>
<ModalDialog.Header
className="shadow-sm zindex-10"
>
<div
className="d-flex flex-row justify-content-between"
>
<h2
className="h3 col pl-0"
>
<injectIntl(ShimmedIntlComponent)
isInitialized={false}
/>
</h2>
<IconButton
alt="Exit the editor"
iconAs="Icon"
onClick={[MockFunction openCancelConfirmModal]}
src={[MockFunction icons.Close]}
/>
</div>
</ModalDialog.Header>
<ModalDialog.Body
className="pb-0 mb-6"
/>
<injectIntl(ShimmedIntlComponent)
disableSave={true}
onCancel={[MockFunction openCancelConfirmModal]}
onSave={
{
"handleSaveClicked": {
"dispatch": [MockFunction react-redux.dispatch],
"getContent": [MockFunction props.getContent],
"returnFunction": [MockFunction props.returnFunction],
"validateEntry": [MockFunction props.validateEntry],
},
}
}
/>
</div>
`;

View File

@@ -1,114 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`EditorFooter render snapshot: default args (disableSave: false, saveFailed: false) 1`] = `
<div
className="editor-footer fixed-bottom"
>
<ModalDialog.Footer
className="shadow-sm"
>
<ActionRow>
<Button
aria-label="Discard changes and return to learning context"
onClick={[MockFunction args.onCancel]}
variant="tertiary"
>
<FormattedMessage
defaultMessage="Cancel"
description="Label for cancel button"
id="authoring.editorfooter.cancelButton.label"
/>
</Button>
<Button
aria-label="Save changes and return to learning context"
disabled={false}
onClick={[MockFunction args.onSave]}
>
<FormattedMessage
defaultMessage="Save"
description="Label for Save button"
id="authoring.editorfooter.savebutton.label"
/>
</Button>
</ActionRow>
</ModalDialog.Footer>
</div>
`;
exports[`EditorFooter render snapshot: save disabled. Show button spinner 1`] = `
<div
className="editor-footer fixed-bottom"
>
<ModalDialog.Footer
className="shadow-sm"
>
<ActionRow>
<Button
aria-label="Discard changes and return to learning context"
onClick={[MockFunction args.onCancel]}
variant="tertiary"
>
<FormattedMessage
defaultMessage="Cancel"
description="Label for cancel button"
id="authoring.editorfooter.cancelButton.label"
/>
</Button>
<Button
aria-label="Save changes and return to learning context"
disabled={true}
onClick={[MockFunction args.onSave]}
>
<Spinner
animation="border"
className="mr-3"
/>
</Button>
</ActionRow>
</ModalDialog.Footer>
</div>
`;
exports[`EditorFooter render snapshot: save failed. Show error message 1`] = `
<div
className="editor-footer fixed-bottom"
>
<Toast
show={true}
>
<FormattedMessage
defaultMessage="Error: Content save failed. Please check recent changes and try again later."
description="Error message displayed when content fails to save."
id="authoring.editorfooter.save.error"
/>
</Toast>
<ModalDialog.Footer
className="shadow-sm"
>
<ActionRow>
<Button
aria-label="Discard changes and return to learning context"
onClick={[MockFunction args.onCancel]}
variant="tertiary"
>
<FormattedMessage
defaultMessage="Cancel"
description="Label for cancel button"
id="authoring.editorfooter.cancelButton.label"
/>
</Button>
<Button
aria-label="Save changes and return to learning context"
disabled={false}
onClick={[MockFunction args.onSave]}
>
<FormattedMessage
defaultMessage="Save"
description="Label for Save button"
id="authoring.editorfooter.savebutton.label"
/>
</Button>
</ActionRow>
</ModalDialog.Footer>
</div>
`;

View File

@@ -1,64 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import {
Spinner,
ActionRow,
Button,
ModalDialog,
Toast,
} from '@openedx/paragon';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import messages from './messages';
const EditorFooter = ({
clearSaveFailed,
disableSave,
onCancel,
onSave,
saveFailed,
// injected
intl,
}) => (
<div className="editor-footer fixed-bottom">
{saveFailed && (
<Toast show onClose={clearSaveFailed}>
<FormattedMessage {...messages.contentSaveFailed} />
</Toast>
)}
<ModalDialog.Footer className="shadow-sm">
<ActionRow>
<Button
aria-label={intl.formatMessage(messages.cancelButtonAriaLabel)}
variant="tertiary"
onClick={onCancel}
>
<FormattedMessage {...messages.cancelButtonLabel} />
</Button>
<Button
aria-label={intl.formatMessage(messages.saveButtonAriaLabel)}
onClick={onSave}
disabled={disableSave}
>
{disableSave
? <Spinner animation="border" className="mr-3" />
: <FormattedMessage {...messages.saveButtonLabel} />}
</Button>
</ActionRow>
</ModalDialog.Footer>
</div>
);
EditorFooter.propTypes = {
clearSaveFailed: PropTypes.func.isRequired,
disableSave: PropTypes.bool.isRequired,
onCancel: PropTypes.func.isRequired,
onSave: PropTypes.func.isRequired,
saveFailed: PropTypes.bool.isRequired,
// injected
intl: intlShape.isRequired,
};
export const EditorFooterInternal = EditorFooter; // For testing only
export default injectIntl(EditorFooter);

View File

@@ -1,33 +0,0 @@
import 'CourseAuthoring/editors/setupEditorTest';
import React from 'react';
import { shallow } from '@edx/react-unit-test-utils';
import { formatMessage } from '../../../../testUtils';
import { EditorFooterInternal as EditorFooter } from '.';
jest.mock('../../hooks', () => ({
nullMethod: jest.fn().mockName('hooks.nullMethod'),
}));
describe('EditorFooter', () => {
const props = {
intl: { formatMessage },
disableSave: false,
onCancel: jest.fn().mockName('args.onCancel'),
onSave: jest.fn().mockName('args.onSave'),
saveFailed: false,
};
describe('render', () => {
test('snapshot: default args (disableSave: false, saveFailed: false)', () => {
expect(shallow(<EditorFooter {...props} />).snapshot).toMatchSnapshot();
});
test('snapshot: save disabled. Show button spinner', () => {
expect(shallow(<EditorFooter {...props} disableSave />).snapshot).toMatchSnapshot();
});
test('snapshot: save failed. Show error message', () => {
expect(shallow(<EditorFooter {...props} saveFailed />).snapshot).toMatchSnapshot();
});
});
});

View File

@@ -1,32 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
contentSaveFailed: {
id: 'authoring.editorfooter.save.error',
defaultMessage: 'Error: Content save failed. Please check recent changes and try again later.',
description: 'Error message displayed when content fails to save.',
},
cancelButtonAriaLabel: {
id: 'authoring.editorfooter.cancelButton.ariaLabel',
defaultMessage: 'Discard changes and return to learning context',
description: 'Screen reader label for cancel button',
},
cancelButtonLabel: {
id: 'authoring.editorfooter.cancelButton.label',
defaultMessage: 'Cancel',
description: 'Label for cancel button',
},
saveButtonAriaLabel: {
id: 'authoring.editorfooter.savebutton.ariaLabel',
defaultMessage: 'Save changes and return to learning context',
description: 'Screen reader label for save button',
},
saveButtonLabel: {
id: 'authoring.editorfooter.savebutton.label',
defaultMessage: 'Save',
description: 'Label for Save button',
},
});
export default messages;

View File

@@ -118,6 +118,7 @@ describe('EditorContainer hooks', () => {
destination: reactRedux.useSelector(selectors.app.returnUrl),
analyticsEvent: analyticsEvt.editorCancelClick,
analytics: reactRedux.useSelector(selectors.app.analytics),
returnFunction: null,
}),
);
});

View File

@@ -6,11 +6,6 @@ import { RequestKeys } from '../../data/constants/requests';
import { selectors } from '../../data/redux';
import { StrictDict } from '../../utils';
import * as appHooks from '../../hooks';
// This 'module' self-import hack enables mocking during tests.
// See src/editors/decisions/0005-internal-editor-testability-decisions.md. The whole approach to how hooks are tested
// should be re-thought and cleaned up to avoid this pattern.
// eslint-disable-next-line import/no-self-import
import * as module from './hooks';
export const {
clearSaveError,
@@ -47,7 +42,7 @@ export const handleSaveClicked = ({
};
export const cancelConfirmModalToggle = () => {
const [isCancelConfirmOpen, setIsOpen] = module.state.isCancelConfirmModalOpen(false);
const [isCancelConfirmOpen, setIsOpen] = state.isCancelConfirmModalOpen(false);
return {
isCancelConfirmOpen,
openCancelConfirmModal: () => setIsOpen(true),
@@ -55,7 +50,13 @@ export const cancelConfirmModalToggle = () => {
};
};
export const handleCancel = ({ onClose, returnFunction }) => {
export const handleCancel = ({
onClose = null,
returnFunction = null,
}: {
onClose?: (() => void) | null;
returnFunction?: (() => (result: any) => void) | null;
}): ((result?: any) => void) => {
if (onClose) {
return onClose;
}

View File

@@ -1,104 +0,0 @@
import React from 'react';
import { useDispatch } from 'react-redux';
import PropTypes from 'prop-types';
import {
Icon, ModalDialog, IconButton, Button,
} from '@openedx/paragon';
import { Close } from '@openedx/paragon/icons';
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
import BaseModal from '../../sharedComponents/BaseModal';
import EditorFooter from './components/EditorFooter';
import TitleHeader from './components/TitleHeader';
import * as hooks from './hooks';
import messages from './messages';
import './index.scss';
const EditorContainer = ({
children,
getContent,
onClose,
validateEntry,
returnFunction,
// injected
intl,
}) => {
const dispatch = useDispatch();
const isInitialized = hooks.isInitialized();
const { isCancelConfirmOpen, openCancelConfirmModal, closeCancelConfirmModal } = hooks.cancelConfirmModalToggle();
const handleCancel = hooks.handleCancel({ onClose, returnFunction });
return (
<div
className="editor-container d-flex flex-column position-relative zindex-0"
style={{ minHeight: '100%' }}
>
<BaseModal
size="md"
confirmAction={(
<Button
variant="primary"
onClick={() => {
handleCancel();
if (returnFunction) {
closeCancelConfirmModal();
}
}}
>
<FormattedMessage {...messages.okButtonLabel} />
</Button>
)}
isOpen={isCancelConfirmOpen}
close={closeCancelConfirmModal}
title={intl.formatMessage(messages.cancelConfirmTitle)}
>
<FormattedMessage {...messages.cancelConfirmDescription} />
</BaseModal>
<ModalDialog.Header className="shadow-sm zindex-10">
<div className="d-flex flex-row justify-content-between">
<h2 className="h3 col pl-0">
<TitleHeader isInitialized={isInitialized} />
</h2>
<IconButton
src={Close}
iconAs={Icon}
onClick={openCancelConfirmModal}
alt={intl.formatMessage(messages.exitButtonAlt)}
/>
</div>
</ModalDialog.Header>
<ModalDialog.Body className="pb-0 mb-6">
{isInitialized && children}
</ModalDialog.Body>
<EditorFooter
clearSaveFailed={hooks.clearSaveError({ dispatch })}
disableSave={!isInitialized}
onCancel={openCancelConfirmModal}
onSave={hooks.handleSaveClicked({
dispatch,
getContent,
validateEntry,
returnFunction,
})}
saveFailed={hooks.saveFailed()}
/>
</div>
);
};
EditorContainer.defaultProps = {
onClose: null,
returnFunction: null,
validateEntry: null,
};
EditorContainer.propTypes = {
children: PropTypes.node.isRequired,
getContent: PropTypes.func.isRequired,
onClose: PropTypes.func,
returnFunction: PropTypes.func,
validateEntry: PropTypes.func,
// injected
intl: intlShape.isRequired,
};
export const EditorContainerInternal = EditorContainer; // For testing only
export default injectIntl(EditorContainer);

View File

@@ -1,68 +0,0 @@
import 'CourseAuthoring/editors/setupEditorTest';
import { shallow } from '@edx/react-unit-test-utils';
import { useDispatch } from 'react-redux';
import { EditorContainerInternal as EditorContainer } from '.';
import * as hooks from './hooks';
import { formatMessage } from '../../testUtils';
const props = {
getContent: jest.fn().mockName('props.getContent'),
onClose: jest.fn().mockName('props.onClose'),
validateEntry: jest.fn().mockName('props.validateEntry'),
returnFunction: jest.fn().mockName('props.returnFunction'),
// inject
intl: { formatMessage },
};
jest.mock('./hooks', () => ({
clearSaveError: jest.fn().mockName('hooks.clearSaveError'),
isInitialized: jest.fn().mockReturnValue(true),
handleCancel: (args) => ({ handleCancel: args }),
handleSaveClicked: (args) => ({ handleSaveClicked: args }),
saveFailed: jest.fn().mockName('hooks.saveFailed'),
cancelConfirmModalToggle: jest.fn(() => ({
isCancelConfirmOpen: false,
openCancelConfirmModal: jest.fn().mockName('openCancelConfirmModal'),
closeCancelConfirmModal: jest.fn().mockName('closeCancelConfirmModal'),
})),
}));
let el;
describe('EditorContainer component', () => {
describe('render', () => {
const testContent = (<h1>My test content</h1>);
test('snapshot: not initialized. disable save and pass to header', () => {
hooks.isInitialized.mockReturnValueOnce(false);
expect(
shallow(<EditorContainer {...props}>{testContent}</EditorContainer>).snapshot,
).toMatchSnapshot();
});
test('snapshot: initialized. enable save and pass to header', () => {
expect(
shallow(<EditorContainer {...props}>{testContent}</EditorContainer>).snapshot,
).toMatchSnapshot();
});
describe('behavior inspection', () => {
beforeEach(() => {
el = shallow(<EditorContainer {...props}>{testContent}</EditorContainer>);
});
test('save behavior is linked to footer onSave', () => {
const expected = hooks.handleSaveClicked({
dispatch: useDispatch(),
getContent: props.getContent,
validateEntry: props.validateEntry,
returnFunction: props.returnFunction,
});
expect(el.shallowWrapper.props.children[3]
.props.onSave).toEqual(expected);
});
test('behavior is linked to clearSaveError', () => {
const expected = hooks.clearSaveError({ dispatch: useDispatch() });
expect(el.shallowWrapper.props.children[3]
.props.clearSaveFailed).toEqual(expected);
});
});
});
});

View File

@@ -0,0 +1,97 @@
import { snakeCaseObject } from '@edx/frontend-platform';
import {
render,
screen,
initializeMocks,
fireEvent,
waitFor,
} from '../../../testUtils';
import editorCmsApi from '../../data/services/cms/api';
import EditorPage from '../../EditorPage';
// Mock this plugins component:
jest.mock('frontend-components-tinymce-advanced-plugins', () => ({ a11ycheckerCss: '' }));
// Always mock out the "fetch course images" endpoint:
jest.spyOn(editorCmsApi, 'fetchImages').mockImplementation(async () => ( // eslint-disable-next-line
{ data: { assets: [], start: 0, end: 0, page: 0, pageSize: 50, totalCount: 0 } }
));
// Mock out the 'get ancestors' API:
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,
}],
},
}));
const defaultPropsHtml = {
blockId: 'block-v1:Org+TS100+24+type@html+block@123456html',
blockType: 'html',
courseId: 'course-v1:Org+TS100+24',
lmsEndpointUrl: 'http://lms.test.none/',
studioEndpointUrl: 'http://cms.test.none/',
onClose: jest.fn(),
fullScreen: false,
};
const fieldsHtml = {
displayName: 'Introduction to Testing',
data: '<p>This is a text component which uses <strong>HTML</strong>.</p>',
metadata: { displayName: 'Introduction to Testing' },
};
describe('EditorContainer', () => {
beforeEach(() => {
initializeMocks();
});
test('it displays a confirmation dialog when closing the editor modal', async () => {
jest.spyOn(editorCmsApi, 'fetchBlockById').mockImplementationOnce(async () => (
{ status: 200, data: snakeCaseObject(fieldsHtml) }
));
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);
// Now we should see the confirmation message:
expect(await screen.findByText(confirmMessage)).toBeInTheDocument();
expect(defaultPropsHtml.onClose).not.toHaveBeenCalled();
// And can confirm the cancelation:
const confirmButton = await screen.findByRole('button', { name: 'OK' });
fireEvent.click(confirmButton);
expect(defaultPropsHtml.onClose).toHaveBeenCalled();
});
test('it disables the save button until the fields have been loaded', async () => {
// Mock that loading the block data has begun but not completed yet:
let resolver: (result: { data: any }) => void;
jest.spyOn(editorCmsApi, 'fetchBlockById').mockImplementationOnce(() => new Promise((r) => { resolver = r; }));
render(<EditorPage {...defaultPropsHtml} />);
// Then the editor should open. The "Save" button should be disabled
const saveButton = await screen.findByRole('button', { name: /Save changes and return/ });
expect(saveButton).toBeDisabled();
// Now complete the loading of the data:
await waitFor(() => expect(resolver).not.toBeUndefined());
resolver!({ data: snakeCaseObject(fieldsHtml) });
// Now the save button should be active:
await waitFor(() => expect(saveButton).not.toBeDisabled());
});
});

View File

@@ -0,0 +1,158 @@
import React from 'react';
import { useDispatch } from 'react-redux';
import {
ActionRow,
Button,
Icon,
IconButton,
ModalDialog,
Spinner,
Toast,
} from '@openedx/paragon';
import { Close } from '@openedx/paragon/icons';
import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
import { EditorComponent } from '../../EditorComponent';
import { useEditorContext } from '../../EditorContext';
import BaseModal from '../../sharedComponents/BaseModal';
import TitleHeader from './components/TitleHeader';
import * as hooks from './hooks';
import messages from './messages';
import './index.scss';
interface WrapperProps {
children: React.ReactNode;
}
export const EditorModalWrapper: React.FC<WrapperProps & { onClose: () => void }> = ({ children, onClose }) => {
const { fullScreen } = useEditorContext();
const intl = useIntl();
if (fullScreen) {
return (
<div
className="editor-container d-flex flex-column position-relative zindex-0"
style={{ minHeight: '100%' }}
>
{children}
</div>
);
}
const title = intl.formatMessage(messages.modalTitle);
return (
<ModalDialog isOpen size="xl" isOverflowVisible={false} onClose={onClose} title={title}>{children}</ModalDialog>
);
};
export const EditorModalBody: React.FC<WrapperProps> = ({ children }) => {
const { fullScreen } = useEditorContext();
return <ModalDialog.Body className={fullScreen ? 'pb-6' : 'pb-0'}>{ children }</ModalDialog.Body>;
};
export const FooterWrapper: React.FC<WrapperProps> = ({ children }) => {
const { fullScreen } = useEditorContext();
if (fullScreen) {
return <div className="editor-footer fixed-bottom">{children}</div>;
}
// eslint-disable-next-line react/jsx-no-useless-fragment
return <>{ children }</>;
};
interface Props extends EditorComponent {
children: React.ReactNode;
getContent: Function;
validateEntry?: Function | null;
}
const EditorContainer: React.FC<Props> = ({
children,
getContent,
onClose = null,
validateEntry = null,
returnFunction = null,
}) => {
const intl = useIntl();
const dispatch = useDispatch();
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({
dispatch,
getContent,
validateEntry,
returnFunction,
});
return (
<EditorModalWrapper onClose={openCancelConfirmModal}>
{saveFailed && (
<Toast show onClose={clearSaveFailed}>
<FormattedMessage {...messages.contentSaveFailed} />
</Toast>
)}
<BaseModal
size="md"
confirmAction={(
<Button
variant="primary"
onClick={() => {
handleCancel();
if (returnFunction) {
closeCancelConfirmModal();
}
}}
>
<FormattedMessage {...messages.okButtonLabel} />
</Button>
)}
isOpen={isCancelConfirmOpen}
close={closeCancelConfirmModal}
title={intl.formatMessage(messages.cancelConfirmTitle)}
>
<FormattedMessage {...messages.cancelConfirmDescription} />
</BaseModal>
<ModalDialog.Header className="shadow-sm zindex-10">
<div className="d-flex flex-row justify-content-between">
<h2 className="h3 col pl-0">
<TitleHeader isInitialized={isInitialized} />
</h2>
<IconButton
src={Close}
iconAs={Icon}
onClick={openCancelConfirmModal}
alt={intl.formatMessage(messages.exitButtonAlt)}
/>
</div>
</ModalDialog.Header>
<EditorModalBody>
{isInitialized && children}
</EditorModalBody>
<FooterWrapper>
<ModalDialog.Footer className="shadow-sm">
<ActionRow>
<Button
aria-label={intl.formatMessage(messages.cancelButtonAriaLabel)}
variant="tertiary"
onClick={openCancelConfirmModal}
>
<FormattedMessage {...messages.cancelButtonLabel} />
</Button>
<Button
aria-label={intl.formatMessage(messages.saveButtonAriaLabel)}
onClick={onSave}
disabled={disableSave}
>
{disableSave
? <Spinner animation="border" className="mr-3" />
: <FormattedMessage {...messages.saveButtonLabel} />}
</Button>
</ActionRow>
</ModalDialog.Footer>
</FooterWrapper>
</EditorModalWrapper>
);
};
export default EditorContainer;

View File

@@ -22,6 +22,36 @@ const messages = defineMessages({
defaultMessage: 'OK',
description: 'Label for OK button',
},
modalTitle: {
id: 'authoring.editorContainer.accessibleTitle',
defaultMessage: 'Editor Dialog',
description: 'Text that labels the the editor modal dialog for non-visual users',
},
contentSaveFailed: {
id: 'authoring.editorfooter.save.error',
defaultMessage: 'Error: Content save failed. Please check recent changes and try again later.',
description: 'Error message displayed when content fails to save.',
},
cancelButtonAriaLabel: {
id: 'authoring.editorfooter.cancelButton.ariaLabel',
defaultMessage: 'Discard changes and return to learning context',
description: 'Screen reader label for cancel button',
},
cancelButtonLabel: {
id: 'authoring.editorfooter.cancelButton.label',
defaultMessage: 'Cancel',
description: 'Label for cancel button',
},
saveButtonAriaLabel: {
id: 'authoring.editorfooter.savebutton.ariaLabel',
defaultMessage: 'Save changes and return to learning context',
description: 'Screen reader label for save button',
},
saveButtonLabel: {
id: 'authoring.editorfooter.savebutton.label',
defaultMessage: 'Save',
description: 'Label for Save button',
},
});
export default messages;

View File

@@ -1,7 +1,7 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`EditorProblemView component renders raw editor 1`] = `
<injectIntl(ShimmedIntlComponent)
<EditorContainer
getContent={[Function]}
returnFunction={null}
>
@@ -66,11 +66,11 @@ exports[`EditorProblemView component renders raw editor 1`] = `
/>
</span>
</div>
</injectIntl(ShimmedIntlComponent)>
</EditorContainer>
`;
exports[`EditorProblemView component renders simple view 1`] = `
<injectIntl(ShimmedIntlComponent)
<EditorContainer
getContent={[Function]}
returnFunction={null}
>
@@ -139,5 +139,5 @@ exports[`EditorProblemView component renders simple view 1`] = `
/>
</span>
</div>
</injectIntl(ShimmedIntlComponent)>
</EditorContainer>
`;

View File

@@ -1,63 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import {
ActionRow,
Button,
ModalDialog,
} from '@openedx/paragon';
import messages from './messages';
import * as hooks from '../hooks';
import { actions, selectors } from '../../../../../data/redux';
const SelectTypeFooter = ({
onCancel,
selected,
}) => {
const intl = useIntl();
const defaultSettings = useSelector(selectors.problem.defaultSettings);
const dispatch = useDispatch();
const updateField = React.useCallback((data) => dispatch(actions.problem.updateField(data)), [dispatch]);
const setBlockTitle = React.useCallback((title) => dispatch(actions.app.setBlockTitle(title)), [dispatch]);
return (
<div className="editor-footer fixed-bottom">
<ModalDialog.Footer className="border-top-0">
<ActionRow>
<ActionRow.Spacer />
<Button
aria-label={intl.formatMessage(messages.cancelButtonAriaLabel)}
variant="tertiary"
onClick={onCancel}
>
<FormattedMessage {...messages.cancelButtonLabel} />
</Button>
<Button
aria-label={intl.formatMessage(messages.selectButtonAriaLabel)}
onClick={hooks.onSelect({
selected,
updateField,
setBlockTitle,
defaultSettings,
})}
disabled={!selected}
>
<FormattedMessage {...messages.selectButtonLabel} />
</Button>
</ActionRow>
</ModalDialog.Footer>
</div>
);
};
SelectTypeFooter.defaultProps = {
selected: null,
};
SelectTypeFooter.propTypes = {
onCancel: PropTypes.func.isRequired,
selected: PropTypes.string,
};
export default SelectTypeFooter;

View File

@@ -1,45 +0,0 @@
import 'CourseAuthoring/editors/setupEditorTest';
import { shallow } from '@edx/react-unit-test-utils';
import { Button } from '@openedx/paragon';
import { formatMessage } from '../../../../../testUtils';
import SelectTypeFooter from './SelectTypeFooter';
import * as hooks from '../hooks';
jest.mock('../hooks', () => ({
onSelect: jest.fn().mockName('onSelect'),
}));
describe('SelectTypeFooter', () => {
const props = {
onCancel: jest.fn().mockName('onCancel'),
selected: null,
// redux
defaultSettings: {},
updateField: jest.fn().mockName('UpdateField'),
// inject
intl: { formatMessage },
};
test('snapshot', () => {
expect(shallow(<SelectTypeFooter {...props} />).snapshot).toMatchSnapshot();
});
describe('behavior', () => {
let el;
beforeEach(() => {
el = shallow(<SelectTypeFooter {...props} />);
});
test('close behavior is linked to modal onCancel', () => {
const expected = props.onCancel;
expect(el.instance.findByType(Button)[0].props.onClick)
.toEqual(expected);
});
test('select behavior is linked to modal onSelect', () => {
const expected = hooks.onSelect(props.selected, props.updateField);
const button = el.instance.findByType(Button);
expect(button[button.length - 1].props.onClick)
.toEqual(expected);
});
});
});

View File

@@ -1,36 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SelectTypeFooter snapshot 1`] = `
<div
className="editor-footer fixed-bottom"
>
<ModalDialog.Footer
className="border-top-0"
>
<ActionRow>
<ActionRow.Spacer />
<Button
aria-label="Cancel"
onClick={[MockFunction onCancel]}
variant="tertiary"
>
<FormattedMessage
defaultMessage="Cancel"
description="Label for cancel button."
id="authoring.problemeditor.selecttype.cancelButton.label"
/>
</Button>
<Button
aria-label="Select"
disabled={true}
>
<FormattedMessage
defaultMessage="Select"
description="Label for select button."
id="authoring.problemeditor.selecttype.selectButton.label"
/>
</Button>
</ActionRow>
</ModalDialog.Footer>
</div>
`;

View File

@@ -1,38 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SelectTypeWrapper snapshot 1`] = `
<div
className="position-relative zindex-0"
>
<ModalDialog.Header
className="shadow-sm zindex-10"
>
<ModalDialog.Title>
<FormattedMessage
defaultMessage="Select problem type"
description="Title for select problem type modal"
id="authoring.problemEditor.selectType.title"
/>
<div
className="pgn__modal-close-container"
>
<IconButton
alt="Exit the editor"
iconAs="Icon"
src={[MockFunction icons.Close]}
/>
</div>
</ModalDialog.Title>
</ModalDialog.Header>
<ModalDialog.Body
className="pb-6"
>
<h1>
test child
</h1>
</ModalDialog.Body>
<SelectTypeFooter
selected="iMAsElecTedValUE"
/>
</div>
`;

View File

@@ -0,0 +1,61 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SelectTypeWrapper snapshot 1`] = `
<EditorModalWrapper>
<ModalDialog.Header
className="shadow-sm zindex-10"
>
<ModalDialog.Title>
<FormattedMessage
defaultMessage="Select problem type"
description="Title for select problem type modal"
id="authoring.problemEditor.selectType.title"
/>
<div
className="pgn__modal-close-container"
>
<IconButton
alt="Exit the editor"
iconAs="Icon"
src={[MockFunction icons.Close]}
/>
</div>
</ModalDialog.Title>
</ModalDialog.Header>
<EditorModalBody>
<h1>
test child
</h1>
</EditorModalBody>
<FooterWrapper>
<ModalDialog.Footer
className="border-top-0"
>
<ActionRow>
<ActionRow.Spacer />
<Button
aria-label="Cancel"
variant="tertiary"
>
<FormattedMessage
defaultMessage="Cancel"
description="Label for cancel button."
id="authoring.problemeditor.selecttype.cancelButton.label"
/>
</Button>
<Button
aria-label="Select"
disabled={false}
onClick={[Function]}
>
<FormattedMessage
defaultMessage="Select"
description="Label for select button."
id="authoring.problemeditor.selecttype.selectButton.label"
/>
</Button>
</ActionRow>
</ModalDialog.Footer>
</FooterWrapper>
</EditorModalWrapper>
`;

View File

@@ -1,57 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import { Icon, ModalDialog, IconButton } from '@openedx/paragon';
import { Close } from '@openedx/paragon/icons';
import SelectTypeFooter from './SelectTypeFooter';
import * as hooks from '../../../../EditorContainer/hooks';
import ecMessages from '../../../../EditorContainer/messages';
import messages from './messages';
const SelectTypeWrapper = ({
children,
onClose,
selected,
}) => {
const handleCancel = hooks.handleCancel({ onClose });
const intl = useIntl();
return (
<div
className="position-relative zindex-0"
>
<ModalDialog.Header className="shadow-sm zindex-10">
<ModalDialog.Title>
<FormattedMessage {...messages.selectTypeTitle} />
<div className="pgn__modal-close-container">
<IconButton
src={Close}
iconAs={Icon}
onClick={handleCancel}
alt={intl.formatMessage(ecMessages.exitButtonAlt)}
/>
</div>
</ModalDialog.Title>
</ModalDialog.Header>
<ModalDialog.Body className="pb-6">
{children}
</ModalDialog.Body>
<SelectTypeFooter
selected={selected}
onCancel={handleCancel}
/>
</div>
);
};
SelectTypeWrapper.defaultProps = {
onClose: null,
};
SelectTypeWrapper.propTypes = {
selected: PropTypes.string.isRequired,
children: PropTypes.node.isRequired,
onClose: PropTypes.func,
};
export default SelectTypeWrapper;

View File

@@ -0,0 +1,85 @@
import React from 'react';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import { useDispatch, useSelector } from 'react-redux';
import {
ActionRow,
Button,
Icon,
ModalDialog,
IconButton,
} from '@openedx/paragon';
import { Close } from '@openedx/paragon/icons';
import { EditorModalBody, EditorModalWrapper, FooterWrapper } from '../../../../EditorContainer';
import { actions, selectors } from '../../../../../data/redux';
import * as containerHooks from '../../../../EditorContainer/hooks';
import * as hooks from '../hooks';
import ecMessages from '../../../../EditorContainer/messages';
import messages from './messages';
interface Props {
selected: string;
onClose: (() => void) | null;
}
const SelectTypeWrapper: React.FC<Props> = ({
children,
onClose = null,
selected,
}) => {
const handleCancel = containerHooks.handleCancel({ onClose });
const intl = useIntl();
const defaultSettings = useSelector(selectors.problem.defaultSettings);
const dispatch = useDispatch();
const updateField = React.useCallback((data) => dispatch(actions.problem.updateField(data)), [dispatch]);
const setBlockTitle = React.useCallback((title) => dispatch(actions.app.setBlockTitle(title)), [dispatch]);
return (
<EditorModalWrapper onClose={handleCancel}>
<ModalDialog.Header className="shadow-sm zindex-10">
<ModalDialog.Title>
<FormattedMessage {...messages.selectTypeTitle} />
<div className="pgn__modal-close-container">
<IconButton
src={Close}
iconAs={Icon}
onClick={handleCancel}
alt={intl.formatMessage(ecMessages.exitButtonAlt)}
/>
</div>
</ModalDialog.Title>
</ModalDialog.Header>
<EditorModalBody>
{children}
</EditorModalBody>
<FooterWrapper>
<ModalDialog.Footer className="border-top-0">
<ActionRow>
<ActionRow.Spacer />
<Button
aria-label={intl.formatMessage(messages.cancelButtonAriaLabel)}
variant="tertiary"
onClick={handleCancel}
>
<FormattedMessage {...messages.cancelButtonLabel} />
</Button>
<Button
aria-label={intl.formatMessage(messages.selectButtonAriaLabel)}
onClick={hooks.onSelect({
selected,
updateField,
setBlockTitle,
defaultSettings,
})}
disabled={!selected}
>
<FormattedMessage {...messages.selectButtonLabel} />
</Button>
</ActionRow>
</ModalDialog.Footer>
</FooterWrapper>
</EditorModalWrapper>
);
};
export default SelectTypeWrapper;

View File

@@ -6,6 +6,7 @@ import {
initializeMocks,
} from '../../../../../testUtils';
import editorStore from '../../../../data/store';
import { EditorContextProvider } from '../../../../EditorContext';
import * as hooks from './hooks';
import SelectTypeModal from '.';
@@ -18,7 +19,13 @@ describe('SelectTypeModal', () => {
const mockSelect = jest.fn();
jest.spyOn(hooks, 'onSelect').mockImplementation(mockSelect);
// This is a new-style test, unlike most of the old snapshot-based editor tests.
render(<Provider store={editorStore}><SelectTypeModal onClose={mockClose} /></Provider>);
render(
<EditorContextProvider fullScreen={false} learningContextId="course-v1:Org+COURSE+RUN">
<Provider store={editorStore}>
<SelectTypeModal onClose={mockClose} />
</Provider>
</EditorContextProvider>,
);
// First we see the menu of problem types:
expect(await screen.findByRole('button', { name: 'Numerical input' })).toBeInTheDocument();

View File

@@ -19,6 +19,21 @@ interface AssetResponse {
assets: Record<string, string>[]; // In the raw response here, these are NOT camel-cased yet.
}
type FieldsResponse = {
display_name: string; // In the raw response here, these are NOT camel-cased yet.
data: any;
metadata: Record<string, any>;
} & Record<string, any>; // In courses (but not in libraries), there are many other fields returned here.
interface AncestorsResponse {
ancestors: {
id: string;
display_name: string; // In the raw response here, these are NOT camel-cased yet.
category: string;
has_children: boolean;
}[];
}
export const loadImage = (imageData) => ({
...imageData,
dateAdded: new Date(imageData.dateAdded.replace(' at', '')).getTime(),
@@ -89,10 +104,11 @@ export const processLicense = (licenseType, licenseDetails) => {
};
export const apiMethods = {
fetchBlockById: ({ blockId, studioEndpointUrl }) => get(
fetchBlockById: ({ blockId, studioEndpointUrl }): Promise<{ data: FieldsResponse }> => get(
urls.block({ blockId, studioEndpointUrl }),
),
fetchByUnitId: ({ blockId, studioEndpointUrl }) => get(
/** A better name for this would be 'get ancestors of block' */
fetchByUnitId: ({ blockId, studioEndpointUrl }): Promise<{ data: AncestorsResponse }> => get(
urls.blockAncestor({ studioEndpointUrl, blockId }),
fetchByUnitIdOptions,
),

View File

@@ -4,11 +4,6 @@ import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import analyticsEvt from './data/constants/analyticsEvt';
import { actions, thunkActions } from './data/redux';
// This 'module' self-import hack enables mocking during tests.
// See src/editors/decisions/0005-internal-editor-testability-decisions.md. The whole approach to how hooks are tested
// should be re-thought and cleaned up to avoid this pattern.
// eslint-disable-next-line import/no-self-import
import * as module from './hooks';
import { RequestKeys } from './data/constants/requests';
// eslint-disable-next-line react-hooks/rules-of-hooks
@@ -17,7 +12,7 @@ export const initializeApp = ({ dispatch, data }) => useEffect(
[data],
);
export const navigateTo = (destination) => {
export const navigateTo = (destination: string | URL) => {
window.location.assign(destination);
};
@@ -34,7 +29,7 @@ export const navigateCallback = ({
returnFunction()(response);
return;
}
module.navigateTo(destination);
navigateTo(destination);
};
export const nullMethod = () => ({});
@@ -61,7 +56,7 @@ export const saveBlock = ({
if (attemptSave) {
dispatch(thunkActions.app.saveBlock(
content,
module.navigateCallback({
navigateCallback({
destination,
analyticsEvent: analyticsEvt.editorSaveClick,
analytics,

View File

@@ -80,6 +80,7 @@ describe('<LibraryAuthoringPage />', () => {
initializeMocks();
// The Meilisearch client-side API uses fetch, not Axios.
fetchMock.mockReset();
fetchMock.post(searchEndpoint, (_url, req) => {
const requestData = JSON.parse(req.body?.toString() ?? '');
const query = requestData?.queries[0]?.q ?? '';
@@ -94,11 +95,6 @@ describe('<LibraryAuthoringPage />', () => {
});
});
afterEach(() => {
jest.clearAllMocks();
fetchMock.mockReset();
});
const renderLibraryPage = async () => {
render(<LibraryLayout />, { path, params: { libraryId: mockContentLibrary.libraryId } });

View File

@@ -1,70 +1,26 @@
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 { CreateCollectionModal } from './create-collection';
import { invalidateComponentData } from './data/apiHooks';
import LibraryCollectionPage from './collections/LibraryCollectionPage';
import { ComponentEditorModal } from './components/ComponentEditorModal';
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((prevPath?: string) => {
if (prevPath) {
// Redirects back to the previous route like collection page or library page
navigate(prevPath);
} else {
// Go back to the library
navigate(`/library/${libraryId}`);
}
}, []);
const returnFunction = React.useCallback((prevPath?: string) => {
// When changes are cancelled, either onClose (goBack) or this returnFunction will be called.
// When changes are saved, this returnFunction is called.
goBack(prevPath);
return (args) => {
if (args === undefined) {
return; // Do nothing - the user cancelled the changes
}
const { id: usageKey } = args;
// invalidate any queries that involve this XBlock:
invalidateComponentData(queryClient, libraryId, usageKey);
};
}, [goBack]);
return (
<LibraryProvider libraryId={libraryId}>
<Routes>
{/*
TODO: we should be opening this editor as a modal, not making it a separate page/URL.
That will be a much nicer UX because users can just close the modal and be on the same page they were already
on, instead of always getting sent back to the library home.
*/}
<Route
path="editor/:blockType/:blockId?"
element={(
<PageWrap>
<EditorContainer learningContextId={libraryId} onClose={goBack} returnFunction={returnFunction} />
</PageWrap>
)}
/>
<Route
path="collection/:collectionId"
element={<LibraryCollectionPage />}
@@ -75,6 +31,7 @@ const LibraryLayout = () => {
/>
</Routes>
<CreateCollectionModal />
<ComponentEditorModal />
</LibraryProvider>
);
};

View File

@@ -16,14 +16,14 @@ import {
ContentPaste,
} from '@openedx/paragon/icons';
import { v4 as uuid4 } from 'uuid';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { 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, useUpdateCollectionComponents } from '../data/apiHooks';
import { getEditUrl } from '../components/utils';
import { useLibraryContext } from '../common/context';
import { canEditComponent } from '../components/ComponentEditorModal';
import messages from './messages';
@@ -61,9 +61,6 @@ const AddContentButton = ({ contentType, onCreateContent } : AddContentButtonPro
const AddContentContainer = () => {
const intl = useIntl();
const navigate = useNavigate();
const location = useLocation();
const currentPath = location.pathname;
const { libraryId, collectionId } = useParams();
const createBlockMutation = useCreateLibraryBlock();
const updateComponentsMutation = useUpdateCollectionComponents(libraryId, collectionId);
@@ -73,6 +70,7 @@ const AddContentContainer = () => {
const { showPasteXBlock } = useCopyToClipboard(canEdit);
const {
openCreateCollectionModal,
openComponentEditor,
} = useLibraryContext();
const collectionButtonData = {
@@ -151,14 +149,12 @@ const AddContentContainer = () => {
blockType,
definitionId: `${uuid4()}`,
}).then((data) => {
const editUrl = getEditUrl(data.id);
const hasEditor = canEditComponent(data.id);
updateComponentsMutation.mutateAsync([data.id]).catch(() => {
showToast(intl.formatMessage(messages.errorAssociateComponentMessage));
});
if (editUrl) {
// Pass currentPath in state so that we can come back to
// current page on save or cancel
navigate(editUrl, { state: { from: currentPath } });
if (hasEditor) {
openComponentEditor(data.id);
} else {
// We can't start editing this right away so just show a toast message:
showToast(intl.formatMessage(messages.successCreateMessage));

View File

@@ -34,8 +34,6 @@ describe('<CollectionDetails />', () => {
});
afterEach(() => {
jest.clearAllMocks();
axiosMock.restore();
fetchMock.mockReset();
});

View File

@@ -77,10 +77,6 @@ describe('<LibraryCollectionPage />', () => {
});
});
afterEach(() => {
jest.clearAllMocks();
});
const renderLibraryCollectionPage = async (collectionId?: string, libraryId?: string) => {
const libId = libraryId || mockContentLibrary.libraryId;
const colId = collectionId || mockCollection.collectionId;

View File

@@ -25,6 +25,11 @@ export interface LibraryContextData {
// Current collection
openCollectionInfoSidebar: (collectionId: string) => void;
currentCollectionId?: string;
// 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;
closeComponentEditor: () => void;
}
/**
@@ -45,6 +50,8 @@ export const LibraryProvider = (props: { children?: React.ReactNode, libraryId:
const [currentComponentUsageKey, setCurrentComponentUsageKey] = React.useState<string>();
const [currentCollectionId, setcurrentCollectionId] = React.useState<string>();
const [isCreateCollectionModalOpen, openCreateCollectionModal, closeCreateCollectionModal] = useToggle(false);
const [componentBeingEdited, openComponentEditor] = React.useState<string | undefined>();
const closeComponentEditor = React.useCallback(() => openComponentEditor(undefined), []);
const resetSidebar = React.useCallback(() => {
setCurrentComponentUsageKey(undefined);
@@ -91,6 +98,9 @@ export const LibraryProvider = (props: { children?: React.ReactNode, libraryId:
closeCreateCollectionModal,
openCollectionInfoSidebar,
currentCollectionId,
componentBeingEdited,
openComponentEditor,
closeComponentEditor,
}), [
props.libraryId,
sidebarBodyComponent,
@@ -104,6 +114,9 @@ export const LibraryProvider = (props: { children?: React.ReactNode, libraryId:
closeCreateCollectionModal,
openCollectionInfoSidebar,
currentCollectionId,
componentBeingEdited,
openComponentEditor,
closeComponentEditor,
]);
return (

View File

@@ -0,0 +1,63 @@
import {
initializeMocks,
render,
screen,
waitFor,
} from '../../testUtils';
import { mockContentLibrary, mockLibraryBlockMetadata } from '../data/api.mocks';
import { mockBroadcastChannel } from '../../generic/data/api.mock';
import { LibraryProvider } from '../common/context';
import ComponentInfo from './ComponentInfo';
mockBroadcastChannel();
mockContentLibrary.applyMock();
mockLibraryBlockMetadata.applyMock();
jest.mock('./ComponentPreview', () => ({
__esModule: true, // Required when mocking 'default' export
default: () => <div>Mocked preview</div>,
}));
jest.mock('./ComponentManagement', () => ({
__esModule: true, // Required when mocking 'default' export
default: () => <div>Mocked management tab</div>,
}));
const withLibraryId = (libraryId: string) => ({
extraWrapper: ({ children }: { children: React.ReactNode }) => (
<LibraryProvider libraryId={libraryId}>{children}</LibraryProvider>
),
});
describe('<ComponentInfo> Sidebar', () => {
it('should show a disabled "Edit" button when the component type is not editable', async () => {
initializeMocks();
render(
<ComponentInfo usageKey={mockLibraryBlockMetadata.usageKeyThirdPartyXBlock} />,
withLibraryId(mockContentLibrary.libraryId),
);
const editButton = await screen.findByRole('button', { name: /Edit component/ });
expect(editButton).toBeDisabled();
});
it('should show a disabled "Edit" button when the library is read-only', async () => {
initializeMocks();
render(
<ComponentInfo usageKey={mockLibraryBlockMetadata.usageKeyPublished} />,
withLibraryId(mockContentLibrary.libraryIdReadOnly),
);
const editButton = await screen.findByRole('button', { name: /Edit component/ });
expect(editButton).toBeDisabled();
});
it('should show a working "Edit" button for a normal component', async () => {
initializeMocks();
render(
<ComponentInfo usageKey={mockLibraryBlockMetadata.usageKeyPublished} />,
withLibraryId(mockContentLibrary.libraryId),
);
const editButton = await screen.findByRole('button', { name: /Edit component/ });
await waitFor(() => expect(editButton).not.toBeDisabled());
});
});

View File

@@ -1,4 +1,3 @@
import React from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
Button,
@@ -6,14 +5,15 @@ import {
Tabs,
Stack,
} from '@openedx/paragon';
import { Link } from 'react-router-dom';
import { getEditUrl } from '../components/utils';
import { ComponentMenu } from '../components';
import ComponentDetails from './ComponentDetails';
import ComponentManagement from './ComponentManagement';
import ComponentPreview from './ComponentPreview';
import messages from './messages';
import { canEditComponent } from '../components/ComponentEditorModal';
import { useLibraryContext } from '../common/context';
import { useContentLibrary } from '../data/apiHooks';
interface ComponentInfoProps {
usageKey: string;
@@ -21,13 +21,15 @@ interface ComponentInfoProps {
const ComponentInfo = ({ usageKey }: ComponentInfoProps) => {
const intl = useIntl();
const editUrl = getEditUrl(usageKey);
const { libraryId, openComponentEditor } = useLibraryContext();
const { data: libraryData } = useContentLibrary(libraryId);
const canEdit = libraryData?.canEditLibrary && canEditComponent(usageKey);
return (
<Stack>
<div className="d-flex flex-wrap">
<Button
{...(editUrl ? { as: Link, to: editUrl } : { disabled: true, to: '#' })}
{...(canEdit ? { onClick: () => openComponentEditor(usageKey) } : { disabled: true })}
variant="outline-primary"
className="m-1 text-nowrap flex-grow-1"
>

View File

@@ -39,10 +39,6 @@ describe('<CollectionCard />', () => {
initializeMocks();
});
afterEach(() => {
jest.clearAllMocks();
});
it('should render the card with title and description', () => {
render(<CollectionCard collectionHit={CollectionHitSample} />);

View File

@@ -7,7 +7,6 @@ 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';
@@ -15,8 +14,8 @@ import { type ContentHit } from '../../search-manager';
import { useLibraryContext } from '../common/context';
import messages from './messages';
import { STUDIO_CLIPBOARD_CHANNEL } from '../../constants';
import { getEditUrl } from './utils';
import BaseComponentCard from './BaseComponentCard';
import { canEditComponent } from './ComponentEditorModal';
type ComponentCardProps = {
contentHit: ContentHit,
@@ -24,7 +23,8 @@ type ComponentCardProps = {
export const ComponentMenu = ({ usageKey }: { usageKey: string }) => {
const intl = useIntl();
const editUrl = usageKey && getEditUrl(usageKey);
const { openComponentEditor } = useLibraryContext();
const canEdit = usageKey && canEditComponent(usageKey);
const { showToast } = useContext(ToastContext);
const [clipboardBroadcastChannel] = useState(() => new BroadcastChannel(STUDIO_CLIPBOARD_CHANNEL));
const updateClipboardClick = () => {
@@ -48,7 +48,7 @@ export const ComponentMenu = ({ usageKey }: { usageKey: string }) => {
data-testid="component-card-menu-toggle"
/>
<Dropdown.Menu>
<Dropdown.Item {...(editUrl ? { as: Link, to: editUrl } : { disabled: true, to: '#' })}>
<Dropdown.Item {...(canEdit ? { onClick: () => openComponentEditor(usageKey) } : { disabled: true })}>
<FormattedMessage {...messages.menuEdit} />
</Dropdown.Item>
<Dropdown.Item onClick={updateClipboardClick}>

View File

@@ -0,0 +1,42 @@
import { getConfig } from '@edx/frontend-platform';
import React from 'react';
import { useLibraryContext } from '../common/context';
import { getBlockType } from '../../generic/key-utils';
import EditorPage from '../../editors/EditorPage';
/* eslint-disable import/prefer-default-export */
export function canEditComponent(usageKey: string): boolean {
let blockType: string;
try {
blockType = getBlockType(usageKey);
} catch {
return false;
}
// Which XBlock/component types are supported by the 'editors' built in to this repo?
const mfeEditorTypes = ['html', 'problem', 'video'];
return mfeEditorTypes.includes(blockType);
}
export const ComponentEditorModal: React.FC<Record<never, never>> = () => {
const { componentBeingEdited, closeComponentEditor, libraryId } = useLibraryContext();
if (componentBeingEdited === undefined) {
return null;
}
const blockType = getBlockType(componentBeingEdited);
return (
<EditorPage
courseId={libraryId}
blockType={blockType}
blockId={componentBeingEdited}
studioEndpointUrl={getConfig().STUDIO_BASE_URL}
lmsEndpointUrl={getConfig().LMS_BASE_URL}
onClose={closeComponentEditor}
returnFunction={() => { closeComponentEditor(); return () => {}; }}
fullScreen={false}
/>
);
};

View File

@@ -76,7 +76,6 @@ describe('<LibraryComponents />', () => {
afterEach(() => {
fetchMock.reset();
mockFetchNextPage.mockReset();
});
it('should render empty state', async () => {

View File

@@ -1,27 +0,0 @@
import { getEditUrl } from './utils';
describe('component utils', () => {
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('returns the right URL for editing a Problem block', () => {
const usageKey = 'lb:org:beta:problem:571fe018-f3ce-45c9-8f53-5dafcb422fdd';
expect(getEditUrl(usageKey)).toStrictEqual(`/library/lib:org:beta/editor/problem/${usageKey}`);
});
it('returns the right URL for editing a Video block', () => {
const usageKey = 'lb:org:beta:video:571fe018-f3ce-45c9-8f53-5dafcb422fdd';
expect(getEditUrl(usageKey)).toStrictEqual(`/library/lib:org:beta/editor/video/${usageKey}`);
});
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();
});
});
});

View File

@@ -1,20 +0,0 @@
import { getBlockType, getLibraryId } from '../../generic/key-utils';
/* eslint-disable import/prefer-default-export */
export function getEditUrl(usageKey: string): string | undefined {
let blockType: string;
let libraryId: string;
try {
blockType = getBlockType(usageKey);
libraryId = getLibraryId(usageKey);
} catch {
return undefined;
}
// Which XBlock/component types are supported by the 'editors' built in to this repo?
const mfeEditorTypes = ['html', 'problem', 'video'];
if (mfeEditorTypes.includes(blockType)) {
return `/library/${libraryId}/editor/${blockType}/${usageKey}`;
}
return undefined;
}

View File

@@ -142,6 +142,7 @@ export async function mockXBlockFields(usageKey: string): Promise<api.XBlockFiel
case thisMock.usageKeyNewHtml: return thisMock.dataNewHtml;
case thisMock.usageKeyNewProblem: return thisMock.dataNewProblem;
case thisMock.usageKeyNewVideo: return thisMock.dataNewVideo;
case thisMock.usageKeyThirdParty: return thisMock.dataThirdParty;
default: throw new Error(`No mock has been set up for usageKey "${usageKey}"`);
}
}
@@ -172,6 +173,12 @@ mockXBlockFields.dataNewVideo = {
data: '',
metadata: { displayName: 'New Video' },
} satisfies api.XBlockFields;
mockXBlockFields.usageKeyThirdParty = 'lb:Axim:TEST:third_party:12345';
mockXBlockFields.dataThirdParty = {
displayName: 'Third party XBlock',
data: '',
metadata: { displayName: 'Third party XBlock' },
} 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);
@@ -192,6 +199,7 @@ export async function mockLibraryBlockMetadata(usageKey: string): Promise<api.Li
throw createAxiosError({ code: 404, message: 'Not found.', path: api.getLibraryBlockMetadataUrl(usageKey) });
case thisMock.usageKeyNeverPublished: return thisMock.dataNeverPublished;
case thisMock.usageKeyPublished: return thisMock.dataPublished;
case thisMock.usageKeyThirdPartyXBlock: return thisMock.dataThirdPartyXBlock;
case thisMock.usageKeyForTags: return thisMock.dataPublished;
default: throw new Error(`No mock has been set up for usageKey "${usageKey}"`);
}
@@ -228,6 +236,12 @@ mockLibraryBlockMetadata.dataPublished = {
modified: '2024-06-21T13:54:21Z',
tagsCount: 0,
} satisfies api.LibraryBlockMetadata;
mockLibraryBlockMetadata.usageKeyThirdPartyXBlock = mockXBlockFields.usageKeyThirdParty;
mockLibraryBlockMetadata.dataThirdPartyXBlock = {
...mockLibraryBlockMetadata.dataPublished,
id: mockLibraryBlockMetadata.usageKeyThirdPartyXBlock,
blockType: 'third_party',
} satisfies api.LibraryBlockMetadata;
mockLibraryBlockMetadata.usageKeyForTags = mockContentTaxonomyTagsData.largeTagsId;
/** Apply this mock. Returns a spy object that can tell you if it's been called. */
mockLibraryBlockMetadata.applyMock = () => jest.spyOn(api, 'getLibraryBlockMetadata').mockImplementation(mockLibraryBlockMetadata);

View File

@@ -178,6 +178,9 @@ export function initializeMocks({ user = defaultUser, initialState = undefined }
toastMessage: null,
};
// Clear the call counts etc. of all mocks. This doesn't remove the mock's effects; just clears their history.
jest.clearAllMocks();
return {
reduxStore,
axiosMock,