diff --git a/package-lock.json b/package-lock.json index 367276cc4..234bb7322 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 5bf3ed0f0..1ac66a8ed 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/src/editors/EditorFooter.jsx b/src/editors/EditorFooter.jsx new file mode 100644 index 000000000..199a13e24 --- /dev/null +++ b/src/editors/EditorFooter.jsx @@ -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 ( +
+ { saveUnderway === 'complete' && saveResponse.error != null + && ( + + + )} + + + + + + + +
+ ); +} diff --git a/src/editors/EditorFooter.test.jsx b/src/editors/EditorFooter.test.jsx new file mode 100644 index 000000000..6f2ce95c4 --- /dev/null +++ b/src/editors/EditorFooter.test.jsx @@ -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( + + + , + ); + expect(screen.getByText('Cancel')).toBeTruthy(); + expect(screen.getByText('Add To Course')).toBeTruthy(); +}); + +test('Rendering: loading url', () => { + const context = { + unitUrlLoading: ActionStates.NOT_BEGUN, + }; + render( + + + , + ); + 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( + + + , + ); + expect(screen.getByText('Cancel')).toBeTruthy(); + userEvent.click(screen.getByText('Cancel')); + expect(window.location.assign).toHaveBeenCalled(); +}); + +test('Navigation: Save', () => { + const wrapper = mount( + + + , + ); + const button = wrapper.find({ children: 'Add To Course' }); + expect(button).toBeTruthy(); + button.simulate('click'); + expect(saveBlock).toHaveBeenCalled(); + expect(window.location.assign).toHaveBeenCalled(); +}); diff --git a/src/editors/EditorHeader.jsx b/src/editors/EditorHeader.jsx new file mode 100644 index 000000000..d0b36f82c --- /dev/null +++ b/src/editors/EditorHeader.jsx @@ -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 ( +
+ + + + {mapBlockTypeToName(title)} + + + + + +
+ ); +}; +EditorHeader.propTypes = { + title: PropTypes.string.isRequired, +}; +export default EditorHeader; diff --git a/src/editors/EditorHeader.test.jsx b/src/editors/EditorHeader.test.jsx new file mode 100644 index 000000000..f9acbd507 --- /dev/null +++ b/src/editors/EditorHeader.test.jsx @@ -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( + + + , + ); + 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( + + + , + ); + expect(screen.getByText(title)).toBeTruthy(); + expect(screen.getByLabelText('Close')).toBeTruthy(); + userEvent.click(screen.getByLabelText('Close')); + expect(window.location.assign).toHaveBeenCalled(); +}); diff --git a/src/editors/EditorPage.jsx b/src/editors/EditorPage.jsx new file mode 100644 index 000000000..055a5bec2 --- /dev/null +++ b/src/editors/EditorPage.jsx @@ -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 ; + case 'video': + return ; + case 'problem': + return ; + default: + return ( + + ); + } + }; + + return ( + + {}} + hasCloseButton={false} + variant="dark" + > +
+ + {selectEditor(blockType)} + +
+
+
+ ); +} +EditorPage.propTypes = { + courseId: PropTypes.string, + blockType: PropTypes.string.isRequired, + blockId: PropTypes.string, + studioEndpointUrl: PropTypes.string, +}; +EditorPage.defaultProps = { + courseId: null, + blockId: null, + studioEndpointUrl: null, +}; diff --git a/src/editors/EditorPage.test.jsx b/src/editors/EditorPage.test.jsx new file mode 100644 index 000000000..155ebbcc5 --- /dev/null +++ b/src/editors/EditorPage.test.jsx @@ -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(); + 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(); + 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(); +}); diff --git a/src/editors/EditorPageContext.jsx b/src/editors/EditorPageContext.jsx new file mode 100644 index 000000000..e3c24fe7a --- /dev/null +++ b/src/editors/EditorPageContext.jsx @@ -0,0 +1,4 @@ +import React from 'react'; + +const EditorPageContext = React.createContext(); +export default EditorPageContext; diff --git a/src/editors/EditorPageProvider.jsx b/src/editors/EditorPageProvider.jsx new file mode 100644 index 000000000..1ef924329 --- /dev/null +++ b/src/editors/EditorPageProvider.jsx @@ -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 ( + + {children} + + ); +}; +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; diff --git a/src/editors/EditorPageTest.jsx b/src/editors/EditorPageTest.jsx new file mode 100644 index 000000000..e69de29bb diff --git a/src/editors/ProblemEditor/ProblemEditor.jsx b/src/editors/ProblemEditor/ProblemEditor.jsx new file mode 100644 index 000000000..cbf94abe3 --- /dev/null +++ b/src/editors/ProblemEditor/ProblemEditor.jsx @@ -0,0 +1,9 @@ +import React from 'react'; + +export default function ProblemEditor() { + return ( +
+ Problem +
+ ); +} diff --git a/src/editors/ProblemEditor/ProblemEditor.test.jsx b/src/editors/ProblemEditor/ProblemEditor.test.jsx new file mode 100644 index 000000000..73a28809d --- /dev/null +++ b/src/editors/ProblemEditor/ProblemEditor.test.jsx @@ -0,0 +1,8 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import ProblemEditor from './ProblemEditor'; + +test('Videoeditor: Basic Render', () => { + render(); + expect(screen.findByText('Problem')).toBeTruthy(); +}); diff --git a/src/editors/TextEditor/TextEditor.jsx b/src/editors/TextEditor/TextEditor.jsx new file mode 100644 index 000000000..7efb84ddf --- /dev/null +++ b/src/editors/TextEditor/TextEditor.jsx @@ -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 ( +
+ {}}> + + + {blockLoading !== ActionStates.FINISHED + ? ( +
+ +
+ ) + : ( + { 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, + }} + /> + )} +
+ ); +}; + +export default TextEditor; diff --git a/src/editors/TextEditor/TextEditor.test.jsx b/src/editors/TextEditor/TextEditor.test.jsx new file mode 100644 index 000000000..c6fe51b8d --- /dev/null +++ b/src/editors/TextEditor/TextEditor.test.jsx @@ -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: () =>
+ , + }; +}); + +test('Loading State:', () => { + const context = { + blockValue: null, + blockError: null, + blockLoading: ActionStates.IN_PROGRESS, + editorRef: null, + }; + render( + + + , + ); + 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( + + + , + ); + expect(screen.findByRole(mockRole)).toBeTruthy(); +}); diff --git a/src/editors/VideoEditor/VideoEditor.jsx b/src/editors/VideoEditor/VideoEditor.jsx new file mode 100644 index 000000000..93ebca236 --- /dev/null +++ b/src/editors/VideoEditor/VideoEditor.jsx @@ -0,0 +1,9 @@ +import React from 'react'; + +export default function VideoEditor() { + return ( +
+ Video +
+ ); +} diff --git a/src/editors/VideoEditor/VideoEditor.test.jsx b/src/editors/VideoEditor/VideoEditor.test.jsx new file mode 100644 index 000000000..256adc825 --- /dev/null +++ b/src/editors/VideoEditor/VideoEditor.test.jsx @@ -0,0 +1,8 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import VideoEditor from './VideoEditor'; + +test('Videoeditor: Basic Render', () => { + render(); + expect(screen.findByText('Video')).toBeTruthy(); +}); diff --git a/src/editors/data/api.js b/src/editors/data/api.js new file mode 100644 index 000000000..68707db78 --- /dev/null +++ b/src/editors/data/api.js @@ -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); +} diff --git a/src/editors/data/api.test.js b/src/editors/data/api.test.js new file mode 100644 index 000000000..9c92412d5 --- /dev/null +++ b/src/editors/data/api.test.js @@ -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(); +}); diff --git a/src/editors/data/constants.js b/src/editors/data/constants.js new file mode 100644 index 000000000..063d67cbb --- /dev/null +++ b/src/editors/data/constants.js @@ -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.`); + } +} diff --git a/src/index.jsx b/src/index.jsx index 6cb75c6f7..a92a3b872 100644 --- a/src/index.jsx +++ b/src/index.jsx @@ -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;