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:
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
39
src/editors/EditorContext.tsx
Normal file
39
src/editors/EditorContext.tsx
Normal 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>;
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
98
src/editors/EditorPage.test.tsx
Normal file
98
src/editors/EditorPage.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
58
src/editors/EditorPage.tsx
Normal file
58
src/editors/EditorPage.tsx
Normal 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;
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -118,6 +118,7 @@ describe('EditorContainer hooks', () => {
|
||||
destination: reactRedux.useSelector(selectors.app.returnUrl),
|
||||
analyticsEvent: analyticsEvt.editorCancelClick,
|
||||
analytics: reactRedux.useSelector(selectors.app.analytics),
|
||||
returnFunction: null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
97
src/editors/containers/EditorContainer/index.test.tsx
Normal file
97
src/editors/containers/EditorContainer/index.test.tsx
Normal 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());
|
||||
});
|
||||
});
|
||||
158
src/editors/containers/EditorContainer/index.tsx
Normal file
158
src/editors/containers/EditorContainer/index.tsx
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
@@ -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 } });
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -34,8 +34,6 @@ describe('<CollectionDetails />', () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
axiosMock.restore();
|
||||
fetchMock.mockReset();
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
|
||||
63
src/library-authoring/component-info/ComponentInfo.test.tsx
Normal file
63
src/library-authoring/component-info/ComponentInfo.test.tsx
Normal 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());
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -39,10 +39,6 @@ describe('<CollectionCard />', () => {
|
||||
initializeMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render the card with title and description', () => {
|
||||
render(<CollectionCard collectionHit={CollectionHitSample} />);
|
||||
|
||||
|
||||
@@ -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}>
|
||||
|
||||
42
src/library-authoring/components/ComponentEditorModal.tsx
Normal file
42
src/library-authoring/components/ComponentEditorModal.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -76,7 +76,6 @@ describe('<LibraryComponents />', () => {
|
||||
|
||||
afterEach(() => {
|
||||
fetchMock.reset();
|
||||
mockFetchNextPage.mockReset();
|
||||
});
|
||||
|
||||
it('should render empty state', async () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user