feat: Text Editor and V2 Editor Framework (#9)

Text Editor and V2 Editor Framework. Documentation to come.
This commit is contained in:
connorhaugh
2022-01-25 14:04:57 -05:00
committed by GitHub
parent c93c5e986a
commit d8c6b8dddd
21 changed files with 929 additions and 2 deletions

149
package-lock.json generated
View File

@@ -2390,6 +2390,132 @@
"loader-utils": "^2.0.0"
}
},
"@testing-library/dom": {
"version": "8.11.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.11.1.tgz",
"integrity": "sha512-3KQDyx9r0RKYailW2MiYrSSKEfH0GTkI51UGEvJenvcoDoeRYs0PZpi2SXqtnMClQvCqdtTTpOfFETDTVADpAg==",
"dev": true,
"requires": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
"@types/aria-query": "^4.2.0",
"aria-query": "^5.0.0",
"chalk": "^4.1.0",
"dom-accessibility-api": "^0.5.9",
"lz-string": "^1.4.4",
"pretty-format": "^27.0.2"
},
"dependencies": {
"ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"requires": {
"color-convert": "^2.0.1"
}
},
"aria-query": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.0.0.tgz",
"integrity": "sha512-V+SM7AbUwJ+EBnB8+DXs0hPZHO0W6pqBcc0dW90OwtVG02PswOu/teuARoLQjdDOH+t9pJgGnW5/Qmouf3gPJg==",
"dev": true
},
"chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dev": true,
"requires": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
}
},
"color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"requires": {
"color-name": "~1.1.4"
}
},
"color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true
},
"has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true
},
"pretty-format": {
"version": "27.4.6",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.4.6.tgz",
"integrity": "sha512-NblstegA1y/RJW2VyML+3LlpFjzx62cUrtBIKIWDXEDkjNeleA7Od7nrzcs/VLQvAeV4CgSYhrN39DRN88Qi/g==",
"dev": true,
"requires": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
"react-is": "^17.0.1"
},
"dependencies": {
"ansi-styles": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true
}
}
},
"react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true
},
"supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"requires": {
"has-flag": "^4.0.0"
}
}
}
},
"@testing-library/react": {
"version": "12.1.1",
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-12.1.1.tgz",
"integrity": "sha512-JDyWbvMuedEpP6SPL4Cvbhk59TVxQ3pwuR6ZfJHdRsHuxDd/ziSMA3nVM3fViaSbsQhuQFE/mvFrPrvQbL5kRQ==",
"dev": true,
"requires": {
"@babel/runtime": "^7.12.5",
"@testing-library/dom": "^8.0.0"
}
},
"@testing-library/user-event": {
"version": "13.5.0",
"resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-13.5.0.tgz",
"integrity": "sha512-5Kwtbo3Y/NowpkbRuSepbyMFkZmHgD+vPzYB/RJ4oxt5Gj/avFFBYjhw27cqSVPVw/3a67NK1PbiIr9k4Gwmdg==",
"dev": true,
"requires": {
"@babel/runtime": "^7.12.5"
}
},
"@tinymce/tinymce-react": {
"version": "3.13.0",
"resolved": "https://registry.npmjs.org/@tinymce/tinymce-react/-/tinymce-react-3.13.0.tgz",
"integrity": "sha512-8+OHYIUP9W5D5z7gknausaG48ovQtEvjcK4c+zpha7QppRVVX0ltaINpo10V6Vb4qj9Jf7ZFfZpMRxxcFL2YvQ==",
"requires": {
"prop-types": "^15.6.2",
"tinymce": "^5.5.1"
}
},
"@tootallnate/once": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz",
@@ -2402,6 +2528,12 @@
"integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==",
"dev": true
},
"@types/aria-query": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-4.2.2.tgz",
"integrity": "sha512-HnYpAE1Y6kRyKM/XkEuiRQhTHvkzMBurTHnpFLYLBGPIylZNPs9jJcuOOYWxPLJCSEtmZT0Y8rHDokKN7rRTig==",
"dev": true
},
"@types/babel__core": {
"version": "7.1.17",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.1.17.tgz",
@@ -5918,6 +6050,12 @@
"esutils": "^2.0.2"
}
},
"dom-accessibility-api": {
"version": "0.5.10",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.10.tgz",
"integrity": "sha512-Xu9mD0UjrJisTmv7lmVSDMagQcU9R5hwAbxsaAE/35XPnPLJobbuREfV/rraiSaEj/UOvgrzQs66zyTWTlyd+g==",
"dev": true
},
"dom-converter": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz",
@@ -11874,6 +12012,12 @@
"yallist": "^4.0.0"
}
},
"lz-string": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.4.4.tgz",
"integrity": "sha1-wNjq82BZ9wV5bh40SBHPTEmNOiY=",
"dev": true
},
"mailto-link": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/mailto-link/-/mailto-link-1.0.0.tgz",
@@ -16673,6 +16817,11 @@
"integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==",
"dev": true
},
"tinymce": {
"version": "5.10.2",
"resolved": "https://registry.npmjs.org/tinymce/-/tinymce-5.10.2.tgz",
"integrity": "sha512-5QhnZ6c8F28fYucLLc00MM37fZoAZ4g7QCYzwIl38i5TwJR5xGqzOv6YMideyLM4tytCzLCRwJoQen2LI66p5A=="
},
"tmp": {
"version": "0.0.33",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz",

View File

@@ -37,6 +37,9 @@
"@edx/frontend-build": "9.0.4",
"@edx/frontend-platform": "1.14.4",
"@edx/paragon": "16.22.0",
"@testing-library/dom": "^8.11.1",
"@testing-library/react": "12.1.1",
"@testing-library/user-event": "^13.5.0",
"codecov": "3.8.3",
"enzyme": "3.11.0",
"enzyme-adapter-react-16": "1.15.6",
@@ -52,6 +55,7 @@
"redux-saga": "1.1.3"
},
"dependencies": {
"@tinymce/tinymce-react": "^3.13.0",
"babel-polyfill": "6.26.0",
"react-responsive": "8.2.0",
"react-transition-group": "4.4.2"

View File

@@ -0,0 +1,77 @@
import React, { useContext, useEffect } from 'react';
import {
Spinner, ActionRow, Button, ModalDialog, Toast,
} from '@edx/paragon';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import EditorPageContext from './EditorPageContext';
import { ActionStates } from './data/constants';
const navigateAway = (destination) => {
window.location.assign(destination);
};
export default function EditorFooter() {
const {
blockLoading,
setBlockContent,
unitUrlLoading,
unitUrl,
setSaveUnderway,
saveUnderway,
saveResponse,
studioEndpointUrl,
editorRef,
} = useContext(EditorPageContext);
const onSaveClicked = () => {
if (blockLoading === ActionStates.FINISHED && unitUrlLoading === ActionStates.FINISHED && editorRef) {
const content = editorRef.current.getContent();
setBlockContent(content);
setSaveUnderway(ActionStates.IN_PROGRESS);
}
};
const onCancelClicked = () => {
if (unitUrlLoading === ActionStates.FINISHED) {
const destination = `${studioEndpointUrl}/container/${unitUrl.data.ancestors[0].id}`;
navigateAway(destination);
}
};
useEffect(() => {
if (saveUnderway === ActionStates.FINISHED
&& blockLoading === ActionStates.FINISHED
&& unitUrlLoading === ActionStates.FINISHED) {
const destination = `${studioEndpointUrl}/container/${unitUrl.data.ancestors[0].id}`;
navigateAway(destination);
}
}, [saveUnderway]);
return (
<div className="editor-footer mt-auto">
{ saveUnderway === 'complete' && saveResponse.error != null
&& (
<Toast><FormattedMessage
id="authoring.editorfooter.save.error"
defaultMessage="Error: Content save failed. Try again later."
description="Error message displayed when content fails to save."
/>
</Toast>
)}
<ModalDialog.Footer>
<ActionRow>
<ActionRow.Spacer />
<Button aria-label="Discard Changes and Return to Learning Context" variant="tertiary" onClick={onCancelClicked}>Cancel</Button>
<Button aria-label="Save Changes and Return to Learning Context" onClick={onSaveClicked}>
{unitUrlLoading !== ActionStates.FINISHED
? <Spinner animation="border" className="mr-3" />
: (
<FormattedMessage
id="authoring.editorfooter.savebutton.label"
defaultMessage="Add To Course"
description="Label for Save button"
/>
)}
</Button>
</ActionRow>
</ModalDialog.Footer>
</div>
);
}

View File

@@ -0,0 +1,104 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { mount } from 'enzyme';
import EditorFooter from './EditorFooter';
import EditorPageContext from './EditorPageContext';
import { ActionStates } from './data/constants';
import EditorPageProvider from './EditorPageProvider';
import { saveBlock } from './data/api';
const locationTemp = window.location;
beforeAll(() => {
delete window.location;
window.location = {
assign: jest.fn(),
};
});
afterAll(() => {
window.location = locationTemp;
});
jest.mock('./data/api', () => {
const originalModule = jest.requireActual('./data/api');
// Mock the default export and named export saveBlock
return {
__esModule: true,
...originalModule,
saveBlock: jest.fn(() => {}),
};
});
jest.spyOn(React, 'useRef').mockReturnValue({
current: {
getContent: () => '',
},
});
test('Rendering: loaded', () => {
const context = {
unitUrlLoading: ActionStates.FINISHED,
};
render(
<EditorPageContext.Provider value={context}>
<EditorFooter />
</EditorPageContext.Provider>,
);
expect(screen.getByText('Cancel')).toBeTruthy();
expect(screen.getByText('Add To Course')).toBeTruthy();
});
test('Rendering: loading url', () => {
const context = {
unitUrlLoading: ActionStates.NOT_BEGUN,
};
render(
<EditorPageContext.Provider value={context}>
<EditorFooter />
</EditorPageContext.Provider>,
);
expect(screen.getByText('Cancel')).toBeTruthy();
expect(screen.getAllByRole('button', { 'aria-label': 'Save' })).toBeTruthy();
expect(screen.queryByText('Add To Course')).toBeNull();
});
test('Navigation: Cancel', () => {
const context = {
unitUrlLoading: ActionStates.FINISHED,
unitUrl: {
data: {
ancestors:
[
{ id: 'fakeblockid' },
],
},
},
studioEndpointUrl: 'Testurl',
};
render(
<EditorPageContext.Provider value={context}>
<EditorFooter />
</EditorPageContext.Provider>,
);
expect(screen.getByText('Cancel')).toBeTruthy();
userEvent.click(screen.getByText('Cancel'));
expect(window.location.assign).toHaveBeenCalled();
});
test('Navigation: Save', () => {
const wrapper = mount(
<EditorPageProvider
blockType="html"
courseId="myCourse101"
blockId="redosablocksalot"
studioEndpointUrl="celaicboss.axe"
>
<EditorFooter />
</EditorPageProvider>,
);
const button = wrapper.find({ children: 'Add To Course' });
expect(button).toBeTruthy();
button.simulate('click');
expect(saveBlock).toHaveBeenCalled();
expect(window.location.assign).toHaveBeenCalled();
});

View File

@@ -0,0 +1,44 @@
import {
ActionRow, IconButton, Icon, ModalDialog,
} from '@edx/paragon';
import PropTypes from 'prop-types';
import React, { useContext } from 'react';
import { Close } from '@edx/paragon/icons';
import EditorPageContext from './EditorPageContext';
import { ActionStates, mapBlockTypeToName } from './data/constants';
const EditorHeader = ({ title }) => {
const { unitUrl, unitUrlLoading, studioEndpointUrl } = useContext(EditorPageContext);
const onCancelClicked = () => {
if (unitUrlLoading === ActionStates.FINISHED) {
const destination = `${studioEndpointUrl}/container/${unitUrl.data.ancestors[0].id}`;
window.location.assign(destination);
}
};
return (
<div className="editor-header">
<ModalDialog.Header>
<ActionRow>
<ModalDialog.Title>
{mapBlockTypeToName(title)}
</ModalDialog.Title>
<ActionRow.Spacer />
<IconButton
aria-label="Cancel Changes and Return to Learning Context"
src={Close}
iconAs={Icon}
alt="Close"
onClick={onCancelClicked}
variant="light"
className="mr-2"
/>
</ActionRow>
</ModalDialog.Header>
</div>
);
};
EditorHeader.propTypes = {
title: PropTypes.string.isRequired,
};
export default EditorHeader;

View File

@@ -0,0 +1,58 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import EditorHeader from './EditorHeader';
import EditorPageContext from './EditorPageContext';
import { ActionStates } from './data/constants';
const locationTemp = window.location;
beforeEach(() => {
delete window.location;
window.location = {
assign: jest.fn(),
};
});
afterAll(() => {
window.location = locationTemp;
});
test('Rendering And Click Close Button: Does not Navigate off of Page When Loading', () => {
const title = 'An Awesome Block';
const context = {
unitUrlLoading: ActionStates.IN_PROGRESS,
};
render(
<EditorPageContext.Provider value={context}>
<EditorHeader title={title} />
</EditorPageContext.Provider>,
);
expect(screen.getByText(title)).toBeTruthy();
expect(screen.getByLabelText('Close')).toBeTruthy();
userEvent.click(screen.getByLabelText('Close'));
expect(window.location.assign).not.toHaveBeenCalled();
});
test('Rendering And Click Button: Loaded Navigates Away', () => {
const title = 'An Awesome Block';
const context = {
unitUrlLoading: ActionStates.FINISHED,
unitUrl: {
data: {
ancestors:
[
{ id: 'fakeblockid' },
],
},
},
studioEndpointUrl: 'Testurl',
};
render(
<EditorPageContext.Provider value={context}>
<EditorHeader title={title} />
</EditorPageContext.Provider>,
);
expect(screen.getByText(title)).toBeTruthy();
expect(screen.getByLabelText('Close')).toBeTruthy();
userEvent.click(screen.getByLabelText('Close'));
expect(window.location.assign).toHaveBeenCalled();
});

View File

@@ -0,0 +1,71 @@
import React from 'react';
import PropTypes from 'prop-types';
import { ModalDialog } from '@edx/paragon';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import TextEditor from './TextEditor/TextEditor';
import VideoEditor from './VideoEditor/VideoEditor';
import ProblemEditor from './ProblemEditor/ProblemEditor';
import EditorFooter from './EditorFooter';
import EditorHeader from './EditorHeader';
import EditorPageProvider from './EditorPageProvider';
export default function EditorPage({
courseId,
blockType,
blockId,
studioEndpointUrl,
}) {
const selectEditor = (type) => {
switch (type) {
case 'html':
return <TextEditor />;
case 'video':
return <VideoEditor />;
case 'problem':
return <ProblemEditor />;
default:
return (
<FormattedMessage
id="authoring.editorpage.selecteditor.error"
defaultMessage="Error: Could Not find Editor"
description="Error Message Dispayed When An unsopported Editor is desired in V2"
/>
);
}
};
return (
<EditorPageProvider
blockType={blockType}
courseId={courseId}
blockId={blockId}
studioEndpointUrl={studioEndpointUrl}
>
<ModalDialog
title={blockType}
isOpen
size="fullscreen"
onClose={() => {}}
hasCloseButton={false}
variant="dark"
>
<div className="d-flex flex-column vh-100">
<EditorHeader title={blockType} />
{selectEditor(blockType)}
<EditorFooter />
</div>
</ModalDialog>
</EditorPageProvider>
);
}
EditorPage.propTypes = {
courseId: PropTypes.string,
blockType: PropTypes.string.isRequired,
blockId: PropTypes.string,
studioEndpointUrl: PropTypes.string,
};
EditorPage.defaultProps = {
courseId: null,
blockId: null,
studioEndpointUrl: null,
};

View File

@@ -0,0 +1,39 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import EditorPage from './EditorPage';
test('rendering correctly with expected Input', () => {
const courseId = 'course-v1:edX+DemoX+Demo_Course';
const blockType = 'html';
const blockId = 'block-v1:edX+DemoX+Demo_Course+type@html+block@030e35c4756a4ddc8d40b95fbbfff4d4';
const studioEndpointUrl = 'fakeurl.com';
render(<EditorPage
courseId={courseId}
blockType={blockType}
blockId={blockId}
studioEndpointUrl={studioEndpointUrl}
/>);
expect(screen.getByText('Text')).toBeTruthy();
expect(screen.getByText('Cancel')).toBeTruthy();
expect(screen.getAllByLabelText('Close')).toBeTruthy();
expect(screen.getByText('Add To Course')).toBeTruthy();
expect(screen.getByText('Error: Could Not Load Text Content')).toBeTruthy();
});
test('rendering correctly with expected Error', () => {
const courseId = 'course-v1:edX+DemoX+Demo_Course';
const blockType = 'Smelly Garbage Xblock';
const blockId = 'BadGarbadioU@Garbagefha4521ea ';
const studioEndpointUrl = 'fakeurl.com';
render(<EditorPage
courseId={courseId}
blockType={blockType}
blockId={blockId}
studioEndpointUrl={studioEndpointUrl}
/>);
expect(screen.getByText(blockType)).toBeTruthy();
expect(screen.getByText('Cancel')).toBeTruthy();
expect(screen.getAllByLabelText('Close')).toBeTruthy();
expect(screen.getByText('Add To Course')).toBeTruthy();
expect(screen.getByText('Error: Could Not find Editor')).toBeTruthy();
});

View File

@@ -0,0 +1,4 @@
import React from 'react';
const EditorPageContext = React.createContext();
export default EditorPageContext;

View File

@@ -0,0 +1,97 @@
import React, {
useState, useEffect, useMemo,
} from 'react';
import PropTypes from 'prop-types';
import { fetchBlockById, fetchUnitById, saveBlock } from './data/api';
import EditorPageContext from './EditorPageContext';
import { ActionStates } from './data/constants';
/* This Component serves as a container for state for V2 editors,
to avoid prop drilling for: saving, loading, and navigating away from content. */
const EditorPageProvider = ({
blockType, courseId, blockId, studioEndpointUrl, children,
}) => {
const editorRef = React.useRef(null);
const [blockValue, setBlockValue] = useState(null); // this is the intial block, as called in from the api.
const [blockError, setBlockError] = useState(null);
const [blockLoading, setBlockLoading] = useState(ActionStates.NOT_BEGUN);
const [unitUrl, setUnitUrlValue] = useState(null);
const [unitUrlError, setUnitUrlError] = useState(null);
const [unitUrlLoading, setUnitUrlLoading] = useState(ActionStates.NOT_BEGUN);
const [blockContent, setBlockContent] = useState(null); // This is the updated content to be saved via api call
const [saveResponse, setSaveResponse] = useState(null);
const [saveUnderway, setSaveUnderway] = useState(ActionStates.NOT_BEGUN);
/* We memoize the context value, so it it is only updated
(and therefore only causes a re-render of the consumers of this provider)
when blockLoading, unitUrlLoading, or saveUnderway change */
const value = useMemo(() => ({
editorRef,
blockValue,
blockError,
blockLoading,
unitUrl,
unitUrlError,
unitUrlLoading,
setBlockContent,
saveResponse,
setSaveUnderway,
saveUnderway,
studioEndpointUrl,
blockId,
courseId,
blockType,
}), [blockLoading, unitUrlLoading, saveUnderway]);
useEffect(() => {
// On init, begin fetching data
if (unitUrlLoading === ActionStates.NOT_BEGUN) {
fetchUnitById({
setValue: setUnitUrlValue,
setError: setUnitUrlError,
setLoading: setUnitUrlLoading,
}, blockId, studioEndpointUrl);
}
if (blockLoading === ActionStates.NOT_BEGUN) {
fetchBlockById(
{
setValue: setBlockValue,
setError: setBlockError,
setLoading: setBlockLoading,
}, blockId, studioEndpointUrl,
);
}
if (saveUnderway === ActionStates.IN_PROGRESS) {
saveBlock(
blockId,
blockType,
courseId,
studioEndpointUrl,
blockContent,
{ setInProgress: setSaveUnderway, setResponse: setSaveResponse },
);
}
}, [saveUnderway]);
return (
<EditorPageContext.Provider
value={value}
>
{children}
</EditorPageContext.Provider>
);
};
EditorPageProvider.propTypes = {
blockType: PropTypes.string.isRequired,
courseId: PropTypes.string.isRequired,
blockId: PropTypes.string.isRequired,
studioEndpointUrl: PropTypes.string,
children: PropTypes.node.isRequired,
};
EditorPageProvider.defaultProps = {
studioEndpointUrl: null,
};
export default EditorPageProvider;

View File

View File

@@ -0,0 +1,9 @@
import React from 'react';
export default function ProblemEditor() {
return (
<div className="problem-editor">
<span>Problem</span>
</div>
);
}

View File

@@ -0,0 +1,8 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import ProblemEditor from './ProblemEditor';
test('Videoeditor: Basic Render', () => {
render(<ProblemEditor />);
expect(screen.findByText('Problem')).toBeTruthy();
});

View File

@@ -0,0 +1,56 @@
import React, { useContext } from 'react';
import { Spinner, Toast } from '@edx/paragon';
import { Editor } from '@tinymce/tinymce-react';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import EditorPageContext from '../EditorPageContext';
import { ActionStates } from '../data/constants';
const TextEditor = () => {
const {
blockValue, blockError, blockLoading, editorRef,
} = useContext(EditorPageContext);
return (
<div className="editor-body h-75">
<Toast show={blockError != null} onClose={() => {}}>
<FormattedMessage
id="authoring.texteditor.load.error"
defaultMessage="Error: Could Not Load Text Content"
description="Error Message Dispayed When HTML content fails to Load"
/>
</Toast>
{blockLoading !== ActionStates.FINISHED
? (
<div className="text-center p-6">
<Spinner animation="border" className="m-3" screenreadertext="loading" />
</div>
)
: (
<Editor
onInit={(evt, editor) => { editorRef.current = editor; }}
initialValue={blockValue ? blockValue.data.data : ''}
init={{
height: '100%',
menubar: false,
plugins: [
'advlist autolink lists link image charmap print preview anchor',
'searchreplace visual blocks code fullscreen',
'insertdatetime media table paste code help wordcount',
'autoresize',
],
toolbar: 'undo redo | formatselect | '
+ 'bold italic backcolor | alignleft aligncenter '
+ 'alignright alignjustify | bullist numlist outdent indent | '
+ 'removeformat | help',
content_style: 'body { font-family:Helvetica,Arial,sans-serif; font-size:14px }',
max_height: 900,
min_height: 700,
branding: false,
}}
/>
)}
</div>
);
};
export default TextEditor;

View File

@@ -0,0 +1,51 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import TextEditor from './TextEditor';
import EditorPageContext from '../EditorPageContext';
import { ActionStates } from '../data/constants';
// Per https://github.com/tinymce/tinymce-react/issues/91 React unit testing in JSDOM is not supported by tinymce.
// Consequently, mock the Editor out.
const mockRole = 'Tiny-MCE-Mock';
jest.mock('@tinymce/tinymce-react', () => {
const originalModule = jest.requireActual('@tinymce/tinymce-react');
return {
__esModule: true,
...originalModule,
Editor: () => <div role={mockRole} />
,
};
});
test('Loading State:', () => {
const context = {
blockValue: null,
blockError: null,
blockLoading: ActionStates.IN_PROGRESS,
editorRef: null,
};
render(
<EditorPageContext.Provider value={context}>
<TextEditor />
</EditorPageContext.Provider>,
);
expect(screen.queryByRole(mockRole)).not.toBeTruthy();
});
test('Loaded State-- No Error', () => {
const htmltext = 'Im baby palo santo ugh celiac fashion axe. La croix lo-fi venmo whatever. Beard man braid migas single-origin coffee forage ramps.';
const context = {
blockValue:
{
data:
{ data: { htmltext } },
},
blockError: null,
blockLoading: ActionStates.FINISHED,
};
render(
<EditorPageContext.Provider value={context}>
<TextEditor />
</EditorPageContext.Provider>,
);
expect(screen.findByRole(mockRole)).toBeTruthy();
});

View File

@@ -0,0 +1,9 @@
import React from 'react';
export default function VideoEditor() {
return (
<div className="video-editor">
<span>Video</span>
</div>
);
}

View File

@@ -0,0 +1,8 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import VideoEditor from './VideoEditor';
test('Videoeditor: Basic Render', () => {
render(<VideoEditor />);
expect(screen.findByText('Video')).toBeTruthy();
});

42
src/editors/data/api.js Normal file
View File

@@ -0,0 +1,42 @@
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { ActionStates, normalizeContent } from './constants';
async function getAsync(updateContext, params) {
try {
updateContext.setLoading(ActionStates.IN_PROGRESS);
const result = await getAuthenticatedHttpClient().get(...params);
updateContext.setValue(result);
} catch (e) {
updateContext.setError(e);
} finally {
updateContext.setLoading(ActionStates.FINISHED);
}
}
async function saveAsync(updateContext, params) {
try {
const result = await getAuthenticatedHttpClient().post(...params);
updateContext.setResponse(result);
} catch (e) {
updateContext.setResponse(e);
} finally {
updateContext.setInProgress(ActionStates.FINISHED);
}
}
export async function fetchBlockById(updateContext, blockId, studioEndpointUrl) {
const url = `${studioEndpointUrl}/xblock/${blockId}`;
getAsync(updateContext, [url]);
}
export async function fetchUnitById(updateContext, blockId, studioEndpointUrl) {
const url = `${studioEndpointUrl}/xblock/${blockId}?fields=ancestorInfo`;
getAsync(updateContext, [url]);
}
export async function saveBlock(blockId, blockType, courseId, studioEndpointUrl, content, updateContext) {
const normalizedContent = normalizeContent(blockType, content, blockId, courseId);
const url = `${studioEndpointUrl}/xblock/${encodeURI(blockId)}`;
const params = [url, normalizedContent];
saveAsync(updateContext, params);
}

View File

@@ -0,0 +1,66 @@
import axios from 'axios'; // eslint-disable-line import/no-extraneous-dependencies
import { fetchBlockById, fetchUnitById, saveBlock } from './api';
const get = jest.spyOn(axios, 'get');
const post = jest.spyOn(axios, 'post');
const saveFunctionsGet = {
setValue: jest.fn(),
setError: jest.fn(),
setLoading: jest.fn(),
};
const saveFunctionsSave = {
setResponse: jest.fn(),
setInProgress: jest.fn(),
};
const blockId = 'coursev1:2uX@4345432';
const studioEndpointUrl = 'hortus.coa';
test('fetchBlockById 404', () => {
get.mockRejectedValue({ response: { status: 404 } });
fetchBlockById(saveFunctionsGet, blockId, studioEndpointUrl);
expect(saveFunctionsGet.setLoading).toHaveBeenCalled();
expect(saveFunctionsGet.setError).toHaveBeenCalled();
});
test('fetchBlockById 403', () => {
get.mockRejectedValue({ response: { status: 403 } });
fetchBlockById(saveFunctionsGet, blockId, studioEndpointUrl);
expect(saveFunctionsGet.setLoading).toHaveBeenCalled();
expect(saveFunctionsGet.setError).toHaveBeenCalled();
});
test('fetchBlockById 408', () => {
get.mockRejectedValue({ response: { status: 408 } });
fetchBlockById(saveFunctionsGet, blockId, studioEndpointUrl);
expect(saveFunctionsGet.setLoading).toHaveBeenCalled();
expect(saveFunctionsGet.setError).toHaveBeenCalled();
});
test('fetchBlockById 404', () => {
get.mockRejectedValue({ response: { status: 404 } });
fetchBlockById(saveFunctionsGet, blockId, studioEndpointUrl);
expect(saveFunctionsGet.setLoading).toHaveBeenCalled();
expect(saveFunctionsGet.setError).toHaveBeenCalled();
});
test('fetchUnitById 401', () => {
get.mockRejectedValue({ response: { status: 401 } });
fetchUnitById(saveFunctionsGet, blockId, studioEndpointUrl);
expect(saveFunctionsGet.setLoading).toHaveBeenCalled();
expect(saveFunctionsGet.setError).toHaveBeenCalled();
});
test('fetchUnitById 404', () => {
get.mockRejectedValue({ response: { status: 404 } });
fetchUnitById(saveFunctionsGet, blockId, studioEndpointUrl);
expect(saveFunctionsGet.setLoading).toHaveBeenCalled();
expect(saveFunctionsGet.setError).toHaveBeenCalled();
});
test('saveBlock 408', () => {
post.mockRejectedValue({ response: { status: 408 } });
saveBlock(blockId, 'html', 'demo2uX', studioEndpointUrl, 'Im baby palo santo ugh celiac fashion axe. La croix lo-fi venmo whatever. Beard man braid migas single-origin coffee forage ramps.', saveFunctionsSave);
expect(saveFunctionsSave.setInProgress).toHaveBeenCalled();
expect(saveFunctionsSave.setResponse).toHaveBeenCalled();
});
test('saveBlock 404', () => {
post.mockRejectedValue({ response: { status: 404 } });
saveBlock(blockId, 'html', 'demo2uX', studioEndpointUrl, 'Im baby palo santo ugh celiac fashion axe. La croix lo-fi venmo whatever. Beard man braid migas single-origin coffee forage ramps.', saveFunctionsSave);
expect(saveFunctionsSave.setInProgress).toHaveBeenCalled();
expect(saveFunctionsSave.setResponse).toHaveBeenCalled();
});

View File

@@ -0,0 +1,31 @@
export function mapBlockTypeToName(blockType) {
if (blockType === 'html') {
return 'Text';
}
return blockType[0].toUpperCase() + blockType.substring(1);
}
// States for async processes
export const ActionStates = {
NOT_BEGUN: 'not_begun',
IN_PROGRESS: 'in_progress',
FINISHED: 'finished',
};
export function normalizeContent(blockType, content, blockId, courseId) {
/*
For Each V2 Block type, return a javascript object which updates the requisite data fields,
to be POST-messaged to the CMS.
*/
switch (blockType) {
case 'html':
return {
id: blockId,
category: blockType,
has_changes: true,
data: content,
couseKey: courseId,
};
default:
throw new TypeError(`No Block in V2 Editors named /"${blockType}/", Cannot Save Content.`);
}
}

View File

@@ -1,6 +1,6 @@
import Placeholder from './Placeholder';
import messages from './i18n/index';
import EditorPage from './editors/EditorPage';
export { messages };
export { messages, EditorPage };
export default Placeholder;