From 5a1d71a62caf6a0e61d8a164e49a6c3bdf3e0595 Mon Sep 17 00:00:00 2001 From: Ben Warzeski Date: Fri, 18 Feb 2022 13:16:36 -0500 Subject: [PATCH] !refactor: Breaking Change refactor use Redux. No release --- .env.development | 2 + .eslintrc.js | 25 ++- babel.config.js | 2 +- jest.config.js | 4 + package-lock.json | 88 +++++++-- package.json | 15 +- src/editors/Editor.jsx | 86 +++++++++ src/editors/EditorFooter.jsx | 77 -------- src/editors/EditorHeader.jsx | 44 ----- src/editors/EditorPage.jsx | 77 +++----- src/editors/EditorPageContext.jsx | 4 - src/editors/EditorPageProvider.jsx | 97 ---------- src/editors/TextEditor/TextEditor.jsx | 78 -------- src/editors/components/EditorFooter/index.jsx | 89 +++++++++ .../EditorFooter/index.test.jsx} | 0 .../EditorHeader/.HeaderTitle.jsx.swp | Bin 0 -> 12288 bytes .../EditorHeader/EditableHeader.jsx | 41 ++++ .../components/EditorHeader/HeaderTitle.jsx | 90 +++++++++ src/editors/components/EditorHeader/hooks.js | 42 ++++ src/editors/components/EditorHeader/index.jsx | 43 +++++ .../EditorHeader/index.test.jsx} | 0 src/editors/components/messages.js | 19 ++ .../ProblemEditor/ProblemEditor.jsx | 0 .../ProblemEditor/ProblemEditor.test.jsx | 0 .../ImageUpload}/ImageUploadModal.jsx | 0 .../containers/TextEditor/TextEditor.jsx | 93 +++++++++ .../TextEditor/TextEditor.test.jsx | 0 src/editors/containers/TextEditor/hooks.js | 50 +++++ src/editors/containers/TextEditor/messages.js | 9 + .../VideoEditor/VideoEditor.jsx | 0 .../VideoEditor/VideoEditor.test.jsx | 0 src/editors/data/api.js | 42 ---- src/editors/data/constants.js | 31 --- src/editors/data/constants/app.js | 8 + src/editors/data/constants/requests.js | 20 ++ src/editors/data/redux/app/.selectors.js.swp | Bin 0 -> 12288 bytes src/editors/data/redux/app/index.js | 2 + src/editors/data/redux/app/reducer.js | 48 +++++ src/editors/data/redux/app/selectors.js | 51 +++++ src/editors/data/redux/index.js | 28 +++ src/editors/data/redux/requests/index.js | 2 + src/editors/data/redux/requests/reducer.js | 52 +++++ src/editors/data/redux/requests/selectors.js | 38 ++++ .../data/redux/thunkActions/.requests.js.swp | Bin 0 -> 12288 bytes src/editors/data/redux/thunkActions/app.js | 50 +++++ .../data/redux/thunkActions/app.test.js | 42 ++++ src/editors/data/redux/thunkActions/index.js | 7 + .../data/redux/thunkActions/requests.js | 95 ++++++++++ .../data/redux/thunkActions/requests.test.js | 179 ++++++++++++++++++ .../data/redux/thunkActions/testUtils.js | 53 ++++++ src/editors/data/services/lms/api.js | 48 +++++ .../data/{ => services/lms}/api.test.js | 0 src/editors/data/services/lms/urls.js | 11 ++ src/editors/data/services/lms/utils.js | 17 ++ src/editors/data/services/lms/utils.test.js | 39 ++++ src/editors/data/store.js | 32 ++++ src/editors/data/store.test.js | 66 +++++++ src/editors/hooks.js | 30 +++ src/editors/messages.js | 9 + src/editors/utils/StrictDict.js | 24 +++ src/editors/utils/StrictDict.test.js | 62 ++++++ src/editors/utils/index.js | 2 + webpack.dev.config.js | 8 +- webpack.prod.config.js | 14 ++ 64 files changed, 1734 insertions(+), 451 deletions(-) create mode 100644 .env.development create mode 100644 src/editors/Editor.jsx delete mode 100644 src/editors/EditorFooter.jsx delete mode 100644 src/editors/EditorHeader.jsx delete mode 100644 src/editors/EditorPageContext.jsx delete mode 100644 src/editors/EditorPageProvider.jsx delete mode 100644 src/editors/TextEditor/TextEditor.jsx create mode 100644 src/editors/components/EditorFooter/index.jsx rename src/editors/{EditorFooter.test.jsx => components/EditorFooter/index.test.jsx} (100%) create mode 100644 src/editors/components/EditorHeader/.HeaderTitle.jsx.swp create mode 100644 src/editors/components/EditorHeader/EditableHeader.jsx create mode 100644 src/editors/components/EditorHeader/HeaderTitle.jsx create mode 100644 src/editors/components/EditorHeader/hooks.js create mode 100644 src/editors/components/EditorHeader/index.jsx rename src/editors/{EditorHeader.test.jsx => components/EditorHeader/index.test.jsx} (100%) create mode 100644 src/editors/components/messages.js rename src/editors/{ => containers}/ProblemEditor/ProblemEditor.jsx (100%) rename src/editors/{ => containers}/ProblemEditor/ProblemEditor.test.jsx (100%) rename src/editors/{TextEditor/ImageUpload/Wizard => containers/TextEditor/ImageUpload}/ImageUploadModal.jsx (100%) create mode 100644 src/editors/containers/TextEditor/TextEditor.jsx rename src/editors/{ => containers}/TextEditor/TextEditor.test.jsx (100%) create mode 100644 src/editors/containers/TextEditor/hooks.js create mode 100644 src/editors/containers/TextEditor/messages.js rename src/editors/{ => containers}/VideoEditor/VideoEditor.jsx (100%) rename src/editors/{ => containers}/VideoEditor/VideoEditor.test.jsx (100%) delete mode 100644 src/editors/data/api.js delete mode 100644 src/editors/data/constants.js create mode 100644 src/editors/data/constants/app.js create mode 100644 src/editors/data/constants/requests.js create mode 100644 src/editors/data/redux/app/.selectors.js.swp create mode 100644 src/editors/data/redux/app/index.js create mode 100644 src/editors/data/redux/app/reducer.js create mode 100644 src/editors/data/redux/app/selectors.js create mode 100644 src/editors/data/redux/index.js create mode 100644 src/editors/data/redux/requests/index.js create mode 100644 src/editors/data/redux/requests/reducer.js create mode 100644 src/editors/data/redux/requests/selectors.js create mode 100644 src/editors/data/redux/thunkActions/.requests.js.swp create mode 100644 src/editors/data/redux/thunkActions/app.js create mode 100644 src/editors/data/redux/thunkActions/app.test.js create mode 100644 src/editors/data/redux/thunkActions/index.js create mode 100644 src/editors/data/redux/thunkActions/requests.js create mode 100644 src/editors/data/redux/thunkActions/requests.test.js create mode 100644 src/editors/data/redux/thunkActions/testUtils.js create mode 100644 src/editors/data/services/lms/api.js rename src/editors/data/{ => services/lms}/api.test.js (100%) create mode 100644 src/editors/data/services/lms/urls.js create mode 100644 src/editors/data/services/lms/utils.js create mode 100644 src/editors/data/services/lms/utils.test.js create mode 100755 src/editors/data/store.js create mode 100644 src/editors/data/store.test.js create mode 100644 src/editors/hooks.js create mode 100644 src/editors/messages.js create mode 100644 src/editors/utils/StrictDict.js create mode 100644 src/editors/utils/StrictDict.test.js create mode 100644 src/editors/utils/index.js create mode 100644 webpack.prod.config.js diff --git a/.env.development b/.env.development new file mode 100644 index 000000000..549f3c1c5 --- /dev/null +++ b/.env.development @@ -0,0 +1,2 @@ +NODE_ENV='development' +NODE_PATH=./src diff --git a/.eslintrc.js b/.eslintrc.js index 0e8738197..80a39645c 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,3 +1,26 @@ const { createConfig } = require('@edx/frontend-build'); -module.exports = createConfig('eslint'); \ No newline at end of file +const config = createConfig('eslint', { + rules: { + 'import/no-named-as-default': 'off', + 'import/no-named-as-default-member': 'off', + 'import/no-self-import': 'off', + 'spaced-comment': ['error', 'always', { 'block': { 'exceptions': ['*'] } }], + }, +}); + +config.settings = { + "import/resolver": { + alias: { + map: [ + ['editors', './src/editors'], + ], + }, + node: { + paths: ["editors", "node_modules"], + extensions: [".js", ".jsx"], + }, + }, +}; + +module.exports = config; diff --git a/babel.config.js b/babel.config.js index 70e8bc1df..73278f4ec 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,3 +1,3 @@ const { createConfig } = require('@edx/frontend-build'); -module.exports = createConfig('babel-preserve-modules'); +module.exports = createConfig('babel'); diff --git a/jest.config.js b/jest.config.js index 398cf3684..cfe4bb8fc 100644 --- a/jest.config.js +++ b/jest.config.js @@ -4,4 +4,8 @@ module.exports = createConfig('jest', { setupFiles: [ '/src/setupTest.js', ], + modulePaths: ['/src/'], + snapshotSerializers: [ + 'enzyme-to-json/serializer', + ], }); diff --git a/package-lock.json b/package-lock.json index 234bb7322..2558c6bc6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2227,6 +2227,17 @@ "integrity": "sha512-afmTuJrylUU/0OtqzaRkbyYFFNgCF73Bvel/sw90pvGrWIZ+vyoIJqA6eMSoA6+nb443kTmulmBtC9NerXboNg==", "dev": true }, + "@reduxjs/toolkit": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-1.7.2.tgz", + "integrity": "sha512-wwr3//Ar8ZhM9bS58O+HCIaMlR4Y6SNHfuszz9hKnQuFIKvwaL3Kmjo6fpDKUOjo4Lv54Yi299ed8rofCJ/Vjw==", + "requires": { + "immer": "^9.0.7", + "redux": "^4.1.2", + "redux-thunk": "^2.4.1", + "reselect": "^4.1.5" + } + }, "@restart/context": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/@restart/context/-/context-2.1.4.tgz", @@ -2575,6 +2586,15 @@ "@babel/types": "^7.3.0" } }, + "@types/cheerio": { + "version": "0.22.31", + "resolved": "https://registry.npmjs.org/@types/cheerio/-/cheerio-0.22.31.tgz", + "integrity": "sha512-Kt7Cdjjdi2XWSfrZ53v4Of0wG3ZcmaegFXjMmz9tfNrZSkzzo36G0AL1YqSdcIA78Etjt6E609pt5h1xnQkPUw==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/cookie": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.3.3.tgz", @@ -2639,7 +2659,6 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz", "integrity": "sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA==", - "dev": true, "requires": { "@types/react": "*", "hoist-non-react-statics": "^3.3.0" @@ -2735,8 +2754,7 @@ "@types/prop-types": { "version": "15.7.4", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.4.tgz", - "integrity": "sha512-rZ5drC/jWjrArrS8BR6SIr4cWpW09RNTYt9AMZo3Jwwif+iacXAqgVjm0B0Bv/S1jhDXKHqRVNCbACkJ89RAnQ==", - "dev": true + "integrity": "sha512-rZ5drC/jWjrArrS8BR6SIr4cWpW09RNTYt9AMZo3Jwwif+iacXAqgVjm0B0Bv/S1jhDXKHqRVNCbACkJ89RAnQ==" }, "@types/q": { "version": "1.5.5", @@ -2748,7 +2766,6 @@ "version": "17.0.36", "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.36.tgz", "integrity": "sha512-CUFUp01OdfbpN/76v4koqgcpcRGT3sYOq3U3N6q0ZVGcyeP40NUdVU+EWe3hs34RNaTefiYyBzOpxBBidCc5zw==", - "dev": true, "requires": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -2756,10 +2773,9 @@ } }, "@types/react-redux": { - "version": "7.1.20", - "resolved": "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.20.tgz", - "integrity": "sha512-q42es4c8iIeTgcnB+yJgRTTzftv3eYYvCZOh1Ckn2eX/3o5TdsQYKUWpLoLuGlcY/p+VAhV9IOEZJcWk/vfkXw==", - "dev": true, + "version": "7.1.22", + "resolved": "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.22.tgz", + "integrity": "sha512-GxIA1kM7ClU73I6wg9IRTVwSO9GS+SAKZKe0Enj+82HMU6aoESFU2HNAdNi3+J53IaOHPiUfT3kSG4L828joDQ==", "requires": { "@types/hoist-non-react-statics": "^3.3.0", "@types/react": "*", @@ -2785,8 +2801,7 @@ "@types/scheduler": { "version": "0.16.2", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz", - "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==", - "dev": true + "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==" }, "@types/schema-utils": { "version": "2.4.0", @@ -5733,6 +5748,11 @@ } } }, + "deep-diff": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/deep-diff/-/deep-diff-0.3.8.tgz", + "integrity": "sha1-wB3mPvsO7JeYgB1Ax+Da4ltYLIQ=" + }, "deep-equal": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.1.tgz", @@ -6402,6 +6422,17 @@ "object-is": "^1.1.2" } }, + "enzyme-to-json": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/enzyme-to-json/-/enzyme-to-json-3.6.2.tgz", + "integrity": "sha512-Ynm6Z6R6iwQ0g2g1YToz6DWhxVnt8Dy1ijR2zynRKxTyBGA8rCDXU3rs2Qc4OKvUvc2Qoe1bcFK6bnPs20TrTg==", + "dev": true, + "requires": { + "@types/cheerio": "^0.22.22", + "lodash": "^4.17.21", + "react-is": "^16.12.0" + } + }, "error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -6616,6 +6647,11 @@ "object.entries": "^1.1.2" } }, + "eslint-import-resolver-alias": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-alias/-/eslint-import-resolver-alias-1.1.2.tgz", + "integrity": "sha512-WdviM1Eu834zsfjHtcGHtGfcu+F30Od3V7I9Fi57uhBEwPkjDcii7/yW8jAT+gOhn4P/vOxxNAXbFAKsrrc15w==" + }, "eslint-import-resolver-node": { "version": "0.3.6", "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.6.tgz", @@ -8364,7 +8400,6 @@ "version": "3.3.2", "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", - "dev": true, "requires": { "react-is": "^16.7.0" } @@ -9194,8 +9229,7 @@ "immer": { "version": "9.0.7", "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.7.tgz", - "integrity": "sha512-KGllzpbamZDvOIxnmJ0jI840g7Oikx58lBPWV0hUh7dtAyZpFqqrBZdKka5GlTwMTZ1Tjc/bKKW4VSFAt6BqMA==", - "dev": true + "integrity": "sha512-KGllzpbamZDvOIxnmJ0jI840g7Oikx58lBPWV0hUh7dtAyZpFqqrBZdKka5GlTwMTZ1Tjc/bKKW4VSFAt6BqMA==" }, "import-fresh": { "version": "3.3.0", @@ -14485,7 +14519,6 @@ "version": "7.2.6", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.6.tgz", "integrity": "sha512-10RPdsz0UUrRL1NZE0ejTkucnclYSgXp5q+tB5SWx2qeG2ZJQJyymgAhwKy73yiL/13btfB6fPr+rgbMAaZIAQ==", - "dev": true, "requires": { "@babel/runtime": "^7.15.4", "@types/react-redux": "^7.1.20", @@ -14498,8 +14531,7 @@ "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 + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" } } }, @@ -14809,11 +14841,23 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/redux/-/redux-4.1.2.tgz", "integrity": "sha512-SH8PglcebESbd/shgf6mii6EIoRM0zrQyjcuQ+ojmfxjTtE0z9Y8pa62iA/OJ58qjP6j27uyW4kUF4jl/jd6sw==", - "dev": true, "requires": { "@babel/runtime": "^7.9.2" } }, + "redux-devtools-extension": { + "version": "2.13.9", + "resolved": "https://registry.npmjs.org/redux-devtools-extension/-/redux-devtools-extension-2.13.9.tgz", + "integrity": "sha512-cNJ8Q/EtjhQaZ71c8I9+BPySIBVEKssbPpskBfsXqb8HJ002A3KRVHfeRzwRo6mGPqsm7XuHTqNSNeS1Khig0A==" + }, + "redux-logger": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/redux-logger/-/redux-logger-3.0.6.tgz", + "integrity": "sha1-91VZZvMJjzyIYExEnPC69XeCdL8=", + "requires": { + "deep-diff": "^0.3.5" + } + }, "redux-saga": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/redux-saga/-/redux-saga-1.1.3.tgz", @@ -14823,6 +14867,11 @@ "@redux-saga/core": "^1.1.3" } }, + "redux-thunk": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.4.1.tgz", + "integrity": "sha512-OOYGNY5Jy2TWvTL1KgAlVy6dcx3siPJ1wTq741EPyUKfn6W6nChdICjZwCd0p8AZBs5kWpZlbkXW2nE/zjUa+Q==" + }, "reflect.ownkeys": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/reflect.ownkeys/-/reflect.ownkeys-0.2.0.tgz", @@ -15086,6 +15135,11 @@ "integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=", "dev": true }, + "reselect": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-4.1.5.tgz", + "integrity": "sha512-uVdlz8J7OO+ASpBYoz1Zypgx0KasCY20H+N8JD13oUMtPvSHQuscrHop4KbXrbsBcdB9Ds7lVK7eRkBIfO43vQ==" + }, "resolve": { "version": "1.20.0", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.20.0.tgz", diff --git a/package.json b/package.json index 1e11f161d..7a762f9d3 100644 --- a/package.json +++ b/package.json @@ -41,25 +41,32 @@ "@testing-library/react": "12.1.1", "@testing-library/user-event": "^13.5.0", "codecov": "3.8.3", + "eslint-import-resolver-alias": "^1.1.2", "enzyme": "3.11.0", "enzyme-adapter-react-16": "1.15.6", + "enzyme-to-json": "^3.6.2", "husky": "7.0.4", "prop-types": "15.7.2", "react": "16.14.0", "react-dom": "16.14.0", - "react-redux": "7.2.6", "react-router-dom": "5.3.0", "react-test-renderer": "16.14.0", "reactifex": "1.1.1", - "redux": "4.1.2", "redux-saga": "1.1.3" }, "dependencies": { + "@reduxjs/toolkit": "^1.7.2", "@tinymce/tinymce-react": "^3.13.0", - "tinymce": "^5.10.2", "babel-polyfill": "6.26.0", + "react-redux": "^7.2.6", "react-responsive": "8.2.0", - "react-transition-group": "4.4.2" + "react-transition-group": "4.4.2", + "redux": "4.1.2", + "redux-devtools-extension": "^2.13.9", + "redux-logger": "^3.0.6", + "redux-thunk": "^2.4.1", + "reselect": "^4.1.5", + "tinymce": "^5.10.2" }, "peerDependencies": { "@edx/frontend-platform": "^1.8.0", diff --git a/src/editors/Editor.jsx b/src/editors/Editor.jsx new file mode 100644 index 000000000..3529fe7b5 --- /dev/null +++ b/src/editors/Editor.jsx @@ -0,0 +1,86 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from '@edx/frontend-platform/i18n'; + +import { blockTypes } from './data/constants/app'; +import { thunkActions } from './data/redux'; + +import TextEditor from './containers/TextEditor/TextEditor'; +import VideoEditor from './containers/VideoEditor/VideoEditor'; +import ProblemEditor from './containers/ProblemEditor/ProblemEditor'; +import EditorFooter from './components/EditorFooter'; +import EditorHeader from './components/EditorHeader'; + +import messages from './messages'; +import * as hooks from './hooks'; + +export const supportedEditors = { + [blockTypes.html]: TextEditor, + [blockTypes.video]: VideoEditor, + [blockTypes.problem]: ProblemEditor, +}; + +export const Editor = ({ + courseId, + blockType, + blockId, + studioEndpointUrl, + // redux + initialize, +}) => { + hooks.initializeApp({ + initialize, + data: { + blockId, + blockType, + courseId, + studioEndpointUrl, + }, + }); + + const { editorRef, refReady, setEditorRef } = hooks.prepareEditorRef(); + + const EditorComponent = supportedEditors[blockType]; + + return ( +
+
+ {refReady && ( + <> + + {(EditorComponent !== undefined) + ? + : } + + + )} + +
+
+ ); +}; +Editor.defaultProps = { + courseId: null, + blockId: null, + studioEndpointUrl: null, +}; + +Editor.propTypes = { + courseId: PropTypes.string, + blockType: PropTypes.string.isRequired, + blockId: PropTypes.string, + studioEndpointUrl: PropTypes.string, + // redux + initialize: PropTypes.func.isRequired, +}; +export const mapStateToProps = () => ({}); +export const mapDispatchToProps = { + initialize: thunkActions.app.initialize, +}; + +export default connect(mapStateToProps, mapDispatchToProps)(Editor); diff --git a/src/editors/EditorFooter.jsx b/src/editors/EditorFooter.jsx deleted file mode 100644 index 199a13e24..000000000 --- a/src/editors/EditorFooter.jsx +++ /dev/null @@ -1,77 +0,0 @@ -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/EditorHeader.jsx b/src/editors/EditorHeader.jsx deleted file mode 100644 index d0b36f82c..000000000 --- a/src/editors/EditorHeader.jsx +++ /dev/null @@ -1,44 +0,0 @@ -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/EditorPage.jsx b/src/editors/EditorPage.jsx index 64c15bb5e..0298679e6 100644 --- a/src/editors/EditorPage.jsx +++ b/src/editors/EditorPage.jsx @@ -1,67 +1,38 @@ import React from 'react'; import PropTypes from 'prop-types'; -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'; +import { Provider } from 'react-redux'; -export default function EditorPage({ +import store from './data/store'; +import Editor from './Editor'; + +export const EditorPage = ({ courseId, blockType, blockId, studioEndpointUrl, -}) { - const selectEditor = (type) => { - switch (type) { - case 'html': - return ; - case 'video': - return ; - case 'problem': - return ; - default: - return ( - - ); - } - }; +}) => ( + + + +); +EditorPage.defaultProps = { + courseId: null, + blockId: null, + studioEndpointUrl: null, +}; - return ( - -
-
- - {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, -}; + +export default EditorPage; diff --git a/src/editors/EditorPageContext.jsx b/src/editors/EditorPageContext.jsx deleted file mode 100644 index e3c24fe7a..000000000 --- a/src/editors/EditorPageContext.jsx +++ /dev/null @@ -1,4 +0,0 @@ -import React from 'react'; - -const EditorPageContext = React.createContext(); -export default EditorPageContext; diff --git a/src/editors/EditorPageProvider.jsx b/src/editors/EditorPageProvider.jsx deleted file mode 100644 index 1ef924329..000000000 --- a/src/editors/EditorPageProvider.jsx +++ /dev/null @@ -1,97 +0,0 @@ -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/TextEditor/TextEditor.jsx b/src/editors/TextEditor/TextEditor.jsx deleted file mode 100644 index 45bfb4f4c..000000000 --- a/src/editors/TextEditor/TextEditor.jsx +++ /dev/null @@ -1,78 +0,0 @@ -import React, { useContext } from 'react'; -import { Editor } from '@tinymce/tinymce-react'; -import { FormattedMessage } from '@edx/frontend-platform/i18n'; -import { - useToggle, Spinner, Toast, -} from '@edx/paragon'; -import EditorPageContext from '../EditorPageContext'; -import { ActionStates } from '../data/constants'; -import ImageUploadModal from './ImageUpload/Wizard/ImageUploadModal'; - -import 'tinymce'; -import 'tinymce/themes/silver'; -import 'tinymce/skins/ui/oxide/skin.css'; -import 'tinymce/icons/default'; -import 'tinymce/plugins/link'; -import 'tinymce/plugins/table'; -import 'tinymce/plugins/codesample'; -import 'tinymce/plugins/emoticons'; -import 'tinymce/plugins/emoticons/js/emojis'; -import 'tinymce/plugins/charmap'; -import 'tinymce/plugins/code'; -import 'tinymce/plugins/autoresize'; - -const TextEditor = () => { - const { - blockValue, blockError, blockLoading, editorRef, - } = useContext(EditorPageContext); - - const [isImageUploadModalOpen, openUploadModal, closeUploadModal] = useToggle(false); - - return ( -
- - {}}> - - - {blockLoading !== ActionStates.FINISHED - ? ( -
- -
- ) - : ( - { - editorRef.current = editor; - }} - initialValue={blockValue ? blockValue.data.data : ''} - init={{ - setup: (editor) => { - editor.ui.registry.addButton('imageuploadbutton', { - icon: 'image', - onAction: () => openUploadModal(), - }); - }, - plugins: 'link codesample emoticons table charmap code autoresize', - menubar: false, - toolbar: 'undo redo | formatselect | ' - + 'bold italic backcolor | alignleft aligncenter ' - + 'alignright alignjustify | bullist numlist outdent indent |' - + 'imageuploadbutton | link | emoticons | table | codesample | charmap |' - + 'removeformat | hr |code', - height: '100%', - content_style: 'body { font-family:Helvetica,Arial,sans-serif; font-size:14px }', - min_height: 1000, - branding: false, - }} - /> - )} -
- ); -}; - -export default TextEditor; diff --git a/src/editors/components/EditorFooter/index.jsx b/src/editors/components/EditorFooter/index.jsx new file mode 100644 index 000000000..728245a4e --- /dev/null +++ b/src/editors/components/EditorFooter/index.jsx @@ -0,0 +1,89 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; + +import { + Spinner, + ActionRow, + Button, + ModalDialog, + Toast, +} from '@edx/paragon'; +import { FormattedMessage } from '@edx/frontend-platform/i18n'; + +import { RequestKeys } from '../../data/constants/requests'; +import { selectors, thunkActions } from '../../data/redux'; +import { saveTextBlock, navigateCallback } from '../../hooks'; +import messages from '../messages'; +import * as module from '.'; + +export const handleSaveClicked = (props) => () => saveTextBlock(props); +export const handleCancelClicked = ({ returnUrl }) => navigateCallback(returnUrl); + +export const EditorFooter = ({ + editorRef, + // redux + isInitialized, + returnUrl, + saveFailed, + saveBlock, +}) => ( +
+ {saveFailed && ( + + )} + + + + + + + + +
+); +EditorFooter.defaultProps = { + editorRef: null, + returnUrl: null, +}; +EditorFooter.propTypes = { + editorRef: PropTypes.oneOfType([ + PropTypes.func, + PropTypes.shape({ current: PropTypes.any }), + ]), + // redux + isInitialized: PropTypes.bool.isRequired, + returnUrl: PropTypes.string, + saveFailed: PropTypes.bool.isRequired, + saveBlock: PropTypes.func.isRequired, +}; + +export const mapStateToProps = (state) => ({ + isInitialized: selectors.app.isInitialized(state), + saveFailed: selectors.requests.isFailed(state, { requestKey: RequestKeys.saveBlock }), + studioEndpointUrl: selectors.app.studioEndpointUrl(state), +}); + +export const mapDispatchToProps = { + saveBlock: thunkActions.app.saveBlock, +}; + +export default connect(mapStateToProps, mapDispatchToProps)(EditorFooter); diff --git a/src/editors/EditorFooter.test.jsx b/src/editors/components/EditorFooter/index.test.jsx similarity index 100% rename from src/editors/EditorFooter.test.jsx rename to src/editors/components/EditorFooter/index.test.jsx diff --git a/src/editors/components/EditorHeader/.HeaderTitle.jsx.swp b/src/editors/components/EditorHeader/.HeaderTitle.jsx.swp new file mode 100644 index 0000000000000000000000000000000000000000..0046a380691f4726ec4a5e6dff42a847dfd3af94 GIT binary patch literal 12288 zcmeI2UuYaf9LJ{?YFk?)`dH9ugx*E(c9TdjB)Jfq*d}OgiD?u?WbStEZp_~9c4w|h zx}4&RKB`~^ee07V=(G6HHxYc(2SGs)L|>$eK3P!kJNxH$Hx+|LXR{X4R?*+ccJtbow?g-_F(k_^v zj7_VYIXtwJ+lEjsw_cGuqca`C(2^wgwQ;^Fbr^nv>?A8=7j`bGhx zfKk9GU=%P47zK<1MuCH+fC>(g*O2mEY1%KO_d|Q`=}&Vr3K#{90!9I&fKk9GU=%P4 z7zK<1MggOMQQ&{50CNcOju7(a-3T84|DXN)|K~A6Zh{ZM`=ARN;GcU5`5SxzZh#P+ z2j@T)oC3$e?URK33Vs6bf$Lx!^uWvD5?BO}f>Yoa`0E5AKZ38pP4FrB2z&_M1#f~Y zpam|1$H9Z(DEQ+ZLVf{1fN#M!;7jl^5MTqG2Q%Oya01*qPRRG*b8rK^1>OJ=xC$iL z1j}FvoB@Zy@5c}y_zZjkUI&-KBcKA#fZIn2`3hVI?|`>~3zk3wEC8+fcUpII83l|2 zMggOMQQ*H(fbZ~~zz-F5c$@WIMNwJj@s_Ig*c}^!XBAU?!@m^zfvnLU3s!^-m}+%X z+eEccXg4uW$Y7pOD!Dp~g1@b}{BDl2mJ+@vEfxe;si{WNnGuRq`wUMTG{Tq|m{K9v zJfQ@0#Wn8CVGb9;qXaM>w$xaaI_ilUI;q`&ClT)Lk+%%p_Xbl;9+9Mew6RF5Iw~Dh zKFS=`B5hzkX-O3duVV?h&ad=E$Q>O@Df6OtyYIF3_?FTs`Zj&v-Q#Q96f^X9FvUx8 z&a@awmj%2U(N;eUQ9D^2ig~+qFw#K>qoYmgE7y6u*ah{M7u!0;>WieX?pBax9vC!_Sq#-x?-m)uNTa5^NCseuakj6N@1Zpe^#eD)6vX1ZB`rs&J9Z`K~)Z)L% u(Xns0LU6+mO`5qre(QQ6OQT{?{dpe_7IJ)t>sg}d>a1&HI!x? ( + + } + value={localTitle} + /> + +); +EditableHeader.defaultProps = { + inputRef: null, +}; +EditableHeader.propTypes = { + inputRef: PropTypes.oneOfType([ + PropTypes.func, + PropTypes.shape({ current: PropTypes.any }), + ]), + handleChange: PropTypes.func.isRequired, + updateTitle: PropTypes.func.isRequired, + handleKeyDown: PropTypes.func.isRequired, + localTitle: PropTypes.string.isRequired, +}; + +export default EditableHeader; diff --git a/src/editors/components/EditorHeader/HeaderTitle.jsx b/src/editors/components/EditorHeader/HeaderTitle.jsx new file mode 100644 index 000000000..49c3020f1 --- /dev/null +++ b/src/editors/components/EditorHeader/HeaderTitle.jsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; + +import { Icon, IconButton } from '@edx/paragon'; +import { Edit } from '@edx/paragon/icons'; +import { FormattedMessage } from '@edx/frontend-platform/i18n'; + +import { actions, selectors } from '../../data/redux'; +import messages from '../messages'; +import EditableHeader from './EditableHeader'; +import { localTitleHooks } from './hooks'; + +export const HeaderTitle = ({ + editorRef, + isInitialized, + setBlockTitle, + typeHeader, +}) => { + if (!isInitialized) { return ; } + + console.log('HeaderTitle'); + const { + inputRef, + isEditing, + handleChange, + handleKeyDown, + localTitle, + startEditing, + updateTitle, + } = localTitleHooks({ + editorRef, + setBlockTitle, + typeHeader, + }); + + if (isEditing) { + return ( + + ); + } + return ( +
+
+ {localTitle} +
+ +
+ ); +}; +HeaderTitle.defaultProps = { + editorRef: null, +}; +HeaderTitle.propTypes = { + editorRef: PropTypes.oneOfType([ + PropTypes.func, + PropTypes.shape({ current: PropTypes.any }), + ]), + // redux + isInitialized: PropTypes.bool.isRequired, + setBlockTitle: PropTypes.func.isRequired, + typeHeader: PropTypes.string.isRequired, +}; + +export const mapStateToProps = (state) => ({ + typeHeader: selectors.app.typeHeader(state), + isInitialized: selectors.app.isInitialized(state), +}); + +export const mapDispatchToProps = { + setBlockTitle: actions.app.setBlockTitle, +}; + +export default connect(mapStateToProps, mapDispatchToProps)(HeaderTitle); diff --git a/src/editors/components/EditorHeader/hooks.js b/src/editors/components/EditorHeader/hooks.js new file mode 100644 index 000000000..2b2c7fd6c --- /dev/null +++ b/src/editors/components/EditorHeader/hooks.js @@ -0,0 +1,42 @@ +import React from 'react'; + +/* eslint-disable import/prefer-default-export */ +export const localTitleHooks = ({ + editorRef, + setBlockTitle, + typeHeader, +}) => { + console.log('localTitleHooks'); + const [isEditing, setIsEditing] = React.useState(false); + const startEditing = () => setIsEditing(true); + const stopEditing = () => setIsEditing(false); + const [localTitle, setLocalTitle] = React.useState(typeHeader); + const inputRef = React.createRef(); + const updateTitle = () => { + setBlockTitle(localTitle); + stopEditing(); + }; + + const handleKeyDown = (e) => { + if (e.key === 'Enter') { + stopEditing(); + } + if (e.key === 'Tab' && editorRef) { + e.preventDefault(); + editorRef.current.focus(); + } + }; + + const handleChange = (e) => setLocalTitle(e.target.value); + + return { + isEditing, + handleChange, + startEditing, + stopEditing, + localTitle, + inputRef, + handleKeyDown, + updateTitle, + }; +}; diff --git a/src/editors/components/EditorHeader/index.jsx b/src/editors/components/EditorHeader/index.jsx new file mode 100644 index 000000000..f8f0179f9 --- /dev/null +++ b/src/editors/components/EditorHeader/index.jsx @@ -0,0 +1,43 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; + +import { + ActionRow, IconButton, Icon, ModalDialog, +} from '@edx/paragon'; +import { Close } from '@edx/paragon/icons'; + +import { selectors } from '../../data/redux'; +import * as appHooks from '../../hooks'; + +import HeaderTitle from './HeaderTitle'; + +const EditorHeader = ({ + returnUrl, +}) => ( +
+ + + + + + + +
+); +EditorHeader.propTypes = { + returnUrl: PropTypes.string.isRequired, +}; +export const mapStateToProps = (state) => ({ + returnUrl: selectors.app.returnUrl(state), +}); + +export default connect(mapStateToProps)(EditorHeader); diff --git a/src/editors/EditorHeader.test.jsx b/src/editors/components/EditorHeader/index.test.jsx similarity index 100% rename from src/editors/EditorHeader.test.jsx rename to src/editors/components/EditorHeader/index.test.jsx diff --git a/src/editors/components/messages.js b/src/editors/components/messages.js new file mode 100644 index 000000000..4e911f94c --- /dev/null +++ b/src/editors/components/messages.js @@ -0,0 +1,19 @@ +export const messages = { + contentSaveFailed: { + id: 'authoring.editorfooter.save.error', + defaultMessage: 'Error: Content save failed. Try again later.', + description: 'Error message displayed when content fails to save.', + }, + addToCourse: { + id: 'authoring.editorfooter.savebutton.label', + defaultMessage: 'Save', + description: 'Label for Save button', + }, + loading: { + id: 'authoring.texteditor.title.loading', + description: 'Message displayed while loading content', + defaultMessage: 'Loading...', + }, +}; + +export default messages; diff --git a/src/editors/ProblemEditor/ProblemEditor.jsx b/src/editors/containers/ProblemEditor/ProblemEditor.jsx similarity index 100% rename from src/editors/ProblemEditor/ProblemEditor.jsx rename to src/editors/containers/ProblemEditor/ProblemEditor.jsx diff --git a/src/editors/ProblemEditor/ProblemEditor.test.jsx b/src/editors/containers/ProblemEditor/ProblemEditor.test.jsx similarity index 100% rename from src/editors/ProblemEditor/ProblemEditor.test.jsx rename to src/editors/containers/ProblemEditor/ProblemEditor.test.jsx diff --git a/src/editors/TextEditor/ImageUpload/Wizard/ImageUploadModal.jsx b/src/editors/containers/TextEditor/ImageUpload/ImageUploadModal.jsx similarity index 100% rename from src/editors/TextEditor/ImageUpload/Wizard/ImageUploadModal.jsx rename to src/editors/containers/TextEditor/ImageUpload/ImageUploadModal.jsx diff --git a/src/editors/containers/TextEditor/TextEditor.jsx b/src/editors/containers/TextEditor/TextEditor.jsx new file mode 100644 index 000000000..c4b9a4163 --- /dev/null +++ b/src/editors/containers/TextEditor/TextEditor.jsx @@ -0,0 +1,93 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import { Editor } from '@tinymce/tinymce-react'; + +import 'tinymce'; +import 'tinymce/themes/silver'; +import 'tinymce/skins/ui/oxide/skin.css'; +import 'tinymce/icons/default'; +import 'tinymce/plugins/link'; +import 'tinymce/plugins/table'; +import 'tinymce/plugins/codesample'; +import 'tinymce/plugins/emoticons'; +import 'tinymce/plugins/emoticons/js/emojis'; +import 'tinymce/plugins/charmap'; +import 'tinymce/plugins/code'; +import 'tinymce/plugins/autoresize'; + +import { FormattedMessage } from '@edx/frontend-platform/i18n'; +import { + Spinner, + Toast, +} from '@edx/paragon'; + +import { actions, selectors } from '../../data/redux'; +import { RequestKeys } from '../../data/constants/requests'; +import { + editorConfig, + modalToggle, + nullMethod, +} from './hooks'; +import messages from './messages'; +import ImageUploadModal from './ImageUpload/ImageUploadModal'; + +export const TextEditor = ({ + setEditorRef, + // redux + blockValue, + blockFailed, + blockFinished, + initializeEditor, +}) => { + console.log({ blockValue, blockFailed, blockFinished, test: 1 }); + const { isOpen, openModal, closeModal } = modalToggle(); + + return ( +
+ + + + + + + {(!blockFinished) + ? ( +
+ +
+ ) + : ( + + )} +
+ ); +}; +TextEditor.defaultProps = { + blockValue: null, +}; +TextEditor.propTypes = { + setEditorRef: PropTypes.func.isRequired, + // redux + blockValue: PropTypes.shape({ + data: PropTypes.shape({ data: PropTypes.string }), + }), + blockFailed: PropTypes.bool.isRequired, + blockFinished: PropTypes.bool.isRequired, + initializeEditor: PropTypes.func.isRequired, +}; + +export const mapStateToProps = (state) => ({ + blockValue: selectors.app.blockValue(state), + blockFailed: selectors.requests.isFailed(state, { requestKey: RequestKeys.fetchBlock }), + blockFinished: selectors.requests.isFinished(state, { requestKey: RequestKeys.fetchBlock }), +}); + +export const mapDispatchToProps = { + initializeEditor: actions.app.initializeEditor, +}; + +export default connect(mapStateToProps, mapDispatchToProps)(TextEditor); diff --git a/src/editors/TextEditor/TextEditor.test.jsx b/src/editors/containers/TextEditor/TextEditor.test.jsx similarity index 100% rename from src/editors/TextEditor/TextEditor.test.jsx rename to src/editors/containers/TextEditor/TextEditor.test.jsx diff --git a/src/editors/containers/TextEditor/hooks.js b/src/editors/containers/TextEditor/hooks.js new file mode 100644 index 000000000..e432b0843 --- /dev/null +++ b/src/editors/containers/TextEditor/hooks.js @@ -0,0 +1,50 @@ +import { useState } from 'react'; +import * as module from './hooks'; + +export const addImageUploadButton = (openModal) => (editor) => { + editor.ui.registry.addButton('imageuploadbutton', { + icon: 'image', + onAction: openModal, + }); +}; + +export const initializeEditorRef = (setRef) => (evt, editor) => { setRef(editor); }; + +// for toast onClose to avoid console warnings +export const nullMethod = () => {}; + +export const editorConfig = ({ + setEditorRef, + blockValue, + openModal, + initializeEditor, +}) => ({ + onInit: () => { + module.initializeEditorRef(setEditorRef); + initializeEditor(); + }, + initialValue: blockValue ? blockValue.data.data : '', + init: { + setup: module.addImageUploadButton(openModal), + plugins: 'link codesample emoticons table charmap code autoresize', + menubar: false, + toolbar: 'undo redo | formatselect | ' + + 'bold italic backcolor | alignleft aligncenter ' + + 'alignright alignjustify | bullist numlist outdent indent |' + + 'imageuploadbutton | link | emoticons | table | codesample | charmap |' + + 'removeformat | hr |code', + height: '100%', + content_style: 'body { font-family:Helvetica,Arial,sans-serif; font-size:14px }', + min_height: 1000, + branding: false, + }, +}); + +export const modalToggle = () => { + const [isOpen, setIsOpen] = useState(false); + return { + isOpen, + openModal: () => setIsOpen(true), + closeModal: () => setIsOpen(false), + }; +}; diff --git a/src/editors/containers/TextEditor/messages.js b/src/editors/containers/TextEditor/messages.js new file mode 100644 index 000000000..5d6ad820a --- /dev/null +++ b/src/editors/containers/TextEditor/messages.js @@ -0,0 +1,9 @@ +export const messages = { + couldNotLoadTextContext: { + id: 'authoring.texteditor.load.error', + defaultMessage: 'Error: Could Not Load Text Content', + description: 'Error Message Dispayed When HTML content fails to Load', + }, +}; + +export default messages; diff --git a/src/editors/VideoEditor/VideoEditor.jsx b/src/editors/containers/VideoEditor/VideoEditor.jsx similarity index 100% rename from src/editors/VideoEditor/VideoEditor.jsx rename to src/editors/containers/VideoEditor/VideoEditor.jsx diff --git a/src/editors/VideoEditor/VideoEditor.test.jsx b/src/editors/containers/VideoEditor/VideoEditor.test.jsx similarity index 100% rename from src/editors/VideoEditor/VideoEditor.test.jsx rename to src/editors/containers/VideoEditor/VideoEditor.test.jsx diff --git a/src/editors/data/api.js b/src/editors/data/api.js deleted file mode 100644 index 856f7184d..000000000 --- a/src/editors/data/api.js +++ /dev/null @@ -1,42 +0,0 @@ -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/${blockId}`; - const params = [url, normalizedContent]; - saveAsync(updateContext, params); -} diff --git a/src/editors/data/constants.js b/src/editors/data/constants.js deleted file mode 100644 index 063d67cbb..000000000 --- a/src/editors/data/constants.js +++ /dev/null @@ -1,31 +0,0 @@ -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/editors/data/constants/app.js b/src/editors/data/constants/app.js new file mode 100644 index 000000000..82911452c --- /dev/null +++ b/src/editors/data/constants/app.js @@ -0,0 +1,8 @@ +import { StrictDict } from '../../utils'; + +/* eslint-disable import/prefer-default-export */ +export const blockTypes = StrictDict({ + html: 'html', + video: 'video', + problem: 'problem', +}); diff --git a/src/editors/data/constants/requests.js b/src/editors/data/constants/requests.js new file mode 100644 index 000000000..d2ac9ccc6 --- /dev/null +++ b/src/editors/data/constants/requests.js @@ -0,0 +1,20 @@ +import { StrictDict } from '../../utils'; + +export const ReqeustKeys = StrictDict({ + fetchBlock: 'fetchBlock', + fetchUnit: 'fetchUnit', + saveBlock: 'saveBlock', +}); + +export const RequestStates = StrictDict({ + inactive: 'inactive', + pending: 'pending', + completed: 'completed', + failed: 'failed', +}); + +export const RequestKeys = StrictDict({ + fetchBlock: 'fetchBlock', + fetchUnit: 'fetchUnit', + saveBlock: 'saveBlock', +}); diff --git a/src/editors/data/redux/app/.selectors.js.swp b/src/editors/data/redux/app/.selectors.js.swp new file mode 100644 index 0000000000000000000000000000000000000000..e8be6db756dd573373e5c9255f2df3f5fabfe969 GIT binary patch literal 12288 zcmeI2&yN&E6vqp=fG%M4ASQ&=O2YK$Zucx)$ZiG>g9)P&qOhw8?xAP8z;=7Oo2u$% zVY2K6O~8|9O-#4~iSb1I5Bzm8diUzVKf&m?s=KFWrrBX{#`rqjl^%22`i=Ec#&V|;+&bw#VG78_U&hl_aIGT5o={_L=fskDlhQ0?@1A4 zTb`e$o)VGhn-2FARUeJMAp%5TlE7hh=JZm_e0TEodH%}FIOOyY0U|&IhyW2F0z`la z5CJ0azagO0eQXD_-CxZ3O!3|~^)8z9A_7E!2oM1xKm>>Y5g-CYfCvx)B0vQGg9QA5 zvCj`OrVb%_{QtlF`~SD2jQt8dgloy z`Df?{=zHiX^bq<2x(97SW?mAahX@b>B0vO)01+Sp|003y6~?)~or;UX4}@GmOA4LI zcvVKG5vpz+>d=qEJ0dV&xUQ?hK^loGWx?YD6I*E_H4nsfKZ`UU;#ZRi+cfr^FY_L1 zFgLDZkZY}VS0}4!DrCo3!fEn1t4>#CJ*8zB-*A?i*7H1XToqfofrc}R(d6xO+^O2U z-ELPwl>4zBM8^HvAPKTaOlRwk0Wmy*V?+z_OF@S zApInhN_2zSzNM9QvB$}7%h^tqUFBx3ggtl}v&!jt+1Y`*LFLfN>$vwYP+ ({ + ...state, + studioEndpointUrl: payload.studioEndpointUrl, + blockId: payload.blockId, + courseId: payload.courseId, + blockType: payload.blockType, + }), + setUnitUrl: (state, { payload }) => ({ ...state, unitUrl: payload }), + setBlockValue: (state, { payload }) => ({ ...state, blockValue: payload }), + setBlockContent: (state, { payload }) => ({ ...state, blockContent: payload }), + setBlockTitle: (state, { payload }) => ({ ...state, blockTitle: payload }), + setSaveResponse: (state, { payload }) => ({ ...state, saveResponse: payload }), + initializeEditor: (state) => ({ ...state, editorInitialized: true }), + }, +}); + +const actions = StrictDict(app.actions); + +const { reducer } = app; + +export { + actions, + initialState, + reducer, +}; diff --git a/src/editors/data/redux/app/selectors.js b/src/editors/data/redux/app/selectors.js new file mode 100644 index 000000000..f83a82ee6 --- /dev/null +++ b/src/editors/data/redux/app/selectors.js @@ -0,0 +1,51 @@ +import { createSelector } from 'reselect'; + +import { blockTypes } from '../../constants/app'; +import * as urls from '../../services/lms/urls'; +import * as module from './selectors'; + +export const appSelector = (state) => state.app; + +const mkSimpleSelector = (cb) => createSelector([module.appSelector], cb); + +// top-level app data selectors +export const simpleSelectors = { + blockContent: mkSimpleSelector(app => app.blockContent), + blockId: mkSimpleSelector(app => app.blockId), + blockType: mkSimpleSelector(app => app.blockType), + blockValue: mkSimpleSelector(app => app.blockValue), + courseId: mkSimpleSelector(app => app.courseId), + editorInitialized: mkSimpleSelector(app => app.editorInitialized), + saveResponse: mkSimpleSelector(app => app.saveResponse), + studioEndpointUrl: mkSimpleSelector(app => app.studioEndpointUrl), + unitUrl: mkSimpleSelector(app => app.unitUrl), +}; + +export const returnUrl = createSelector( + [module.simpleSelectors.unitUrl, module.simpleSelectors.studioEndpointUrl], + (unitUrl, studioEndpointUrl) => (unitUrl ? urls.unit({ studioEndpointUrl, unitUrl }) : ''), +); + +export const isInitialized = createSelector( + [ + module.simpleSelectors.unitUrl, + module.simpleSelectors.editorInitialized, + module.simpleSelectors.blockValue, + ], + (unitUrl, editorInitialized, blockValue) => !!(unitUrl && blockValue && editorInitialized), +); + +export const typeHeader = createSelector( + [module.simpleSelectors.blockType], + (blockType) => ((blockType === blockTypes.html) + ? 'Text' + : blockType[0].toUpperCase() + blockType.substring(1) + ), +); + +export default { + ...simpleSelectors, + isInitialized, + returnUrl, + typeHeader, +}; diff --git a/src/editors/data/redux/index.js b/src/editors/data/redux/index.js new file mode 100644 index 000000000..c81b22e4a --- /dev/null +++ b/src/editors/data/redux/index.js @@ -0,0 +1,28 @@ +import { combineReducers } from 'redux'; + +import { StrictDict } from '../../utils'; + +import * as app from './app'; +import * as requests from './requests'; + +export { default as thunkActions } from './thunkActions'; + +const modules = { + app, + requests, +}; + +const moduleProps = (propName) => Object.keys(modules).reduce( + (obj, moduleKey) => ({ ...obj, [moduleKey]: modules[moduleKey][propName] }), + {}, +); + +const rootReducer = combineReducers(moduleProps('reducer')); + +const actions = StrictDict(moduleProps('actions')); + +const selectors = StrictDict(moduleProps('selectors')); + +export { actions, selectors }; + +export default rootReducer; diff --git a/src/editors/data/redux/requests/index.js b/src/editors/data/redux/requests/index.js new file mode 100644 index 000000000..8abd5f91d --- /dev/null +++ b/src/editors/data/redux/requests/index.js @@ -0,0 +1,2 @@ +export { actions, reducer } from './reducer'; +export { default as selectors } from './selectors'; diff --git a/src/editors/data/redux/requests/reducer.js b/src/editors/data/redux/requests/reducer.js new file mode 100644 index 000000000..b552cd771 --- /dev/null +++ b/src/editors/data/redux/requests/reducer.js @@ -0,0 +1,52 @@ +import { createSlice } from '@reduxjs/toolkit'; + +import { StrictDict } from '../../../utils'; + +import { RequestStates, RequestKeys } from '../../constants/requests'; + +const initialState = { + [RequestKeys.fetchUnit]: { status: RequestStates.inactive }, + [RequestKeys.fetchBlock]: { status: RequestStates.inactive }, + [RequestKeys.saveBlock]: { status: RequestStates.inactive }, +}; + +// eslint-disable-next-line no-unused-vars +const requests = createSlice({ + name: 'requests', + initialState, + reducers: { + startRequest: (state, { payload }) => ({ + ...state, + [payload]: { + status: RequestStates.pending, + }, + }), + completeRequest: (state, { payload }) => ({ + ...state, + [payload.requestKey]: { + status: RequestStates.completed, + response: payload.response, + }, + }), + failRequest: (state, { payload }) => ({ + ...state, + [payload.requestKey]: { + status: RequestStates.failed, + error: payload.error, + }, + }), + clearRequest: (state, { payload }) => ({ + ...state, + [payload.requestKey]: {}, + }), + }, +}); + +const actions = StrictDict(requests.actions); +const { reducer } = requests; + +export { + actions, + reducer, + initialState, +}; diff --git a/src/editors/data/redux/requests/selectors.js b/src/editors/data/redux/requests/selectors.js new file mode 100644 index 000000000..2a4ba632e --- /dev/null +++ b/src/editors/data/redux/requests/selectors.js @@ -0,0 +1,38 @@ +import { StrictDict } from '../../../utils'; +import { RequestStates } from '../../constants/requests'; +import * as module from './selectors'; + +export const requestStatus = (state, { requestKey }) => state.requests[requestKey]; + +const statusSelector = (fn) => (state, { requestKey }) => fn(state.requests[requestKey]); + +export const isInactive = ({ status }) => status === RequestStates.inactive; +export const isPending = ({ status }) => status === RequestStates.pending; +export const isCompleted = ({ status }) => status === RequestStates.completed; +export const isFailed = ({ status }) => status === RequestStates.failed; +export const isFinished = ({ status }) => ( + [RequestStates.failed, RequestStates.completed].includes(status) +); +export const error = (request) => request.error; +export const errorStatus = (request) => request.error?.response?.status; +export const errorCode = (request) => request.error?.response?.data; + +export const data = (request) => request.data; + +export const allowNavigation = ({ requests }) => ( + !Object.keys(requests).some(requestKey => module.isPending(requests[requestKey])) +); + +export default StrictDict({ + requestStatus, + allowNavigation, + isInactive: statusSelector(isInactive), + isPending: statusSelector(isPending), + isCompleted: statusSelector(isCompleted), + isFailed: statusSelector(isFailed), + isFinished: statusSelector(isFinished), + error: statusSelector(error), + errorCode: statusSelector(errorCode), + errorStatus: statusSelector(errorStatus), + data: statusSelector(data), +}); diff --git a/src/editors/data/redux/thunkActions/.requests.js.swp b/src/editors/data/redux/thunkActions/.requests.js.swp new file mode 100644 index 0000000000000000000000000000000000000000..c4d7c512b482529eb92be66c20c0c0801cda13ca GIT binary patch literal 12288 zcmeI2&x;&I6vr#Z-!VoaUDY!?vujrHU}7zNnC_le zud2TF>b)Men&U^O578suE{4DR7`wQCOLN{xI1T)G*!Mq0{{Fwbg|Q#Nd*B3^00+PW zU_00bzP^{S(_jMpvYD}Oz#Cu|Yz7zZVeA|@3*G^5fiB3v^I!+Kwu!Ot!J8lj2f;>g z?QX{Y1b=|v!6k4O90S|HR`A_jjC}%L2gkv7@bjIF{Rl3Di{M*u0h|M8fC6=J7V2iI2_ZiYMnI9W9}tfEu^At?BU6*AS98hx9GH=YRhDGvTKKBqy3Lk%{r}P`;lLBORfoY?Ei z31SP!uq0p8k3?)Hb6q>C&U>Yn76k=XAVUfa6wsYV#$JHAY9* z8SG}E2Lo-*^~W~+7Xp4o6qQ?fNpD|7+}}r1VOwtWe;P?u(=w9G=D#5W$VQ0`gKBC6 zVbB*)D@4|t*9O&%7%Mu{O)RZ1SSD=6uqk4Fd8gD%x4lWjrO@TN@lse#hie9muBUw) z(JzmbqO6)Dr7dLHDzn=hg_=;OBE@3&nOYpRs8%KA5nAqLWs{8eQ5Su6N4%gY9{nl$ zI%CksVPmfN>f^9kdT=NvotHx~H#KWKQjSJ&m5YQ-HuI2@lUmP47bcy}s=ymkM=D<^ zJ1nCa#L=1z)HPepda~$JnkHso`o@exU-n#8??YuZU0G|3$o@}P$Y;v8jjo*>3aw6E z{3H%H+h_fi*;dq^0BB)uNa@;&f=f||=20u+Lbpv@q@z^73#5XispxpBE392wZK$mz zitw}|Kb9%^sQsCey;UGT3404<609U-=_V(f>5fs@7a}!ZcusSbX=IF*J6C-v2R)Tuj)C{BC=Xz>-*u|l83?qexDlIEYgfr-P4<998Qu(Gw zIIP~GonCd|eCIyK`pxvx8XK?Sc@4?M$q4UPjkqHl_!9QUeA%dgnO3jq)U6u#8_x9J hL;EbW`(jJ@`joth5ZO1N<-T7I1MSc>R|o}I{{``jqBH;i literal 0 HcmV?d00001 diff --git a/src/editors/data/redux/thunkActions/app.js b/src/editors/data/redux/thunkActions/app.js new file mode 100644 index 000000000..bd53bb704 --- /dev/null +++ b/src/editors/data/redux/thunkActions/app.js @@ -0,0 +1,50 @@ +import { StrictDict } from '../../../utils'; +import { actions, selectors } from '..'; +import * as requests from './requests'; + +export const fetchBlock = () => (dispatch) => { + dispatch(requests.fetchBlock({ + onSuccess: (response) => dispatch(actions.app.setBlockValue(response)), + onFailure: (e) => dispatch(actions.app.setBlockValue(e)), + })); +}; + +export const fetchUnit = () => (dispatch) => { + dispatch(requests.fetchUnit({ + onSuccess: (response) => dispatch(actions.app.setUnitUrl(response)), + onFailure: (e) => dispatch(actions.app.setUnitUrl(e)), + })); +}; + +/** + * @param {string} studioEndpointUrl + * @param {string} blockId + * @param {string} courseId + * @param {string} blockType + */ +export const initialize = (data) => (dispatch) => { + dispatch(actions.app.initialize(data)); + dispatch(fetchBlock()); + dispatch(fetchUnit()); +}; + +/** + * @param {func} onSuccess + */ +export const saveBlock = ({ content, returnToUnit }) => (dispatch, getState) => { + dispatch(actions.app.setBlockContent(content)); + dispatch(requests.saveBlock({ + content, + onSuccess: (response) => { + dispatch(actions.app.setSaveResponse(response)); + returnToUnit(); + }, + })); +}; + +export default StrictDict({ + fetchBlock, + fetchUnit, + initialize, + saveBlock, +}); diff --git a/src/editors/data/redux/thunkActions/app.test.js b/src/editors/data/redux/thunkActions/app.test.js new file mode 100644 index 000000000..28d9ff524 --- /dev/null +++ b/src/editors/data/redux/thunkActions/app.test.js @@ -0,0 +1,42 @@ +import { locationId } from './data/constants/app'; + +import { actions } from './data/redux'; +import thunkActions from './app'; + +jest.mock('./requests', () => ({ + initializeApp: (args) => ({ initializeApp: args }), +})); + +describe('app thunkActions', () => { + let dispatch; + let dispatchedAction; + beforeEach(() => { + dispatch = jest.fn((action) => ({ dispatch: action })); + }); + describe('initialize', () => { + beforeEach(() => { + thunkActions.initialize()(dispatch); + [[dispatchedAction]] = dispatch.mock.calls; + }); + it('dispatches initializeApp with locationId and onSuccess', () => { + expect(dispatchedAction.initializeApp.locationId).toEqual(locationId); + expect(typeof dispatchedAction.initializeApp.onSuccess).toEqual('function'); + }); + describe('on success', () => { + test('loads oraMetadata, courseMetadata and list data', () => { + dispatch.mockClear(); + const response = { + oraMetadata: { some: 'ora-metadata' }, + courseMetadata: { some: 'course-metadata' }, + submissions: { some: 'submissions' }, + }; + dispatchedAction.initializeApp.onSuccess(response); + expect(dispatch.mock.calls).toEqual([ + [actions.app.loadOraMetadata(response.oraMetadata)], + [actions.app.loadCourseMetadata(response.courseMetadata)], + [actions.submissions.loadList(response.submissions)], + ]); + }); + }); + }); +}); diff --git a/src/editors/data/redux/thunkActions/index.js b/src/editors/data/redux/thunkActions/index.js new file mode 100644 index 000000000..4e6372ec0 --- /dev/null +++ b/src/editors/data/redux/thunkActions/index.js @@ -0,0 +1,7 @@ +import { StrictDict } from '../../../utils'; + +import app from './app'; + +export default StrictDict({ + app, +}); diff --git a/src/editors/data/redux/thunkActions/requests.js b/src/editors/data/redux/thunkActions/requests.js new file mode 100644 index 000000000..1389d7447 --- /dev/null +++ b/src/editors/data/redux/thunkActions/requests.js @@ -0,0 +1,95 @@ +import { StrictDict } from '../../../utils'; + +import { RequestKeys } from '../../constants/requests'; +import { actions, selectors } from '..'; +import * as api from '../../services/lms/api'; + +import * as module from './requests'; + +/** + * Wrapper around a network request promise, that sends actions to the redux store to + * track the state of that promise. + * Tracks the promise by requestKey, and sends an action when it is started, succeeds, or + * fails. It also accepts onSuccess and onFailure methods to be called with the output + * of failure or success of the promise. + * @param {string} requestKey - request tracking identifier + * @param {Promise} promise - api event promise + * @param {[func]} onSuccess - onSuccess method ((response) => { ... }) + * @param {[func]} onFailure - onFailure method ((error) => { ... }) + */ +export const networkRequest = ({ + requestKey, + promise, + onSuccess, + onFailure, +}) => (dispatch) => { + dispatch(actions.requests.startRequest(requestKey)); + return promise.then((response) => { + if (onSuccess) { onSuccess(response); } + dispatch(actions.requests.completeRequest({ requestKey, response })); + }).catch((error) => { + if (onFailure) { onFailure(error); } + dispatch(actions.requests.failRequest({ requestKey, error })); + }); +}; + +/** + * Tracked fetchByBlockId api method. + * Tracked to the `fetchBlock` request key. + * @param {[func]} onSuccess - onSuccess method ((response) => { ... }) + * @param {[func]} onFailure - onFailure method ((error) => { ... }) + */ +export const fetchBlock = ({ ...rest }) => (dispatch, getState) => { + dispatch(module.networkRequest({ + requestKey: RequestKeys.fetchBlock, + promise: api.fetchBlockById({ + studioEndpointUrl: selectors.app.studioEndpointUrl(getState()), + blockId: selectors.app.blockId(getState()), + }), + ...rest, + })); +}; + +/** + * Tracked fetchByUnitId api method. + * Tracked to the `fetchUnit` request key. + * @param {[func]} onSuccess - onSuccess method ((response) => { ... }) + * @param {[func]} onFailure - onFailure method ((error) => { ... }) + */ +export const fetchUnit = ({ ...rest }) => (dispatch, getState) => { + dispatch(module.networkRequest({ + requestKey: RequestKeys.fetchUnit, + promise: api.fetchByUnitId({ + studioEndpointUrl: selectors.app.studioEndpointUrl(getState()), + blockId: selectors.app.blockId(getState()), + }), + ...rest, + })); +}; + +/** + * Tracked saveBlock api method. Tracked to the `saveBlock` request key. + * @param {string} content + * @param {[func]} onSuccess - onSuccess method ((response) => { ... }) + * @param {[func]} onFailure - onFailure method ((error) => { ... }) + */ +export const saveBlock = ({ content, ...rest }) => (dispatch, getState) => { + dispatch(module.networkRequest({ + requestKey: RequestKeys.saveBlock, + promise: api.saveBlock({ + blockId: selectors.app.blockId(getState()), + blockType: selectors.app.blockType(getState()), + courseId: selectors.app.courseId(getState()), + content, + studioEndpointUrl: selectors.app.studioEndpointUrl(getState()), + title: selectors.app.title(getState()), + }), + ...rest, + })); +}; + +export default StrictDict({ + fetchUnit, + fetchBlock, + saveBlock, +}); diff --git a/src/editors/data/redux/thunkActions/requests.test.js b/src/editors/data/redux/thunkActions/requests.test.js new file mode 100644 index 000000000..9b67968f4 --- /dev/null +++ b/src/editors/data/redux/thunkActions/requests.test.js @@ -0,0 +1,179 @@ +import { actions } from 'data/redux'; +import { RequestKeys } from 'data/constants/requests'; +import api from 'data/services/lms/api'; +import * as requests from './requests'; + +jest.mock('data/services/lms/api', () => ({ + initializeApp: (locationId) => ({ initializeApp: locationId }), + fetchSubmissionStatus: (submissionUUID) => ({ fetchSubmissionStatus: submissionUUID }), + fetchSubmission: (submissionUUID) => ({ fetchSubmission: submissionUUID }), + lockSubmission: ({ submissionUUID }) => ({ lockSubmission: { submissionUUID } }), + unlockSubmission: ({ submissionUUID }) => ({ unlockSubmission: { submissionUUID } }), + updateGrade: (submissionUUID, gradeData) => ({ updateGrade: { submissionUUID, gradeData } }), +})); + +let dispatch; +let onSuccess; +let onFailure; +describe('requests thunkActions module', () => { + beforeEach(() => { + dispatch = jest.fn(); + onSuccess = jest.fn(); + onFailure = jest.fn(); + }); + + describe('networkRequest', () => { + const requestKey = 'test-request'; + const testData = { some: 'test data' }; + let resolveFn; + let rejectFn; + beforeEach(() => { + onSuccess = jest.fn(); + onFailure = jest.fn(); + requests.networkRequest({ + requestKey, + promise: new Promise((resolve, reject) => { + resolveFn = resolve; + rejectFn = reject; + }), + onSuccess, + onFailure, + })(dispatch); + }); + test('calls startRequest action with requestKey', async () => { + expect(dispatch.mock.calls).toEqual([[actions.requests.startRequest(requestKey)]]); + }); + describe('on success', () => { + beforeEach(async () => { + await resolveFn(testData); + }); + it('dispatches completeRequest', async () => { + expect(dispatch.mock.calls).toEqual([ + [actions.requests.startRequest(requestKey)], + [actions.requests.completeRequest({ requestKey, response: testData })], + ]); + }); + it('calls onSuccess with response', async () => { + expect(onSuccess).toHaveBeenCalledWith(testData); + expect(onFailure).not.toHaveBeenCalled(); + }); + }); + describe('on failure', () => { + beforeEach(async () => { + await rejectFn(testData); + }); + test('dispatches completeRequest', async () => { + expect(dispatch.mock.calls).toEqual([ + [actions.requests.startRequest(requestKey)], + [actions.requests.failRequest({ requestKey, error: testData })], + ]); + }); + test('calls onSuccess with response', async () => { + expect(onFailure).toHaveBeenCalledWith(testData); + expect(onSuccess).not.toHaveBeenCalled(); + }); + }); + }); + + const testNetworkRequestAction = ({ + action, + args, + expectedData, + expectedString, + }) => { + let dispatchedAction; + beforeEach(() => { + action({ ...args, onSuccess, onFailure })(dispatch); + [[dispatchedAction]] = dispatch.mock.calls; + }); + it('dispatches networkRequest', () => { + expect(dispatchedAction.networkRequest).not.toEqual(undefined); + }); + test('forwards onSuccess and onFailure', () => { + expect(dispatchedAction.networkRequest.onSuccess).toEqual(onSuccess); + expect(dispatchedAction.networkRequest.onFailure).toEqual(onFailure); + }); + test(expectedString, () => { + expect(dispatchedAction.networkRequest).toEqual({ + ...expectedData, + onSuccess, + onFailure, + }); + }); + }; + + describe('network request actions', () => { + const submissionUUID = 'test-submission-id'; + const locationId = 'test-location-id'; + beforeEach(() => { + requests.networkRequest = jest.fn(args => ({ networkRequest: args })); + }); + describe('initializeApp', () => { + testNetworkRequestAction({ + action: requests.initializeApp, + args: { locationId }, + expectedString: 'with initialize key, initializeApp promise', + expectedData: { + requestKey: RequestKeys.initialize, + promise: api.initializeApp(locationId), + }, + }); + }); + describe('fetchSubmissionStatus', () => { + testNetworkRequestAction({ + action: requests.fetchSubmissionStatus, + args: { submissionUUID }, + expectedString: 'with fetchSubmissionStatus promise', + expectedData: { + requestKey: RequestKeys.fetchSubmissionStatus, + promise: api.fetchSubmissionStatus(submissionUUID), + }, + }); + }); + describe('fetchSubmission', () => { + testNetworkRequestAction({ + action: requests.fetchSubmission, + args: { submissionUUID }, + expectedString: 'with fetchSubmission promise', + expectedData: { + requestKey: RequestKeys.fetchSubmission, + promise: api.fetchSubmission(submissionUUID), + }, + }); + }); + describe('setLock: true', () => { + testNetworkRequestAction({ + action: requests.setLock, + args: { submissionUUID, value: true }, + expectedString: 'with setLock promise', + expectedData: { + requestKey: RequestKeys.setLock, + promise: api.lockSubmission(submissionUUID), + }, + }); + }); + describe('setLock: false', () => { + testNetworkRequestAction({ + action: requests.setLock, + args: { submissionUUID, value: false }, + expectedString: 'with setLock promise', + expectedData: { + requestKey: RequestKeys.setLock, + promise: api.unlockSubmission(submissionUUID), + }, + }); + }); + describe('submitGrade', () => { + const gradeData = 'test-grade-data'; + testNetworkRequestAction({ + action: requests.submitGrade, + args: { submissionUUID, gradeData }, + expectedString: 'with submitGrade promise', + expectedData: { + requestKey: RequestKeys.submitGrade, + promise: api.updateGrade(submissionUUID, gradeData), + }, + }); + }); + }); +}); diff --git a/src/editors/data/redux/thunkActions/testUtils.js b/src/editors/data/redux/thunkActions/testUtils.js new file mode 100644 index 000000000..387d84c5f --- /dev/null +++ b/src/editors/data/redux/thunkActions/testUtils.js @@ -0,0 +1,53 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import configureMockStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; + +const mockStore = configureMockStore([thunk]); + +/** createTestFetcher(mockedMethod, thunkAction, args, onDispatch) + * Creates a testFetch method, which will test a given thunkAction of the form: + * ``` + * const = () => (dispatch, getState) => { + * ... + * return .then().catch(); + * ``` + * The returned function will take a promise handler function, a list of expected actions + * to have been dispatched (objects only), and an optional verifyFn method to be called after + * the fetch has been completed. + * + * @param {fn} mockedMethod - already-mocked api method being exercised by the thunkAction. + * @param {fn} thunkAction - thunkAction to call/test + * @param {array} args - array of args to dispatch the thunkAction with + * @param {[fn]} onDispatch - optional function to be called after dispatch + * + * @return {fn} testFetch method + * @param {fn} resolveFn - promise handler of the form (resolve, reject) => {}. + * should return a call to resolve or reject with response data. + * @param {object[]} expectedActions - array of action objects expected to have been dispatched + * will be verified after the thunkAction resolves + * @param {[fn]} verifyFn - optional function to be called after dispatch + */ +export const createTestFetcher = ( + mockedMethod, + thunkAction, + args, + onDispatch, +) => ( + resolveFn, + expectedActions, +) => { + const store = mockStore({}); + mockedMethod.mockReturnValue(new Promise(resolve => { + resolve(new Promise(resolveFn)); + })); + return store.dispatch(thunkAction(...args)).then(() => { + onDispatch(); + if (expectedActions !== undefined) { + expect(store.getActions()).toEqual(expectedActions); + } + }); +}; + +export default { + createTestFetcher, +}; diff --git a/src/editors/data/services/lms/api.js b/src/editors/data/services/lms/api.js new file mode 100644 index 000000000..c0b6bff99 --- /dev/null +++ b/src/editors/data/services/lms/api.js @@ -0,0 +1,48 @@ +import * as urls from './urls'; +import { get, post } from './utils'; + +export const fetchBlockById = ({ blockId, studioEndpointUrl }) => get( + urls.block({ blockId, studioEndpointUrl }), +); + +export const fetchByUnitId = ({ blockId, studioEndpointUrl }) => get( + urls.blockAncestor({ studioEndpointUrl, blockId }), +); + +export const normalizeContent = ({ + blockId, + blockType, + content, + courseId, + title, +}) => { + if (blockType === 'html') { + return { + category: blockType, + couseKey: courseId, + data: content, + has_changes: true, + id: blockId, + metadata: { display_name: title }, + }; + } + throw new TypeError(`No Block in V2 Editors named /"${blockType}/", Cannot Save Content.`); +}; + +export const saveBlock = ({ + blockId, + blockType, + content, + courseId, + studioEndpointUrl, + title, +}) => post( + urls.block({ studioEndpointUrl, blockId }), + normalizeContent({ + blockType, + content, + blockId, + courseId, + title, + }), +); diff --git a/src/editors/data/api.test.js b/src/editors/data/services/lms/api.test.js similarity index 100% rename from src/editors/data/api.test.js rename to src/editors/data/services/lms/api.test.js diff --git a/src/editors/data/services/lms/urls.js b/src/editors/data/services/lms/urls.js new file mode 100644 index 000000000..cfa5030af --- /dev/null +++ b/src/editors/data/services/lms/urls.js @@ -0,0 +1,11 @@ +export const unit = ({ studioEndpointUrl, unitUrl }) => ( + `${studioEndpointUrl}/container/${unitUrl.data.ancestors[0].id}` +); + +export const block = ({ studioEndpointUrl, blockId }) => ( + `${studioEndpointUrl}/xblock/${blockId}` +); + +export const blockAncestor = ({ studioEndpointUrl, blockId }) => ( + `${block({ studioEndpointUrl, blockId })}?fields=ancestorInfo` +); diff --git a/src/editors/data/services/lms/utils.js b/src/editors/data/services/lms/utils.js new file mode 100644 index 000000000..c34782e2b --- /dev/null +++ b/src/editors/data/services/lms/utils.js @@ -0,0 +1,17 @@ +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; + +/** + * get(url) + * simple wrapper providing an authenticated Http client get action + * @param {string} url - target url + */ +export const get = (...args) => getAuthenticatedHttpClient().get(...args); +/** + * post(url, data) + * simple wrapper providing an authenticated Http client post action + * @param {string} url - target url + * @param {object|string} data - post payload + */ +export const post = (...args) => getAuthenticatedHttpClient().post(...args); + +export const client = getAuthenticatedHttpClient; diff --git a/src/editors/data/services/lms/utils.test.js b/src/editors/data/services/lms/utils.test.js new file mode 100644 index 000000000..a033bbae7 --- /dev/null +++ b/src/editors/data/services/lms/utils.test.js @@ -0,0 +1,39 @@ +import queryString from 'query-string'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import * as utils from './utils'; + +jest.mock('query-string', () => ({ + stringifyUrl: jest.fn((url, options) => ({ url, options })), +})); +jest.mock('@edx/frontend-platform/auth', () => ({ + getAuthenticatedHttpClient: jest.fn(), +})); + +describe('lms service utils', () => { + describe('get', () => { + it('forwards arguments to authenticatedHttpClient().get', () => { + const get = jest.fn((...args) => ({ get: args })); + getAuthenticatedHttpClient.mockReturnValue({ get }); + const args = ['some', 'args', 'for', 'the', 'test']; + expect(utils.get(...args)).toEqual(get(...args)); + }); + }); + describe('post', () => { + it('forwards arguments to authenticatedHttpClient().post', () => { + const post = jest.fn((...args) => ({ post: args })); + getAuthenticatedHttpClient.mockReturnValue({ post }); + const args = ['some', 'args', 'for', 'the', 'test']; + expect(utils.post(...args)).toEqual(post(...args)); + }); + }); + describe('stringifyUrl', () => { + it('forwards url and query to stringifyUrl with options to skip null and ""', () => { + const url = 'here.com'; + const query = { some: 'set', of: 'queryParams' }; + const options = { skipNull: true, skipEmptyString: true }; + expect(utils.stringifyUrl(url, query)).toEqual( + queryString.stringifyUrl({ url, query }, options), + ); + }); + }); +}); diff --git a/src/editors/data/store.js b/src/editors/data/store.js new file mode 100755 index 000000000..4e73ca33a --- /dev/null +++ b/src/editors/data/store.js @@ -0,0 +1,32 @@ +import * as redux from 'redux'; +import thunkMiddleware from 'redux-thunk'; +import { composeWithDevTools } from 'redux-devtools-extension/logOnlyInProduction'; +import { createLogger } from 'redux-logger'; + +import reducer, { actions, selectors } from './redux'; + +export const createStore = () => { + const loggerMiddleware = createLogger(); + + const middleware = [thunkMiddleware, loggerMiddleware]; + + const store = redux.createStore( + reducer, + composeWithDevTools(redux.applyMiddleware(...middleware)), + ); + + /** + * Dev tools for redux work + */ + if (process.env.NODE_ENV === 'development') { + window.store = store; + window.actions = actions; + window.selectors = selectors; + } + + return store; +}; + +const store = createStore(); + +export default store; diff --git a/src/editors/data/store.test.js b/src/editors/data/store.test.js new file mode 100644 index 000000000..f44bc0fb7 --- /dev/null +++ b/src/editors/data/store.test.js @@ -0,0 +1,66 @@ +import { applyMiddleware } from 'redux'; +import thunkMiddleware from 'redux-thunk'; +import { composeWithDevTools } from 'redux-devtools-extension/logOnlyInProduction'; +import { createLogger } from 'redux-logger'; + +import rootReducer, { actions, selectors } from 'data/redux'; + +import exportedStore, { createStore } from './store'; + +jest.mock('data/redux', () => ({ + __esModule: true, + default: 'REDUCER', + actions: 'ACTIONS', + selectors: 'SELECTORS', +})); + +jest.mock('redux-logger', () => ({ + createLogger: () => 'logger', +})); +jest.mock('redux-thunk', () => 'thunkMiddleware'); +jest.mock('redux', () => ({ + applyMiddleware: (...middleware) => ({ applied: middleware }), + createStore: (reducer, middleware) => ({ reducer, middleware }), +})); +jest.mock('redux-devtools-extension/logOnlyInProduction', () => ({ + composeWithDevTools: (middleware) => ({ withDevTools: middleware }), +})); + +describe('store aggregator module', () => { + describe('exported store', () => { + it('is generated by createStore', () => { + expect(exportedStore).toEqual(createStore()); + }); + it('creates store with connected reducers', () => { + expect(createStore().reducer).toEqual(rootReducer); + }); + describe('middleware', () => { + it('exports thunk and logger middleware, composed and applied with dev tools', () => { + expect(createStore().middleware).toEqual( + composeWithDevTools(applyMiddleware(thunkMiddleware, createLogger())), + ); + }); + }); + }); + describe('dev exposed tools', () => { + beforeEach(() => { + window.store = undefined; + window.actions = undefined; + window.selectors = undefined; + }); + it('exposes redux tools if in development env', () => { + process.env.NODE_ENV = 'development'; + const store = createStore(); + expect(window.store).toEqual(store); + expect(window.actions).toEqual(actions); + expect(window.selectors).toEqual(selectors); + }); + it('does not expose redux tools if in production env', () => { + process.env.NODE_ENV = 'production'; + createStore(); + expect(window.store).toEqual(undefined); + expect(window.actions).toEqual(undefined); + expect(window.selectors).toEqual(undefined); + }); + }); +}); diff --git a/src/editors/hooks.js b/src/editors/hooks.js new file mode 100644 index 000000000..a86147665 --- /dev/null +++ b/src/editors/hooks.js @@ -0,0 +1,30 @@ +import { useRef, useEffect, useCallback, useState } from 'react'; + +export const initializeApp = ({ initialize, data }) => useEffect(() => initialize(data), []); + +export const prepareEditorRef = () => { + const editorRef = useRef(null); + const setEditorRef = useCallback((ref) => { + editorRef.current = ref; + }, []); + const [refReady, setRefReady] = useState(false); + useEffect(() => setRefReady(true), []); + return { editorRef, refReady, setEditorRef }; +}; + +export const navigateTo = (destination) => { + window.location.assign(destination); +}; + +export const navigateCallback = (destination) => () => navigateTo(destination); + +export const saveTextBlock = ({ + editorRef, + returnUrl, + saveBlock, +}) => { + saveBlock({ + returnToUnit: module.navigateCallback(returnUrl), + content: editorRef.current.getContent(), + }); +}; diff --git a/src/editors/messages.js b/src/editors/messages.js new file mode 100644 index 000000000..4aa6a0ccb --- /dev/null +++ b/src/editors/messages.js @@ -0,0 +1,9 @@ +export const messages = { + couldNotFindEditor: { + id: 'authoring.editorpage.selecteditor.error', + defaultMessage: 'Error: Could Not find Editor', + description: 'Error Message Dispayed When An unsopported Editor is desired in V2', + }, +}; + +export default messages; diff --git a/src/editors/utils/StrictDict.js b/src/editors/utils/StrictDict.js new file mode 100644 index 000000000..d73ac811d --- /dev/null +++ b/src/editors/utils/StrictDict.js @@ -0,0 +1,24 @@ +/* eslint-disable no-console */ +const strictGet = (target, name) => { + if (name === Symbol.toStringTag) { + return target; + } + + if (name in target || name === '_reactFragment') { + return target[name]; + } + + if (name === '$$typeof') { + return typeof target; + } + + console.log(name.toString()); + console.error({ target, name }); + const e = Error(`invalid property "${name.toString()}"`); + console.error(e.stack); + return undefined; +}; + +const StrictDict = (dict) => new Proxy(dict, { get: strictGet }); + +export default StrictDict; diff --git a/src/editors/utils/StrictDict.test.js b/src/editors/utils/StrictDict.test.js new file mode 100644 index 000000000..276f50d2c --- /dev/null +++ b/src/editors/utils/StrictDict.test.js @@ -0,0 +1,62 @@ +import StrictDict from './StrictDict'; + +const value1 = 'valUE1'; +const value2 = 'vALue2'; +const key1 = 'Key1'; +const key2 = 'keY2'; + +jest.spyOn(window, 'Error').mockImplementation(error => ({ stack: error })); + +describe('StrictDict', () => { + let consoleError; + let consoleLog; + let windowError; + beforeEach(() => { + consoleError = window.console.error; + consoleLog = window.console.lot; + windowError = window.Error; + window.console.error = jest.fn(); + window.console.log = jest.fn(); + window.Error = jest.fn(error => ({ stack: error })); + }); + afterAll(() => { + window.console.error = consoleError; + window.console.log = consoleLog; + window.Error = windowError; + }); + const rawDict = { + [key1]: value1, + [key2]: value2, + }; + const dict = StrictDict(rawDict); + it('provides key access like a normal dict object', () => { + expect(dict[key1]).toEqual(value1); + }); + it('allows key listing', () => { + expect(Object.keys(dict)).toEqual([key1, key2]); + }); + it('allows item listing', () => { + expect(Object.values(dict)).toEqual([value1, value2]); + }); + it('allows stringification', () => { + expect(dict.toString()).toEqual(rawDict.toString()); + expect({ ...dict }).toEqual({ ...rawDict }); + }); + it('allows entry listing', () => { + expect(Object.entries(dict)).toEqual(Object.entries(rawDict)); + }); + describe('missing key', () => { + it('logs error with target, name, and error stack', () => { + // eslint-ignore-next-line no-unused-vars + const callBadKey = () => dict.fakeKey; + callBadKey(); + expect(window.console.error.mock.calls).toEqual([ + [{ target: dict, name: 'fakeKey' }], + [Error('invalid property "fakeKey"').stack], + ]); + }); + it('returns undefined', () => { + expect(dict.fakeKey).toEqual(undefined); + }); + }); +}); diff --git a/src/editors/utils/index.js b/src/editors/utils/index.js new file mode 100644 index 000000000..5351f65e0 --- /dev/null +++ b/src/editors/utils/index.js @@ -0,0 +1,2 @@ +/* eslint-disable import/prefer-default-export */ +export { default as StrictDict } from './StrictDict'; diff --git a/webpack.dev.config.js b/webpack.dev.config.js index 4676f4ee2..a46c30af8 100644 --- a/webpack.dev.config.js +++ b/webpack.dev.config.js @@ -2,12 +2,16 @@ const path = require('path'); const { createConfig } = require('@edx/frontend-build'); module.exports = createConfig('webpack-dev', { - entry: path.resolve(__dirname, 'example'), + entry: path.resolve(__dirname, 'src'), output: { - path: path.resolve(__dirname, 'example/dist'), + path: path.resolve(__dirname, 'dist'), publicPath: '/', }, resolve: { + modules: [ + path.resolve(__dirname, './src'), + 'node_modules', + ], alias: { '@edx/frontend-lib-content-components': path.resolve(__dirname, 'src'), }, diff --git a/webpack.prod.config.js b/webpack.prod.config.js new file mode 100644 index 000000000..f54e28cc8 --- /dev/null +++ b/webpack.prod.config.js @@ -0,0 +1,14 @@ +const path = require('path'); +const { createConfig } = require('@edx/frontend-build'); + +const config = createConfig('webpack-prod'); + +config.resolve.modules = [ + path.resolve(__dirname, 'src'), + path.resolve(__dirname, 'dist'), + 'node_modules', +]; + +config.module.rules[0].exclude = /node_modules\/(?!(query-string|split-on-first|strict-uri-encode|@edx))/; + +module.exports = config;