diff --git a/.eslintrc.js b/.eslintrc.js index 0cf4053e..67b4107f 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,9 +1,32 @@ /* eslint-disable import/no-extraneous-dependencies */ - +const path = require('path'); const { createConfig } = require('@openedx/frontend-build'); module.exports = createConfig('eslint', { + settings: { + 'import/resolver': { + webpack: { + config: path.resolve(__dirname, 'webpack.dev.config.js'), + }, + alias: { + map: [ + ['@communications-app', '.'], + ], + extensions: ['.ts', '.js', '.jsx', '.json'], + }, + }, + }, rules: { 'react/function-component-definition': 'off', + 'import/prefer-default-export': 'off', + 'import/no-extraneous-dependencies': 'off', }, + overrides: [ + { + files: ['plugins/**/*.jsx'], + rules: { + 'import/no-extraneous-dependencies': 'off', + }, + }, + ], }); diff --git a/docs/how_tos/create_an_external_plugin_ui_slot.rst b/docs/how_tos/create_an_external_plugin_ui_slot.rst new file mode 100644 index 00000000..8b956c2c --- /dev/null +++ b/docs/how_tos/create_an_external_plugin_ui_slot.rst @@ -0,0 +1,180 @@ +######################################## +How to create an external plugin UI slot +######################################## + +To create a new plugin that will live outside the MFE host, it will be in npm. The plugin needs to be compiled by `Babel `_, that way webpack will be available to read the plugin. Here's an example for a small plugin: + + +**1. Plugin Structure:** + +The plugin will have the following structure: + + - root + - package.json + - .babelrc + - Makefile + - src + - index.jsx + - index.css + + +**Example files:** + +**1.1 package.json:** + +This file contains the necessary dependencies for the plugin and also a Babel script to transpile the files inside the ``src`` folder of the plugin. + +.. code-block:: json + + { + "name": "@openedx-plugins/example-plugin", + "version": "1.0.0", + "scripts": { + "babel:compile": "babel src --out-dir dist --source-maps --ignore '**/*.test.jsx,**/*.test.js' --copy-files" + }, + "peerDependencies": { + "@edx/browserslist-config": "^1.2.0", + "@edx/frontend-app-communications": "https://github.com/eduNEXT/frontend-app-communications.git#jv/pluggable-component-slot", + "@edx/frontend-build": "13.0.1", + "@edx/frontend-platform": "5.5.2", + "@edx/paragon": "^20.44.0", + "eslint-import-resolver-webpack": "^0.13.8", + "react": "17.0.2", + "prop-types": "^15.8.1", + "react-dom": "17.0.2" + }, + "peerDependenciesMeta": { + "@edx/frontend-app-communications": { + "optional": true + } + }, + "devDependencies": { + "@babel/cli": "^7.23.4", + "@babel/core": "^7.23.7", + "@babel/eslint-parser": "^7.23.3", + "@babel/plugin-proposal-class-properties": "^7.18.6", + "@babel/plugin-proposal-object-rest-spread": "^7.20.7", + "@babel/preset-env": "^7.23.8", + "@babel/preset-react": "^7.23.3", + "@edx/browserslist-config": "^1.2.0", + "@edx/frontend-build": "13.0.1" + } + } + +**1.2. .babelrc:** + +The essential configuration for Babel. This configuration works for any plugin. You could set it up if you need it. + +.. code-block:: json + + { + "presets": [ + [ + "@babel/preset-env", + { + "modules": false + } + ], + "@babel/preset-react" + ], + "plugins": [ + "@babel/plugin-proposal-object-rest-spread", + "@babel/plugin-proposal-class-properties" + ], + "env": { + "test": { + "plugins": ["@babel/plugin-proposal-class-properties"], + "presets": [["@babel/preset-env"], "@babel/preset-react"] + } + }, + "ignore": ["**/*.test.jsx", "**/*.test.js"] + } + + +**1.3. Makefile:** + +This will create a folder called ``package`` that will have the compiled Babel files ready to upload to, for example, npm. + +.. code-block:: makefile + + .ONESHELL: clean build + + clean: + # Remove the package folder if it exists + rm -rf package + rm -rf dist + + build: clean + # Run npm run babel + npm run babel:compile + + # Create the package folder + mkdir -p package + + # Copy package.json to the package folder + cp package.json package/ + + # Move files from dist folder to package folder + find dist -type f ! -name "*.map" ! -name "*.test.js" ! -name "*.test.jsx" -exec cp {} package/ \; + + # Rename index.js to index.jsx in the package folder + rm -rf package/index.js + cp dist/index.js package/index.jsx + + +**1.4. index.jsx:** + +This will be the content of the plugin. In this case, it's a simple div. + +.. code-block:: jsx + + import React from 'react'; + + import './index.css'; + + const MyPlugin = () => { + return ( +
+

Hello, World!

+ {/* Add your plugin UI components here */} +
+ ); + } + + export default MyPlugin; + +**1.5. index.css:** + +Styles for the plugin. + +.. code-block:: css + + .openedx-plugin { + background-color: red; + color: white; + } + + +**2. Install Dependencies:** + +After having the plugin structure, you can install the dependencies: + +.. code-block:: bash + + npm install + + +**3. Compile the Plugin:** + +Now you can compile the plugin by running the Makefile. This will create a folder called ``package`` with the necessary compiled files. Run the following command: + +.. code-block:: bash + + make build + +Notice that inside the ``package`` folder, there will always be a file called ``index.jsx`` and not ``index.js``. This ensures that when loading components, lazy loading won't have any problems. + + +**4. Upload the Plugin:** + +Now that you have compiled the plugin, you can upload it to npm for example. Inside the ``package`` folder you can publish the plugin from there diff --git a/jest.config.js b/jest.config.js index 1df99c3d..32eb33b9 100644 --- a/jest.config.js +++ b/jest.config.js @@ -10,4 +10,8 @@ module.exports = createConfig('jest', { 'src/setupTest.js', 'src/i18n', ], + moduleNameMapper: { + '@node_modules/(.*)': '/node_modules/$1', + '@communications-app/(.*)': '/$1' + }, }); diff --git a/package-lock.json b/package-lock.json index eaab566c..956148ce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,14 @@ "@fortawesome/free-regular-svg-icons": "5.15.4", "@fortawesome/free-solid-svg-icons": "5.15.4", "@fortawesome/react-fontawesome": "0.2.0", + "@loadable/component": "^5.15.3", + "@openedx-plugins/communications-app-body-email-form": "file:plugins/communications-app/BodyForm", + "@openedx-plugins/communications-app-instructions-pro-freading": "file:plugins/communications-app/InstructionsProfreading", + "@openedx-plugins/communications-app-recipients-checks": "file:plugins/communications-app/RecipientsForm", + "@openedx-plugins/communications-app-schedule-section": "file:plugins/communications-app/ScheduleSection", + "@openedx-plugins/communications-app-subject-form": "file:plugins/communications-app/SubjectForm", + "@openedx-plugins/communications-app-task-alert-modal": "file:plugins/communications-app/TaskAlertModalForm", + "@openedx-plugins/communications-app-test-component": "file:plugins/communications-app/TestComponent", "@openedx/paragon": "^22.0.0", "@tinymce/tinymce-react": "3.14.0", "axios": "0.27.2", @@ -36,7 +44,8 @@ "react-router-dom": "6.15.0", "redux": "4.2.0", "regenerator-runtime": "0.13.11", - "tinymce": "5.10.7" + "tinymce": "5.10.7", + "use-deep-compare-effect": "^1.8.1" }, "devDependencies": { "@edx/browserslist-config": "^1.2.0", @@ -44,7 +53,11 @@ "@openedx/frontend-build": "13.0.27", "@testing-library/jest-dom": "5.16.5", "@testing-library/react": "12.1.5", + "@testing-library/react-hooks": "^8.0.1", "axios-mock-adapter": "1.21.2", + "eslint-import-resolver-alias": "^1.1.2", + "eslint-import-resolver-webpack": "^0.13.8", + "eslint-plugin-import": "^2.29.1", "glob": "7.2.3", "husky": "7.0.4", "jest": "27.5.1", @@ -4535,6 +4548,31 @@ "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz", "integrity": "sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A==" }, + "node_modules/@loadable/component": { + "version": "5.16.3", + "resolved": "https://registry.npmjs.org/@loadable/component/-/component-5.16.3.tgz", + "integrity": "sha512-2mVvHs2988oVX2/zM0y6nYhJ4rTVHhkhRnpupBA0Rjl5tS8op9uSR4u5SLVfMLxzpspr2UiIBQD+wEuMsuq4Dg==", + "dependencies": { + "@babel/runtime": "^7.7.7", + "hoist-non-react-statics": "^3.3.1", + "react-is": "^16.12.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "react": "^16.3.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@loadable/component/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, "node_modules/@newrelic/publish-sourcemap": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@newrelic/publish-sourcemap/-/publish-sourcemap-5.1.0.tgz", @@ -4646,6 +4684,34 @@ "node": ">= 8" } }, + "node_modules/@openedx-plugins/communications-app-body-email-form": { + "resolved": "plugins/communications-app/BodyForm", + "link": true + }, + "node_modules/@openedx-plugins/communications-app-instructions-pro-freading": { + "resolved": "plugins/communications-app/InstructionsProfreading", + "link": true + }, + "node_modules/@openedx-plugins/communications-app-recipients-checks": { + "resolved": "plugins/communications-app/RecipientsForm", + "link": true + }, + "node_modules/@openedx-plugins/communications-app-schedule-section": { + "resolved": "plugins/communications-app/ScheduleSection", + "link": true + }, + "node_modules/@openedx-plugins/communications-app-subject-form": { + "resolved": "plugins/communications-app/SubjectForm", + "link": true + }, + "node_modules/@openedx-plugins/communications-app-task-alert-modal": { + "resolved": "plugins/communications-app/TaskAlertModalForm", + "link": true + }, + "node_modules/@openedx-plugins/communications-app-test-component": { + "resolved": "plugins/communications-app/TestComponent", + "link": true + }, "node_modules/@openedx/frontend-build": { "version": "13.0.27", "resolved": "https://registry.npmjs.org/@openedx/frontend-build/-/frontend-build-13.0.27.tgz", @@ -4915,6 +4981,14 @@ "wrap-ansi": "^6.2.0" } }, + "node_modules/@openedx/frontend-build/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dependencies": { + "ms": "^2.1.1" + } + }, "node_modules/@openedx/frontend-build/node_modules/diff-sequences": { "version": "26.6.2", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-26.6.2.tgz", @@ -4923,6 +4997,17 @@ "node": ">= 10.14.2" } }, + "node_modules/@openedx/frontend-build/node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/@openedx/frontend-build/node_modules/emittery": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.7.2.tgz", @@ -4934,6 +5019,34 @@ "url": "https://github.com/sindresorhus/emittery?sponsor=1" } }, + "node_modules/@openedx/frontend-build/node_modules/eslint-plugin-import": { + "version": "2.27.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.27.5.tgz", + "integrity": "sha512-LmEt3GVofgiGuiE+ORpnvP+kAm3h6MLZJ4Q5HCyHADofsb4VzXFsRiWj3c0OFiV+3DWFh0qg3v9gcPlfc3zRow==", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "array.prototype.flatmap": "^1.3.1", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.7", + "eslint-module-utils": "^2.7.4", + "has": "^1.0.3", + "is-core-module": "^2.11.0", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.values": "^1.1.6", + "resolve": "^1.22.1", + "semver": "^6.3.0", + "tsconfig-paths": "^3.14.1" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8" + } + }, "node_modules/@openedx/frontend-build/node_modules/execa": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/execa/-/execa-4.1.0.tgz", @@ -6183,6 +6296,36 @@ "react-dom": "<18.0.0" } }, + "node_modules/@testing-library/react-hooks": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/react-hooks/-/react-hooks-8.0.1.tgz", + "integrity": "sha512-Aqhl2IVmLt8IovEVarNDFuJDVWVvhnr9/GCU6UUnrYXwgDFF9h2L2o2P9KBni1AST5sT6riAyoukFLyjQUgD/g==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "react-error-boundary": "^3.1.0" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "@types/react": "^16.9.0 || ^17.0.0", + "react": "^16.9.0 || ^17.0.0", + "react-dom": "^16.9.0 || ^17.0.0", + "react-test-renderer": "^16.9.0 || ^17.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-test-renderer": { + "optional": true + } + } + }, "node_modules/@tinymce/tinymce-react": { "version": "3.14.0", "resolved": "https://registry.npmjs.org/@tinymce/tinymce-react/-/tinymce-react-3.14.0.tgz", @@ -7170,6 +7313,39 @@ "node": ">=0.10.0" } }, + "node_modules/array.prototype.find": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/array.prototype.find/-/array.prototype.find-2.2.2.tgz", + "integrity": "sha512-DRumkfW97iZGOfn+lIXbkVrXL04sfYKX+EfOodo8XboR5sxPDVvOjZTF/rysusa9lmhmSOeD6Vp6RKQP+eP4Tg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "es-shim-unscopables": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.3.tgz", + "integrity": "sha512-LzLoiOMAxvy+Gd3BAq3B7VeIgPdo+Q8hthvKtXybMvRV0jrXfJM/t8mw7nNlpEcVlVUnCnM2KSX4XU5HmpodOA==", + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "es-shim-unscopables": "^1.0.0", + "get-intrinsic": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/array.prototype.flat": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.2.tgz", @@ -9757,6 +9933,18 @@ "eslint-plugin-import": "^2.25.2" } }, + "node_modules/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==", + "dev": true, + "engines": { + "node": ">= 4" + }, + "peerDependencies": { + "eslint-plugin-import": ">=1.4.0" + } + }, "node_modules/eslint-import-resolver-node": { "version": "0.3.9", "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", @@ -9775,6 +9963,99 @@ "ms": "^2.1.1" } }, + "node_modules/eslint-import-resolver-webpack": { + "version": "0.13.8", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-webpack/-/eslint-import-resolver-webpack-0.13.8.tgz", + "integrity": "sha512-Y7WIaXWV+Q21Rz/PJgUxiW/FTBOWmU8NTLdz+nz9mMoiz5vAev/fOaQxwD7qRzTfE3HSm1qsxZ5uRd7eX+VEtA==", + "dev": true, + "dependencies": { + "array.prototype.find": "^2.2.2", + "debug": "^3.2.7", + "enhanced-resolve": "^0.9.1", + "find-root": "^1.1.0", + "hasown": "^2.0.0", + "interpret": "^1.4.0", + "is-core-module": "^2.13.1", + "is-regex": "^1.1.4", + "lodash": "^4.17.21", + "resolve": "^2.0.0-next.5", + "semver": "^5.7.2" + }, + "engines": { + "node": ">= 6" + }, + "peerDependencies": { + "eslint-plugin-import": ">=1.4.0", + "webpack": ">=1.11.0" + } + }, + "node_modules/eslint-import-resolver-webpack/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-import-resolver-webpack/node_modules/enhanced-resolve": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-0.9.1.tgz", + "integrity": "sha512-kxpoMgrdtkXZ5h0SeraBS1iRntpTpQ3R8ussdb38+UAFnMGX5DDyJXePm+OCHOcoXvHDw7mc2erbJBpDnl7TPw==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.1.2", + "memory-fs": "^0.2.0", + "tapable": "^0.1.8" + }, + "engines": { + "node": ">=0.6" + } + }, + "node_modules/eslint-import-resolver-webpack/node_modules/interpret": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-1.4.0.tgz", + "integrity": "sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==", + "dev": true, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/eslint-import-resolver-webpack/node_modules/resolve": { + "version": "2.0.0-next.5", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", + "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", + "dev": true, + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/eslint-import-resolver-webpack/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/eslint-import-resolver-webpack/node_modules/tapable": { + "version": "0.1.10", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-0.1.10.tgz", + "integrity": "sha512-jX8Et4hHg57mug1/079yitEKWGB3LCwoxByLsNim89LABq8NqgiX+6iYVOsq0vX8uJHkU+DZ5fnq95f800bEsQ==", + "dev": true, + "engines": { + "node": ">=0.6" + } + }, "node_modules/eslint-module-utils": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.8.0.tgz", @@ -9800,25 +10081,27 @@ } }, "node_modules/eslint-plugin-import": { - "version": "2.27.5", - "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.27.5.tgz", - "integrity": "sha512-LmEt3GVofgiGuiE+ORpnvP+kAm3h6MLZJ4Q5HCyHADofsb4VzXFsRiWj3c0OFiV+3DWFh0qg3v9gcPlfc3zRow==", - "dependencies": { - "array-includes": "^3.1.6", - "array.prototype.flat": "^1.3.1", - "array.prototype.flatmap": "^1.3.1", + "version": "2.29.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.29.1.tgz", + "integrity": "sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==", + "dependencies": { + "array-includes": "^3.1.7", + "array.prototype.findlastindex": "^1.2.3", + "array.prototype.flat": "^1.3.2", + "array.prototype.flatmap": "^1.3.2", "debug": "^3.2.7", "doctrine": "^2.1.0", - "eslint-import-resolver-node": "^0.3.7", - "eslint-module-utils": "^2.7.4", - "has": "^1.0.3", - "is-core-module": "^2.11.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.8.0", + "hasown": "^2.0.0", + "is-core-module": "^2.13.1", "is-glob": "^4.0.3", "minimatch": "^3.1.2", - "object.values": "^1.1.6", - "resolve": "^1.22.1", - "semver": "^6.3.0", - "tsconfig-paths": "^3.14.1" + "object.fromentries": "^2.0.7", + "object.groupby": "^1.0.1", + "object.values": "^1.1.7", + "semver": "^6.3.1", + "tsconfig-paths": "^3.15.0" }, "engines": { "node": ">=4" @@ -10648,6 +10931,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", + "dev": true + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -15592,6 +15881,12 @@ "node": ">= 4.0.0" } }, + "node_modules/memory-fs": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.2.0.tgz", + "integrity": "sha512-+y4mDxU4rvXXu5UDSGCGNiesFmwCHuefGMoPCO1WYucNYj7DsLqrFaa2fXVI0H+NNiPTwwzKwspn9yTZqUGqng==", + "dev": true + }, "node_modules/merge-descriptors": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", @@ -16128,6 +16423,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/object.groupby": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.1.tgz", + "integrity": "sha512-HqaQtqLnp/8Bn4GL16cj+CUYbnpe1bh0TtEaWvybszDG4tgxCJuRpV8VGuvNaI1fAnI4lUJzDG55MXcOH4JZcQ==", + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "get-intrinsic": "^1.2.1" + } + }, "node_modules/object.hasown": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/object.hasown/-/object.hasown-1.1.3.tgz", @@ -17725,6 +18031,22 @@ "react": ">= 16.8 || 18.0.0" } }, + "node_modules/react-error-boundary": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.4.tgz", + "integrity": "sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + }, + "peerDependencies": { + "react": ">=16.13.1" + } + }, "node_modules/react-error-overlay": { "version": "6.0.11", "resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.11.tgz", @@ -20968,6 +21290,22 @@ } } }, + "node_modules/use-deep-compare-effect": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/use-deep-compare-effect/-/use-deep-compare-effect-1.8.1.tgz", + "integrity": "sha512-kbeNVZ9Zkc0RFGpfMN3MNfaKNvcLNyxOAAd9O4CBZ+kCBXXscn9s/4I+8ytUER4RDpEYs5+O6Rs4PqiZ+rHr5Q==", + "dependencies": { + "@babel/runtime": "^7.12.5", + "dequal": "^2.0.2" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + }, + "peerDependencies": { + "react": ">=16.13" + } + }, "node_modules/use-sidecar": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.2.tgz", @@ -21761,6 +22099,152 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "plugins/communications-app/BodyForm": { + "name": "@openedx-plugins/communications-app-body-email-form", + "version": "1.0.0", + "peerDependencies": { + "@edx/frontend-app-communications": "*", + "@edx/frontend-platform": "*", + "@openedx/paragon": "*", + "prop-types": "*", + "react": "*" + }, + "peerDependenciesMeta": { + "@edx/frontend-app-communications": { + "optional": true + } + } + }, + "plugins/communications-app/CheckBoxForm": { + "name": "@openedx-plugins/communications-app-check-box-form", + "version": "1.0.0", + "extraneous": true, + "peerDependencies": { + "@edx/frontend-app-communications": "*", + "@edx/frontend-platform": "*", + "@edx/paragon": "*", + "prop-types": "*", + "react": "*" + }, + "peerDependenciesMeta": { + "@edx/frontend-app-communications": { + "optional": true + } + } + }, + "plugins/communications-app/InputForm": { + "name": "@openedx-plugins/communications-app-input-form", + "version": "1.0.0", + "extraneous": true, + "peerDependencies": { + "@edx/frontend-app-communications": "*", + "@edx/frontend-platform": "*", + "@edx/paragon": "*", + "prop-types": "*", + "react": "*" + }, + "peerDependenciesMeta": { + "@edx/frontend-app-communications": { + "optional": true + } + } + }, + "plugins/communications-app/InstructionsProfreading": { + "name": "@openedx-plugins/communications-app-instructions-pro-freading", + "version": "1.0.0", + "peerDependencies": { + "@edx/frontend-app-communications": "*", + "@edx/frontend-platform": "*", + "@openedx/paragon": "*", + "prop-types": "*", + "react": "*" + }, + "peerDependenciesMeta": { + "@edx/frontend-app-communications": { + "optional": true + } + } + }, + "plugins/communications-app/RecipientsForm": { + "name": "@openedx-plugins/communications-app-recipients-checks", + "version": "1.0.0", + "peerDependencies": { + "@edx/frontend-app-communications": "*", + "@edx/frontend-platform": "*", + "@openedx/paragon": "*", + "prop-types": "*", + "react": "*" + }, + "peerDependenciesMeta": { + "@edx/frontend-app-communications": { + "optional": true + } + } + }, + "plugins/communications-app/ScheduleSection": { + "name": "@openedx-plugins/communications-app-schedule-section", + "version": "1.0.0", + "peerDependencies": { + "@edx/frontend-app-communications": "*", + "@edx/frontend-platform": "*", + "@openedx/paragon": "*", + "prop-types": "*", + "react": "*" + }, + "peerDependenciesMeta": { + "@edx/frontend-app-communications": { + "optional": true + } + } + }, + "plugins/communications-app/SubjectForm": { + "name": "@openedx-plugins/communications-app-subject-form", + "version": "1.0.0", + "peerDependencies": { + "@edx/frontend-app-communications": "*", + "@edx/frontend-platform": "*", + "@openedx/paragon": "*", + "prop-types": "*", + "react": "*" + }, + "peerDependenciesMeta": { + "@edx/frontend-app-communications": { + "optional": true + } + } + }, + "plugins/communications-app/TaskAlertModalForm": { + "name": "@openedx-plugins/communications-app-task-alert-modal", + "version": "1.0.0", + "peerDependencies": { + "@edx/frontend-app-communications": "*", + "@edx/frontend-platform": "*", + "@openedx/paragon": "*", + "prop-types": "*", + "react": "*" + }, + "peerDependenciesMeta": { + "@edx/frontend-app-communications": { + "optional": true + } + } + }, + "plugins/communications-app/TestComponent": { + "name": "@openedx-plugins/communications-app-test-component", + "version": "1.0.0", + "peerDependencies": { + "@edx/frontend-app-communications": "*", + "@edx/frontend-platform": "*", + "@openedx/paragon": "*", + "prop-types": "*", + "react": "*" + }, + "peerDependenciesMeta": { + "@edx/frontend-app-communications": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index 5a634984..f37c81db 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,14 @@ "@fortawesome/free-regular-svg-icons": "5.15.4", "@fortawesome/free-solid-svg-icons": "5.15.4", "@fortawesome/react-fontawesome": "0.2.0", + "@loadable/component": "^5.15.3", + "@openedx-plugins/communications-app-body-email-form": "file:plugins/communications-app/BodyForm", + "@openedx-plugins/communications-app-instructions-pro-freading": "file:plugins/communications-app/InstructionsProfreading", + "@openedx-plugins/communications-app-recipients-checks": "file:plugins/communications-app/RecipientsForm", + "@openedx-plugins/communications-app-schedule-section": "file:plugins/communications-app/ScheduleSection", + "@openedx-plugins/communications-app-subject-form": "file:plugins/communications-app/SubjectForm", + "@openedx-plugins/communications-app-task-alert-modal": "file:plugins/communications-app/TaskAlertModalForm", + "@openedx-plugins/communications-app-test-component": "file:plugins/communications-app/TestComponent", "@openedx/paragon": "^22.0.0", "@tinymce/tinymce-react": "3.14.0", "axios": "0.27.2", @@ -60,7 +68,8 @@ "react-router-dom": "6.15.0", "redux": "4.2.0", "regenerator-runtime": "0.13.11", - "tinymce": "5.10.7" + "tinymce": "5.10.7", + "use-deep-compare-effect": "^1.8.1" }, "devDependencies": { "@edx/browserslist-config": "^1.2.0", @@ -68,7 +77,11 @@ "@openedx/frontend-build": "13.0.27", "@testing-library/jest-dom": "5.16.5", "@testing-library/react": "12.1.5", + "@testing-library/react-hooks": "^8.0.1", "axios-mock-adapter": "1.21.2", + "eslint-import-resolver-alias": "^1.1.2", + "eslint-import-resolver-webpack": "^0.13.8", + "eslint-plugin-import": "^2.29.1", "glob": "7.2.3", "husky": "7.0.4", "jest": "27.5.1", diff --git a/plugins/communications-app/BodyForm/index.jsx b/plugins/communications-app/BodyForm/index.jsx new file mode 100644 index 00000000..31ddd578 --- /dev/null +++ b/plugins/communications-app/BodyForm/index.jsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { Form } from '@openedx/paragon'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import TextEditor from '@communications-app/src/components/bulk-email-tool/text-editor/TextEditor'; +import { useSelector, useDispatch } from '@communications-app/src/components/bulk-email-tool/bulk-email-form/BulkEmailForm/context'; +import { actionCreators as formActions } from '@communications-app/src/components/bulk-email-tool/bulk-email-form/BulkEmailForm/context/reducer'; + +import messages from './messages'; + +const BodyForm = () => { + const intl = useIntl(); + const formData = useSelector((state) => state.form); + const dispatch = useDispatch(); + const { body, isFormSubmitted = false } = formData; + + const handleChangeTextEditor = (value) => { + dispatch(formActions.updateForm({ body: value })); + }; + + const isBodyValid = body.length > 0; + + return ( + + {intl.formatMessage(messages.bodyFormFieldLabel)} + + {isFormSubmitted && !isBodyValid && ( + + {intl.formatMessage(messages.bodyFormFieldError)} + + )} + + ); +}; + +export default BodyForm; diff --git a/plugins/communications-app/BodyForm/messages.js b/plugins/communications-app/BodyForm/messages.js new file mode 100644 index 00000000..eebbea05 --- /dev/null +++ b/plugins/communications-app/BodyForm/messages.js @@ -0,0 +1,17 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + /* index.jsx Messages */ + bodyFormFieldLabel: { + id: 'body.form.field.label', + defaultMessage: 'Body', + description: 'Email Body label. Meant to have colon or equivalent punctuation.', + }, + bodyFormFieldError: { + id: 'body.form.field.error', + defaultMessage: 'The message cannot be blank', + description: 'An error message located under the body editor. Visible only on failure.', + }, +}); + +export default messages; diff --git a/plugins/communications-app/BodyForm/package.json b/plugins/communications-app/BodyForm/package.json new file mode 100644 index 00000000..21b5a755 --- /dev/null +++ b/plugins/communications-app/BodyForm/package.json @@ -0,0 +1,20 @@ +{ + "name": "@openedx-plugins/communications-app-body-email-form", + "version": "1.0.0", + "description": "openedx body field in build email form to use it in this mfe", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "peerDependencies": { + "@edx/frontend-app-communications": "*", + "@edx/frontend-platform": "*", + "@openedx/paragon": "*", + "prop-types": "*", + "react": "*" + }, + "peerDependenciesMeta": { + "@edx/frontend-app-communications": { + "optional": true + } + } +} diff --git a/plugins/communications-app/InstructionsProfreading/index.jsx b/plugins/communications-app/InstructionsProfreading/index.jsx new file mode 100644 index 00000000..2e7eb9eb --- /dev/null +++ b/plugins/communications-app/InstructionsProfreading/index.jsx @@ -0,0 +1,15 @@ +import React, { memo } from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; + +import messages from './messages'; + +const InstructionsProofreading = () => { + const intl = useIntl(); + return ( +
+

{intl.formatMessage(messages.instructionsProofreading)}

+
+ ); +}; + +export default memo(InstructionsProofreading); diff --git a/plugins/communications-app/InstructionsProfreading/messages.js b/plugins/communications-app/InstructionsProfreading/messages.js new file mode 100644 index 00000000..5a2f0d7e --- /dev/null +++ b/plugins/communications-app/InstructionsProfreading/messages.js @@ -0,0 +1,12 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + /* index.jsx Messages */ + instructionsProofreading: { + id: 'instructions.proofreading', + defaultMessage: 'We recommend sending learners no more than one email message per week. Before you send your email, review the text carefully and send it to yourself first, so that you can preview the formatting and make sure embedded images and links work correctly.', + description: 'A set of instructions to give users a heads up about the formatting of the email they are about to send', + }, +}); + +export default messages; diff --git a/plugins/communications-app/InstructionsProfreading/package.json b/plugins/communications-app/InstructionsProfreading/package.json new file mode 100644 index 00000000..42c579fb --- /dev/null +++ b/plugins/communications-app/InstructionsProfreading/package.json @@ -0,0 +1,20 @@ +{ + "name": "@openedx-plugins/communications-app-instructions-pro-freading", + "version": "1.0.0", + "description": "openedx instructions build email form to use it in this mfe", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "peerDependencies": { + "@edx/frontend-app-communications": "*", + "@edx/frontend-platform": "*", + "@openedx/paragon": "*", + "prop-types": "*", + "react": "*" + }, + "peerDependenciesMeta": { + "@edx/frontend-app-communications": { + "optional": true + } + } +} diff --git a/plugins/communications-app/RecipientsForm/index.jsx b/plugins/communications-app/RecipientsForm/index.jsx new file mode 100644 index 00000000..36150e6c --- /dev/null +++ b/plugins/communications-app/RecipientsForm/index.jsx @@ -0,0 +1,187 @@ +import React, { useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import { Form } from '@openedx/paragon'; +import { FormattedMessage } from '@edx/frontend-platform/i18n'; +import { useSelector, useDispatch } from '@communications-app/src/components/bulk-email-tool/bulk-email-form/BulkEmailForm/context'; +import { actionCreators as formActions } from '@communications-app/src/components/bulk-email-tool/bulk-email-form/BulkEmailForm/context/reducer'; + +import './styles.scss'; + +const disableIsHasLearners = ['track', 'cohort']; +const recipientsFormDescription = 'A selectable choice from a list of potential email recipients'; + +const RecipientsForm = ({ cohorts: additionalCohorts, courseModes }) => { + const formData = useSelector((state) => state.form); + const dispatch = useDispatch(); + const { isEditMode, emailRecipients, isFormSubmitted } = formData; + const hasCourseModes = courseModes.length > 1; + + const [selectedGroups, setSelectedGroups] = useState([]); + const hasAllLearnersSelected = selectedGroups.some((group) => group === 'learners'); + + const handleChangeCheckBoxes = ({ target: { value, checked } }) => { + let newValue; + + if (checked) { + const uniqueSet = new Set([...emailRecipients, value]); + newValue = Array.from(uniqueSet); + } else { + newValue = emailRecipients.filter((item) => item !== value); + } + + if (checked && value === 'learners') { + newValue = newValue.filter(item => !disableIsHasLearners.some(disabled => item.includes(disabled))); + } + + dispatch(formActions.updateForm({ emailRecipients: newValue })); + setSelectedGroups(newValue); + }; + + useEffect(() => { + setSelectedGroups(emailRecipients); + }, [isEditMode, emailRecipients.length, emailRecipients]); + + return ( + + + + + + + + + + + + + + + + + { + // additional modes + hasCourseModes + && courseModes.map((courseMode) => ( + + + + )) + } + { + // additional cohorts + additionalCohorts + && additionalCohorts.map((cohort) => ( + + + + )) + } + + + + + + + + { isFormSubmitted && selectedGroups.length === 0 && ( + + + + )} + + ); +}; + +RecipientsForm.defaultProps = { + cohorts: [], + courseModes: [], +}; + +RecipientsForm.propTypes = { + cohorts: PropTypes.arrayOf(PropTypes.string), + courseModes: PropTypes.arrayOf( + PropTypes.shape({ + slug: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + }), + ), +}; + +export default RecipientsForm; diff --git a/plugins/communications-app/RecipientsForm/package.json b/plugins/communications-app/RecipientsForm/package.json new file mode 100644 index 00000000..50f961e9 --- /dev/null +++ b/plugins/communications-app/RecipientsForm/package.json @@ -0,0 +1,20 @@ +{ + "name": "@openedx-plugins/communications-app-recipients-checks", + "version": "1.0.0", + "description": "openedx recipients field in build email form to use it in this mfe", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "peerDependencies": { + "@edx/frontend-app-communications": "*", + "@edx/frontend-platform": "*", + "@openedx/paragon": "*", + "prop-types": "*", + "react": "*" + }, + "peerDependenciesMeta": { + "@edx/frontend-app-communications": { + "optional": true + } + } +} diff --git a/plugins/communications-app/RecipientsForm/styles.scss b/plugins/communications-app/RecipientsForm/styles.scss new file mode 100644 index 00000000..29206d35 --- /dev/null +++ b/plugins/communications-app/RecipientsForm/styles.scss @@ -0,0 +1,8 @@ +.recipient-groups { + > div { + padding-right: 0.5rem; + input { + padding: 0.5rem !important; + } + } +} diff --git a/plugins/communications-app/ScheduleSection/index.jsx b/plugins/communications-app/ScheduleSection/index.jsx new file mode 100644 index 00000000..83b251f7 --- /dev/null +++ b/plugins/communications-app/ScheduleSection/index.jsx @@ -0,0 +1,180 @@ +import React, { useState, useMemo } from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import { + StatefulButton, + Button, + Form, + Icon, + Toast, +} from '@openedx/paragon'; +import { + SpinnerSimple, + Cancel, + Send, + Event, + Check, +} from '@openedx/paragon/icons'; +import { getConfig } from '@edx/frontend-platform'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import ScheduleEmailForm from '@communications-app/src/components/bulk-email-tool/bulk-email-form/ScheduleEmailForm'; +import useMobileResponsive from '@communications-app/src/utils/useMobileResponsive'; +import { useSelector, useDispatch } from '@communications-app/src/components/bulk-email-tool/bulk-email-form/BulkEmailForm/context'; +import { actionCreators as formActions } from '@communications-app/src/components/bulk-email-tool/bulk-email-form/BulkEmailForm/context/reducer'; + +import messages from './messages'; + +const formStatusToast = ['error', 'complete', 'completeSchedule']; + +const ScheduleSection = ({ openTaskAlert }) => { + const intl = useIntl(); + const isMobile = useMobileResponsive(); + const [scheduleInputChanged, isScheduleInputChanged] = useState(false); + const formData = useSelector((state) => state.form); + const dispatch = useDispatch(); + const { + isScheduled, + scheduleDate = '', + scheduleTime = '', + isEditMode, + formStatus, + isScheduledSubmitted = false, + } = formData; + + const formStatusErrors = { + error: intl.formatMessage(messages.ScheduleSectionSubmitFormError), + complete: intl.formatMessage(messages.ScheduleSectionSubmitFormSuccess), + completeSchedule: intl.formatMessage(messages.ScheduleSectionSubmitFormScheduledSuccess), + }; + + const handleChangeScheduled = () => { + const newSchedule = !isScheduled; + const newFormStatus = newSchedule ? 'schedule' : 'default'; + dispatch(formActions.updateForm({ formStatus: newFormStatus, isScheduled: newSchedule })); + }; + + const handleScheduleDate = ({ target: { name, value } }) => { + dispatch(formActions.updateForm({ [name]: value })); + if (!scheduleInputChanged) { + isScheduleInputChanged(true); + } + }; + + const scheduleFields = isScheduledSubmitted ? scheduleDate.length > 0 && scheduleTime.length > 0 + && scheduleInputChanged : true; + + const checkIsValidSchedule = isScheduled ? scheduleFields : true; + + const handleResetFormValues = () => { + dispatch(formActions.resetForm()); + }; + + const handleCloseToast = () => { + dispatch(formActions.updateForm({ formStatus: 'default' })); + }; + + const handleClickStatefulButton = (event) => { + event.preventDefault(); + if (formStatus === 'schedule' && !isScheduledSubmitted) { + dispatch(formActions.updateForm({ isScheduleButtonClicked: true })); + } + openTaskAlert(); + }; + + const statefulButtonIcons = useMemo(() => ({ + default: , + schedule: , + reschedule: , + pending: , + complete: , + completeSchedule: , + error: , + }), []); + + const statefulButtonLabels = useMemo(() => ({ + default: intl.formatMessage(messages.ScheduleSectionSubmitButtonDefault), + schedule: intl.formatMessage(messages.ScheduleSectionSubmitButtonSchedule), + reschedule: intl.formatMessage(messages.ScheduleSectionSubmitButtonReschedule), + pending: intl.formatMessage(messages.ScheduleSectionSubmitButtonPending), + complete: intl.formatMessage(messages.ScheduleSectionSubmitButtonComplete), + completeSchedule: intl.formatMessage(messages.ScheduleSectionSubmitButtonCompleteSchedule), + error: intl.formatMessage(messages.ScheduleSectionSubmitButtonError), + }), [intl]); + + const statefulButtonDisableStates = useMemo(() => [ + 'pending', + 'complete', + 'completeSchedule', + ], []); + + return ( + + {getConfig().SCHEDULE_EMAIL_SECTION && ( +
+ + {intl.formatMessage(messages.ScheduleSectionSubmitScheduleBox)} + +
+ )} + + {isScheduled && ( + + )} + +
+ + {isEditMode && ( + + )} + + + + + {formStatusErrors[formStatus] || ''} + +
+
+ ); +}; + +ScheduleSection.defaultProps = { + openTaskAlert: () => {}, +}; + +ScheduleSection.propTypes = { + openTaskAlert: PropTypes.func, +}; + +export default ScheduleSection; diff --git a/plugins/communications-app/ScheduleSection/messages.js b/plugins/communications-app/ScheduleSection/messages.js new file mode 100644 index 00000000..e795715d --- /dev/null +++ b/plugins/communications-app/ScheduleSection/messages.js @@ -0,0 +1,53 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + /* index.jsx Messages */ + ScheduleSectionSubmitScheduleBox: { + id: 'schedule.section.submit.scheduleBox', + defaultMessage: 'Schedule this email for a future date', + description: 'Checkbox to schedule sending the email at a later date', + }, + ScheduleSectionSubmitButtonDefault: { + id: 'schedule.section.submit.button.default', + defaultMessage: 'Send email', + }, + ScheduleSectionSubmitFormError: { + id: 'schedule.section.submit.error', + defaultMessage: 'An error occurred while attempting to send the email.', + description: 'An Error message located under the submit button for the email form. Visible only on a failure.', + }, + ScheduleSectionSubmitFormSuccess: { + id: 'schedule.section.form.success', + defaultMessage: 'Email successfully created', + }, + ScheduleSectionSubmitFormScheduledSuccess: { + id: 'schedule.section.submit.scheduled.success', + defaultMessage: 'Email successfully scheduled', + }, + ScheduleSectionSubmitButtonSchedule: { + id: 'schedule.section.submit.button.schedule', + defaultMessage: 'Schedule Email', + }, + ScheduleSectionSubmitButtonReschedule: { + id: 'schedule.section.submit.button.reschedule', + defaultMessage: 'Reschedule Email', + }, + ScheduleSectionSubmitButtonPending: { + id: 'schedule.section.submit.button.pending', + defaultMessage: 'Submitting', + }, + ScheduleSectionSubmitButtonComplete: { + id: 'schedule.section.submit.button.send.complete', + defaultMessage: 'Email Created', + }, + ScheduleSectionSubmitButtonError: { + id: 'schedule.section.submit.button.error', + defaultMessage: 'Error', + }, + ScheduleSectionSubmitButtonCompleteSchedule: { + id: 'schedule.section.submit.button.schedule.complete', + defaultMessage: 'Scheduling Done', + }, +}); + +export default messages; diff --git a/plugins/communications-app/ScheduleSection/package.json b/plugins/communications-app/ScheduleSection/package.json new file mode 100644 index 00000000..10593432 --- /dev/null +++ b/plugins/communications-app/ScheduleSection/package.json @@ -0,0 +1,20 @@ +{ + "name": "@openedx-plugins/communications-app-schedule-section", + "version": "1.0.0", + "description": "openedx schedule fields in build email form to use it in this mfe", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "peerDependencies": { + "@edx/frontend-app-communications": "*", + "@edx/frontend-platform": "*", + "@openedx/paragon": "*", + "prop-types": "*", + "react": "*" + }, + "peerDependenciesMeta": { + "@edx/frontend-app-communications": { + "optional": true + } + } +} diff --git a/plugins/communications-app/SubjectForm/index.jsx b/plugins/communications-app/SubjectForm/index.jsx new file mode 100644 index 00000000..7e0a9134 --- /dev/null +++ b/plugins/communications-app/SubjectForm/index.jsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { Form } from '@openedx/paragon'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { useSelector, useDispatch } from '@communications-app/src/components/bulk-email-tool/bulk-email-form/BulkEmailForm/context'; +import { actionCreators as formActions } from '@communications-app/src/components/bulk-email-tool/bulk-email-form/BulkEmailForm/context/reducer'; + +import messages from './messages'; + +const SubjectForm = () => { + const intl = useIntl(); + const formData = useSelector((state) => state.form); + const dispatch = useDispatch(); + const { subject, isFormSubmitted } = formData; + + const handleChangeEmailSubject = ({ target: { value } }) => { + dispatch(formActions.updateForm({ subject: value })); + }; + + const isSubjectValid = subject.length > 0; + + return ( + + {intl.formatMessage(messages.bulkEmailSubjectLabel)} + + { isFormSubmitted && !isSubjectValid && ( + + {intl.formatMessage(messages.bulkEmailFormSubjectError)} + + ) } + + ); +}; + +export default SubjectForm; diff --git a/plugins/communications-app/SubjectForm/messages.js b/plugins/communications-app/SubjectForm/messages.js new file mode 100644 index 00000000..56e5c357 --- /dev/null +++ b/plugins/communications-app/SubjectForm/messages.js @@ -0,0 +1,17 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + /* SubjectForm.jsx Messages */ + bulkEmailSubjectLabel: { + id: 'bulk.email.subject.label', + defaultMessage: 'Subject', + description: 'Email subject line input label. Meant to have colon or equivalent punctuation.', + }, + bulkEmailFormSubjectError: { + id: 'bulk.email.form.subject.error', + defaultMessage: 'A subject is required', + description: 'An Error message located under the subject line. Visible only on failure.', + }, +}); + +export default messages; diff --git a/plugins/communications-app/SubjectForm/package.json b/plugins/communications-app/SubjectForm/package.json new file mode 100644 index 00000000..f72bc5eb --- /dev/null +++ b/plugins/communications-app/SubjectForm/package.json @@ -0,0 +1,20 @@ +{ + "name": "@openedx-plugins/communications-app-subject-form", + "version": "1.0.0", + "description": "openedx subject field in build email form to use it in this mfe", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "peerDependencies": { + "@edx/frontend-app-communications": "*", + "@edx/frontend-platform": "*", + "@openedx/paragon": "*", + "prop-types": "*", + "react": "*" + }, + "peerDependenciesMeta": { + "@edx/frontend-app-communications": { + "optional": true + } + } +} diff --git a/plugins/communications-app/TaskAlertModalForm/AlertTypes.jsx b/plugins/communications-app/TaskAlertModalForm/AlertTypes.jsx new file mode 100644 index 00000000..140fef3f --- /dev/null +++ b/plugins/communications-app/TaskAlertModalForm/AlertTypes.jsx @@ -0,0 +1,81 @@ +import PropTypes from 'prop-types'; +import { useIntl } from '@edx/frontend-platform/i18n'; + +import messages from './messages.alerts'; + +export const AlertMessage = ({ emailRecipients, isScheduled, subject }) => { + const intl = useIntl(); + return ( + <> +

{intl.formatMessage(messages.TaskAlertModalAlertTypesRecipients, { subject })}

+
    + {emailRecipients.map((group) => ( +
  • {group}
  • + ))} +
+ {!isScheduled && ( +

+ {intl.formatMessage(messages.TaskAlertModalAlertTypesInstructionCaption)} + {intl.formatMessage(messages.TaskAlertModalAlertTypesInstructionCaptionMessage)} +

+ )} + + ); +}; + +AlertMessage.defaultProps = { + emailRecipients: [], +}; + +AlertMessage.propTypes = { + emailRecipients: PropTypes.arrayOf(PropTypes.string), + isScheduled: PropTypes.bool.isRequired, + subject: PropTypes.string.isRequired, +}; + +export const EditMessage = ({ + emailRecipients, isScheduled, scheduleDate, scheduleTime, subject, +}) => { + const intl = useIntl(); + return ( + <> +

+ {intl.formatMessage(messages.TaskAlertModalAlertTypesEditingDate, { + dateTime: new Date(`${scheduleDate} ${scheduleTime}`).toLocaleString(), + })} +

+

+ {intl.formatMessage(messages.TaskAlertModalAlertTypesEditingSubject, { + subject, + })} +

+

{intl.formatMessage(messages.TaskAlertModalAlertTypesEditingTo)}

+
    + {emailRecipients.map((group) => ( +
  • {group}
  • + ))} +
+

{intl.formatMessage(messages.TaskAlertModalAlertTypesEditingWarning)}

+ {!isScheduled && ( +

+ {intl.formatMessage(messages.TaskAlertModalAlertTypesInstructionCaption)} + {intl.formatMessage(messages.TaskAlertModalAlertTypesInstructionCaptionMessage)} +

+ )} + + ); +}; + +EditMessage.defaultProps = { + emailRecipients: [], + scheduleDate: '', + scheduleTime: '', +}; + +EditMessage.propTypes = { + emailRecipients: PropTypes.arrayOf(PropTypes.string), + isScheduled: PropTypes.bool.isRequired, + scheduleDate: PropTypes.string, + scheduleTime: PropTypes.string, + subject: PropTypes.string.isRequired, +}; diff --git a/plugins/communications-app/TaskAlertModalForm/api.js b/plugins/communications-app/TaskAlertModalForm/api.js new file mode 100644 index 00000000..62fb078d --- /dev/null +++ b/plugins/communications-app/TaskAlertModalForm/api.js @@ -0,0 +1,25 @@ +import { getConfig } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { logError } from '@edx/frontend-platform/logging'; + +export async function postBulkEmailInstructorTask(email, courseId) { + try { + const url = `${getConfig().LMS_BASE_URL}/courses/${courseId}/instructor/api/send_email`; + const response = await getAuthenticatedHttpClient().post(url, email); + return response; + } catch (error) { + logError(error); + throw new Error(error); + } +} + +export async function patchScheduledBulkEmailInstructorTask(emailData, courseId, scheduleId) { + const endpointUrl = `${getConfig().LMS_BASE_URL}/api/instructor_task/v1/schedules/${courseId}/bulk_email/${scheduleId}`; + try { + const response = await getAuthenticatedHttpClient().patch(endpointUrl, emailData); + return response; + } catch (error) { + logError(error); + throw new Error(error); + } +} diff --git a/plugins/communications-app/TaskAlertModalForm/index.jsx b/plugins/communications-app/TaskAlertModalForm/index.jsx new file mode 100644 index 00000000..01cfe00c --- /dev/null +++ b/plugins/communications-app/TaskAlertModalForm/index.jsx @@ -0,0 +1,163 @@ +import React, { useContext } from 'react'; +import PropTypes from 'prop-types'; +import TaskAlertModal from '@communications-app/src/components/bulk-email-tool/task-alert-modal'; +import { getScheduledBulkEmailThunk } from '@communications-app/src/components/bulk-email-tool/bulk-email-task-manager/bulk-email-scheduled-emails-table/data/thunks'; +import { BulkEmailContext } from '@communications-app/src/components/bulk-email-tool/bulk-email-context'; +import { useSelector, useDispatch } from '@communications-app/src/components/bulk-email-tool/bulk-email-form/BulkEmailForm/context'; +import { actionCreators as formActions } from '@communications-app/src/components/bulk-email-tool/bulk-email-form/BulkEmailForm/context/reducer'; + +import { postBulkEmailInstructorTask, patchScheduledBulkEmailInstructorTask } from './api'; +import { AlertMessage, EditMessage } from './AlertTypes'; + +const TaskAlertModalForm = ({ + courseId, + isTaskAlertOpen, + closeTaskAlert, +}) => { + const [, dispatch] = useContext(BulkEmailContext); + const formData = useSelector((state) => state.form); + const dispatchForm = useDispatch(); + + const { + isScheduled, + emailRecipients, + scheduleDate = '', + scheduleTime = '', + isEditMode = false, + subject, + emailId = '', + schedulingId = '', + body, + isScheduleButtonClicked = false, + isFormSubmitted = false, + } = formData; + + const changeFormStatus = (status) => dispatchForm(formActions.updateForm({ formStatus: status })); + const handleResetFormValues = () => dispatchForm(formActions.resetForm()); + + const handlePostEmailTask = async () => { + const emailData = new FormData(); + emailData.append('action', 'send'); + emailData.append('send_to', JSON.stringify(emailRecipients)); + emailData.append('subject', subject); + emailData.append('message', body); + + if (isScheduled) { + emailData.append('schedule', new Date(`${scheduleDate} ${scheduleTime}`).toISOString()); + } + + changeFormStatus('pending'); + + try { + await postBulkEmailInstructorTask(emailData, courseId); + const newFormStatus = isScheduled ? 'completeSchedule' : 'complete'; + changeFormStatus(newFormStatus); + setTimeout(() => handleResetFormValues(), 3000); + } catch { + changeFormStatus('error'); + } + }; + + const handlePatchEmailTask = async () => { + const emailRecipientsValue = emailRecipients; + const emailSubject = subject; + const emailBody = body; + + const emailData = { + email: { + targets: emailRecipientsValue, + subject: emailSubject, + message: emailBody, + id: emailId, + }, + schedule: isScheduled ? new Date(`${scheduleDate} ${scheduleTime}`).toISOString() : null, + }; + + changeFormStatus('pending'); + + try { + await patchScheduledBulkEmailInstructorTask(emailData, courseId, schedulingId); + changeFormStatus('completeSchedule'); + setTimeout(() => handleResetFormValues(), 3000); + } catch { + changeFormStatus('error'); + } + }; + + const createEmailTask = async () => { + const isScheduleValid = isScheduled ? scheduleDate.length > 0 && scheduleTime.length > 0 : true; + const isFormValid = emailRecipients.length > 0 && subject.length > 0 + && body.length > 0 && isScheduleValid; + + if (isFormValid && isEditMode) { + await handlePatchEmailTask(); + } + + if (isFormValid && !isEditMode) { + await handlePostEmailTask(); + } + + if (isFormValid) { + dispatch(getScheduledBulkEmailThunk(courseId, 1)); + } + }; + + const handleCloseTaskAlert = (event) => { + closeTaskAlert(); + + if (event.target.name === 'continue') { + if (!isFormSubmitted) { + dispatchForm(formActions.updateForm({ isFormSubmitted: true })); + } + + if (isScheduleButtonClicked) { + dispatchForm(formActions.updateForm({ isScheduledSubmitted: true })); + } + + createEmailTask(); + } + }; + + return ( + + ) + : ( + + )} + close={handleCloseTaskAlert} + /> + ); +}; + +TaskAlertModalForm.defaultProps = { + courseId: '', + formState: {}, + setFormState: () => {}, + openTaskAlert: () => {}, + closeTaskAlert: () => {}, + isTaskAlertOpen: false, +}; + +TaskAlertModalForm.propTypes = { + courseId: PropTypes.string, + formState: PropTypes.shape({}), + setFormState: PropTypes.func, + openTaskAlert: PropTypes.func, + closeTaskAlert: PropTypes.func, + isTaskAlertOpen: PropTypes.bool, + +}; + +export default TaskAlertModalForm; diff --git a/plugins/communications-app/TaskAlertModalForm/messages.alerts.js b/plugins/communications-app/TaskAlertModalForm/messages.alerts.js new file mode 100644 index 00000000..c19cd647 --- /dev/null +++ b/plugins/communications-app/TaskAlertModalForm/messages.alerts.js @@ -0,0 +1,41 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + /* AlertTypes.jsx Messages */ + TaskAlertModalAlertTypesRecipients: { + id: 'task.alert.types.recipients', + defaultMessage: 'You are sending an email message with the subject {subject} to the following recipients:', + description: 'A warning shown to the user after submitting the email, to confirm the email recipients.', + }, + TaskAlertModalAlertTypesInstructionCaption: { + id: 'task.alert.types.caution', + defaultMessage: 'Caution!', + description: 'Checkbox to schedule sending the email at a later date', + }, + TaskAlertModalAlertTypesInstructionCaptionMessage: { + id: 'task.alert.types.caution.message', + defaultMessage: + ' When you select Send Email, you are creating a new email message that is added to the queue for sending, and cannot be cancelled.', + description: 'A warning about how emails are sent out to users', + }, + TaskAlertModalAlertTypesEditingDate: { + id: 'task.alert.types.editing', + defaultMessage: 'You are editing a scheduled email to be sent on: {dateTime}', + description: 'This alert pops up before submitting when editing an email that has already been scheduled', + }, + TaskAlertModalAlertTypesEditingSubject: { + id: 'task.alert.types.subject', + defaultMessage: 'with the subject: {subject}', + }, + TaskAlertModalAlertTypesEditingTo: { + id: 'task.alert.types.to', + defaultMessage: 'to recipients:', + }, + TaskAlertModalAlertTypesEditingWarning: { + id: 'task.alert.types.warning', + defaultMessage: 'This will not create a new scheduled email task and instead overwrite the one currently selected. Do you want to overwrite this scheduled email?', + description: 'This alert pops up before submitting when editing an email that has already been scheduled', + }, +}); + +export default messages; diff --git a/plugins/communications-app/TaskAlertModalForm/messages.js b/plugins/communications-app/TaskAlertModalForm/messages.js new file mode 100644 index 00000000..dfb0abc1 --- /dev/null +++ b/plugins/communications-app/TaskAlertModalForm/messages.js @@ -0,0 +1,49 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + /* index.jsx Messages */ + ScheduleSectionScheduleBox: { + id: 'schedule.section.form.scheduleBox', + defaultMessage: 'Schedule this email for a future date', + description: 'Checkbox to schedule sending the email at a later date', + }, + ScheduleSectionSubmitButtonDefault: { + id: 'schedule.section.submit.button.default', + defaultMessage: 'Send email', + }, + ScheduleSectionSubmitFormError: { + id: 'schedule.section.submit.error', + defaultMessage: 'An error occurred while attempting to send the email.', + description: 'An Error message located under the submit button for the email form. Visible only on a failure.', + }, + ScheduleSectionSubmitFormSuccess: { + id: 'schedule.section.form.success', + defaultMessage: 'Email successfully created', + }, + ScheduleSectionSubmitFormScheduledSuccess: { + id: 'schedule.section.submit.scheduled.success', + defaultMessage: 'Email successfully scheduled', + }, + ScheduleSectionSubmitButtonSchedule: { + id: 'schedule.section.submit.button.schedule', + defaultMessage: 'Schedule Email', + }, + ScheduleSectionSubmitButtonPending: { + id: 'schedule.section.submit.button.pending', + defaultMessage: 'Submitting', + }, + ScheduleSectionSubmitButtonComplete: { + id: 'schedule.section.submit.button.send.complete', + defaultMessage: 'Email Created', + }, + ScheduleSectionSubmitButtonError: { + id: 'schedule.section.submit.button.error', + defaultMessage: 'Error', + }, + ScheduleSectionSubmitButtonCompleteSchedule: { + id: 'schedule.section.submit.button.schedule.complete', + defaultMessage: 'Scheduling Done', + }, +}); + +export default messages; diff --git a/plugins/communications-app/TaskAlertModalForm/package.json b/plugins/communications-app/TaskAlertModalForm/package.json new file mode 100644 index 00000000..acbe905d --- /dev/null +++ b/plugins/communications-app/TaskAlertModalForm/package.json @@ -0,0 +1,20 @@ +{ + "name": "@openedx-plugins/communications-app-task-alert-modal", + "version": "1.0.0", + "description": "openedx alert in build email form to use it in this mfe", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "peerDependencies": { + "@edx/frontend-app-communications": "*", + "@edx/frontend-platform": "*", + "@openedx/paragon": "*", + "prop-types": "*", + "react": "*" + }, + "peerDependenciesMeta": { + "@edx/frontend-app-communications": { + "optional": true + } + } +} diff --git a/plugins/communications-app/TestComponent/index.jsx b/plugins/communications-app/TestComponent/index.jsx new file mode 100644 index 00000000..2fb8e44e --- /dev/null +++ b/plugins/communications-app/TestComponent/index.jsx @@ -0,0 +1,18 @@ +import PropTypes from 'prop-types'; + +const TestComponent = ({ handleClick, title }) => ( + +); + +TestComponent.defaultProps = { + handleClick: () => {}, +}; + +TestComponent.propTypes = { + handleClick: PropTypes.func, + title: PropTypes.string.isRequired, +}; + +export default TestComponent; diff --git a/plugins/communications-app/TestComponent/package.json b/plugins/communications-app/TestComponent/package.json new file mode 100644 index 00000000..eec40ff0 --- /dev/null +++ b/plugins/communications-app/TestComponent/package.json @@ -0,0 +1,20 @@ +{ + "name": "@openedx-plugins/communications-app-test-component", + "version": "1.0.0", + "description": "edx input form to use it in this mfe", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "peerDependencies": { + "@edx/frontend-app-communications": "*", + "@edx/frontend-platform": "*", + "@openedx/paragon": "*", + "prop-types": "*", + "react": "*" + }, + "peerDependenciesMeta": { + "@edx/frontend-app-communications": { + "optional": true + } + } +} diff --git a/src/components/PluggableComponent/MultiplePlugins.jsx b/src/components/PluggableComponent/MultiplePlugins.jsx new file mode 100644 index 00000000..fce580bf --- /dev/null +++ b/src/components/PluggableComponent/MultiplePlugins.jsx @@ -0,0 +1,119 @@ +import React, { useState, useEffect, useRef } from 'react'; +import loadable from '@loadable/component'; +import useDeepCompareEffect from 'use-deep-compare-effect'; +import PropTypes from 'prop-types'; + +import { isPluginAvailable, getPluginsByPrefix } from './utils'; + +const MultiplePlugins = ({ + plugins, + pluggableComponentProps, + prefix, + loadingComponent, + containerPluginsProps, +}) => { + const [pluginComponents, setPluginComponents] = useState({}); + const loadedAllPluginsRef = useRef(null); + + useEffect(() => { + const loadPlugins = (pluginsList) => { + pluginsList.forEach((plugin, index) => { + // Initially set the loading component for each plugin + setPluginComponents(previousPluginComponents => ({ + ...previousPluginComponents, + [plugin.id]: loadingComponent || null, + })); + + const loadPlugin = async () => { + try { + const hasModuleInstalled = await isPluginAvailable(plugin.name); + if (hasModuleInstalled) { + const PluginComponent = loadable(() => import(`@node_modules/@openedx-plugins/${plugin.name}`)); + setPluginComponents(previousPluginComponents => ({ + ...previousPluginComponents, + [plugin.id]: ( + + ), + })); + } + } catch (error) { + console.error(`Failed to load plugin ${plugin.name}:`, error); + // Set to null in case of an error + setPluginComponents(previousPluginComponents => ({ + ...previousPluginComponents, + [plugin.id]: null, + })); + } finally { + const isLastPlugin = index === pluginsList.length - 1; + if (isLastPlugin) { + loadedAllPluginsRef.current = true; + } + } + }; + + loadPlugin(); + }); + }; + + const pluginsToLoad = prefix ? getPluginsByPrefix(prefix) : plugins; + + if (pluginsToLoad.length) { + loadPlugins(pluginsToLoad); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useDeepCompareEffect(() => { + const updatePluginsWithNewProps = () => { + const updatedComponents = Object.keys(pluginComponents).reduce((previousPluginComponents, pluginKey) => { + const PluginComponent = pluginComponents[pluginKey]; + // Check if the component is a valid React element and not a loading or error state + if (React.isValidElement(PluginComponent)) { + const UpdatedComponent = React.cloneElement(PluginComponent, pluggableComponentProps); + return { + ...previousPluginComponents, + [pluginKey]: UpdatedComponent, + }; + } + return previousPluginComponents; + }, {}); + + setPluginComponents(updatedComponents); + }; + + if (loadedAllPluginsRef.current) { + updatePluginsWithNewProps(); + } + }, [pluggableComponentProps]); + + return ( +
+ {Object.entries(pluginComponents).map(([pluginKey, Component]) => ( + + {Component} + + ))} +
+ ); +}; + +MultiplePlugins.defaultProps = { + plugins: [], + pluggableComponentProps: {}, + prefix: '', + loadingComponent: null, + containerPluginsProps: {}, +}; + +MultiplePlugins.propTypes = { + plugins: PropTypes.arrayOf(PropTypes.shape({ + name: PropTypes.string.isRequired, + id: PropTypes.string.isRequired, + })), + pluggableComponentProps: PropTypes.shape({}), + prefix: PropTypes.string, + loadingComponent: PropTypes.node, + containerPluginsProps: PropTypes.shape({}), +}; + +export default MultiplePlugins; diff --git a/src/components/PluggableComponent/MultiplePlugins.test.jsx b/src/components/PluggableComponent/MultiplePlugins.test.jsx new file mode 100644 index 00000000..e3c15fc6 --- /dev/null +++ b/src/components/PluggableComponent/MultiplePlugins.test.jsx @@ -0,0 +1,114 @@ +import React from 'react'; +import { render, waitFor } from '@testing-library/react'; +import MultiplePlugins from './MultiplePlugins'; + +describe('MultiplePlugins', () => { + const mockPlugins = [ + { id: 'plugin1', name: 'Plugin1' }, + { id: 'plugin2', name: 'Plugin2' }, + ]; + + beforeEach(() => { + jest.resetModules(); + jest.clearAllMocks(); + }); + + /* eslint-disable react/prop-types */ + test('initializes with loading components for each plugin', async () => { + const { getAllByText } = render( + Loading...} + />, + ); + + const [pluginLoading1, pluginLoading2] = getAllByText('Loading...'); + + expect(pluginLoading1).toBeInTheDocument(); + expect(pluginLoading1).toHaveTextContent('Loading...'); + expect(pluginLoading2).toBeInTheDocument(); + expect(pluginLoading2).toHaveTextContent('Loading...'); + }); + + test('loads a plugins list successfully', async () => { + const mockValidPlugins = [ + { id: 'plugin1', name: 'communications-app-test-component' }, + ]; + + const MockPluginComponent = () =>
Mocked Plugin Component
; + + jest.mock( + '@node_modules/@openedx-plugins/communications-app-test-component', + () => MockPluginComponent, + ); + + const { getByTestId } = render( + , + ); + + await waitFor(() => { + const pluginComponent = getByTestId('plugin1'); + expect(pluginComponent).toBeInTheDocument(); + expect(pluginComponent).toHaveTextContent('Mocked Plugin Component'); + }); + }); + + test('loads a plugin successfully with prefix', async () => { + const MockPluginComponent = () =>
Mocked Plugin Component
; + + jest.mock( + '@node_modules/@openedx-plugins/communications-app-test-component', + () => MockPluginComponent, + ); + + const { getByTestId } = render( + , + ); + + await waitFor(() => { + const pluginComponent = getByTestId('communications-app-test-component'); + expect(pluginComponent).toBeInTheDocument(); + expect(pluginComponent).toHaveTextContent('Mocked Plugin Component'); + }); + }); + + test('loads a plugin successfully with prefix changing component props', async () => { + const MockPluginComponent = (props) =>
{props.title}
; + + jest.mock( + '@node_modules/@openedx-plugins/communications-app-test-component', + () => MockPluginComponent, + ); + + const { getByTestId, rerender } = render( + , + ); + + // Wait for the component to be in the document with initial props + await waitFor(() => { + const pluginComponent = getByTestId('mock-plugin-props'); + expect(pluginComponent).toBeInTheDocument(); + expect(pluginComponent).toHaveTextContent('Initial Title'); + }); + + rerender( + , + ); + + await waitFor(() => { + const pluginComponent = getByTestId('mock-plugin-props'); + expect(pluginComponent).toBeInTheDocument(); + expect(pluginComponent).toHaveTextContent('Title updated'); + }); + }); +}); diff --git a/src/components/PluggableComponent/__snapshots__/index.test.jsx.snap b/src/components/PluggableComponent/__snapshots__/index.test.jsx.snap new file mode 100644 index 00000000..79ff9351 --- /dev/null +++ b/src/components/PluggableComponent/__snapshots__/index.test.jsx.snap @@ -0,0 +1,12 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PluggableComponent renders correctly 1`] = ` +
+ +
+`; diff --git a/src/components/PluggableComponent/index.jsx b/src/components/PluggableComponent/index.jsx new file mode 100644 index 00000000..3f8a6878 --- /dev/null +++ b/src/components/PluggableComponent/index.jsx @@ -0,0 +1,137 @@ +import React, { useState, useEffect, useRef } from 'react'; +import loadable from '@loadable/component'; +import PropTypes from 'prop-types'; +import useDeepCompareEffect from 'use-deep-compare-effect'; + +import { isPluginAvailable } from './utils'; +import MultiplePlugins from './MultiplePlugins'; + +/** + * PluggableComponent - A component that allows dynamic loading and replacement of child components. + * + * @param {object} props - Component props + * @param {React.ReactNode} props.children - Child elements to be passed to the plugin component + * @param {React.ReactNode} props.loadingComponent - Component to be rendered while the plugin is loading + * @param {string} props.as - String indicating the module to import dynamically + * @param {string} props.id - Identifier for the plugin + * @param {object} props.pluggableComponentProps - Additional props to be passed to the dynamically loaded component + * @param {string} props.pluginsPrefix - Prefix used to identify plugins. This is used without the 'plugins' prop. + * @param {Array} props.plugins - + * An array of plugin configurations. + * Each configuration is an object that may include plugin-specific properties. + * @param {object} props.containerPluginsProps - + * Props to be spread on the container that wraps multiple plugin components. + * Useful for passing classNames or styles for layout. + * @returns {React.ReactNode} - Rendered component + */ +const PluggableComponent = ({ + children, + loadingComponent, + as, + id, + pluginsPrefix, + plugins, + containerPluginsProps, + ...pluggableComponentProps +}) => { + const [newComponent, setNewComponent] = useState(children || null); + const loadedComponentRef = useRef(null); + const [isLoadingComponent, setIsLoadingComponent] = useState(false); + const hasConfigForMultiplePlugins = pluginsPrefix || plugins.length; + + useEffect(() => { + const loadPluginComponent = async () => { + setIsLoadingComponent(true); + + try { + const hasModuleInstalled = await isPluginAvailable(as); + + if (hasModuleInstalled) { + const PluginComponent = loadable(() => import(`@node_modules/@openedx-plugins/${as}`)); + + const component = children ? ( + + {children} + + ) : ( + + ); + + setNewComponent(component); + loadedComponentRef.current = true; + } + } catch (error) { + console.error(`Failed to load plugin ${as}:`, error); + } finally { + setIsLoadingComponent(false); + } + }; + + const hasNoPlugins = !pluginsPrefix && !plugins.length; + + if (hasNoPlugins) { + loadPluginComponent(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [id, as]); + + useEffect(() => { + if (newComponent && children && loadedComponentRef.current) { + const updatedComponent = React.cloneElement(newComponent, pluggableComponentProps, children); + setNewComponent(updatedComponent); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [children]); + + useDeepCompareEffect(() => { + if (newComponent && loadedComponentRef.current) { + const updatedComponent = React.cloneElement(newComponent, pluggableComponentProps, children); + setNewComponent(updatedComponent); + } + }, [pluggableComponentProps]); + + if (hasConfigForMultiplePlugins) { + return ( + + ); + } + + if (isLoadingComponent && loadingComponent) { + return loadingComponent; + } + + return newComponent; +}; + +PluggableComponent.defaultProps = { + loadingComponent: null, + plugins: [], + pluginsPrefix: '', + containerPluginsProps: {}, + children: undefined, + as: '', + id: '', +}; + +PluggableComponent.propTypes = { + children: PropTypes.node, + loadingComponent: PropTypes.node, + as: PropTypes.string, + id: PropTypes.string, + pluginsPrefix: PropTypes.string, + containerPluginsProps: PropTypes.shape({}), + plugins: PropTypes.arrayOf(PropTypes.shape({ + name: PropTypes.string.isRequired, + id: PropTypes.string.isRequired, + })), +}; + +export default PluggableComponent; diff --git a/src/components/PluggableComponent/index.test.jsx b/src/components/PluggableComponent/index.test.jsx new file mode 100644 index 00000000..63b5f7d4 --- /dev/null +++ b/src/components/PluggableComponent/index.test.jsx @@ -0,0 +1,212 @@ +import React, { useState } from 'react'; +import { + render, waitFor, screen, fireEvent, +} from '@testing-library/react'; +import PluggableComponent from '.'; + +const ToggleContentComponent = () => { + const [showContent, setShowContent] = useState(false); + + return ( +
+ + {showContent &&
Toggle On
} +
+ ); +}; + +describe('PluggableComponent', () => { + beforeEach(() => { + jest.resetModules(); + }); + test('renders correctly', async () => { + const handleClickMock = jest.fn(); + const props = { + title: 'button title', + handleClick: handleClickMock, + }; + + const { container } = render( + +

Hi this is the original component

+
, + ); + + await waitFor(() => { + const buttonComponent = screen.getByTestId('button-test'); + expect(buttonComponent).toBeInTheDocument(); + expect(screen.getByText(props.title)).toBeInTheDocument(); + fireEvent.click(buttonComponent); + expect(handleClickMock).toHaveBeenCalled(); + expect(container).toMatchSnapshot(); + }); + }); + + test('loads children component when import is invalid', async () => { + render( + +
Plugin Loaded
+
, + ); + + await waitFor(() => { + const defaultComponent = screen.getByTestId('plugin'); + expect(screen.getByText('Plugin Loaded')).toBeInTheDocument(); + expect(defaultComponent).toBeInTheDocument(); + }); + }); + + test('loads children component when import is empty', async () => { + render( + +
Plugin Loaded
+
, + ); + + await waitFor(() => { + const defaultComponent = screen.getByTestId('plugin'); + expect(screen.getByText('Plugin Loaded')).toBeInTheDocument(); + expect(defaultComponent).toBeInTheDocument(); + }); + }); + + test('returns null when do not have children and import is invalid', async () => { + render( + , + ); + + await waitFor(() => { + expect(screen.queryByTestId('plugin')).not.toBeInTheDocument(); + }); + }); + + test('updates component when props change', async () => { + const { rerender } = render( + , + ); + + await waitFor(() => { + expect(screen.getByText('Testing title component')).toBeInTheDocument(); + }); + + rerender( + , + ); + + await waitFor(() => { + expect(screen.getByText('Testing a new title component')).toBeInTheDocument(); + }); + }); + + test('updates component when children change', async () => { + const { getByText, getByTestId } = render( + + + , + ); + + await waitFor(() => { + const toggleContent = screen.queryByTestId('toggle-content'); + expect(toggleContent).not.toBeInTheDocument(); + }); + + const toggleButton = getByText('Toggle Content'); + fireEvent.click(toggleButton); + + await waitFor(() => { + const toggleContent = getByTestId('toggle-content'); + expect(toggleContent).toBeInTheDocument(); + expect(toggleContent).toHaveTextContent('Toggle On'); + }); + }); + + test('renders loadingComponent while the plugin is loading', async () => { + jest.mock('./utils', () => ({ + isPluginAvailable: jest.fn().mockImplementation( + () => new Promise((resolve) => { + setTimeout(() => { + resolve(true); + }, 1000); + }), + ), + })); + + await waitFor(() => { + const { getByText } = render( + Loading...} + />, + ); + expect(getByText('Loading...')).toBeInTheDocument(); + }); + }); + + test('renders multiple plugins', async () => { + const MockPluginComponent = () =>
Mocked Plugin Component
; + + // Mock the dynamic import to resolve with the MockPluginComponent + jest.mock('@node_modules/@openedx-plugins/communications-app-test-component', () => MockPluginComponent, { virtual: true }); + + const { getByTestId } = render( + , + ); + + await waitFor(() => { + const pluginComponent = getByTestId('plugin-test'); + expect(pluginComponent).toBeInTheDocument(); + expect(pluginComponent).toHaveTextContent('Mocked Plugin Component'); + }); + }); + + test('renders multiple plugins with prefix', async () => { + const MockPluginComponent = () =>
Mocked Plugin Component
; + + // Mock the dynamic import to resolve with the MockPluginComponent + jest.mock('@node_modules/@openedx-plugins/communications-app-test-component', () => MockPluginComponent, { virtual: true }); + + const { getByTestId } = render( + , + ); + + await waitFor(() => { + const pluginComponent = getByTestId('plugin-test'); + expect(pluginComponent).toBeInTheDocument(); + expect(pluginComponent).toHaveTextContent('Mocked Plugin Component'); + }); + }); +}); diff --git a/src/components/PluggableComponent/utils.js b/src/components/PluggableComponent/utils.js new file mode 100644 index 00000000..c2135297 --- /dev/null +++ b/src/components/PluggableComponent/utils.js @@ -0,0 +1,53 @@ +import packageJson from '../../../package.json'; + +/** + * Checks if a given plugin is available by attempting to dynamically import it. + * + * This function tries to dynamically import a plugin based on its name. It constructs + * a path using a predefined pattern that points to the plugin's location within + * the `node_modules` directory. If the import succeeds, it means the plugin is available. + * + * @param {string} pluginName - The name of the plugin to check for availability. + * @returns {Promise} A promise that resolves to `true` if the plugin is available, otherwise `false`. + */ +export const isPluginAvailable = async (pluginName) => { + if (!pluginName) { return false; } + + try { + await import(`@node_modules/@openedx-plugins/${pluginName}`); + return true; + } catch { + return false; + } +}; + +/** + * Retrieves a list of plugins that match a given prefix from the project's dependencies. + * + * This function filters the project's dependencies listed in the `package.json` file, + * looking for ones that match a specified prefix. The prefix is used to identify related + * plugins within the `@openedx-plugins` namespace. Each matching plugin's name is then + * formatted and returned as part of an array of plugin objects, each containing the + * plugin's `id` and `name`. + * + * @param {string} prefix - The prefix to filter plugins by. + * @returns {Array} An array of objects, each representing a plugin. Each object + * contains the `id` and `name` of the plugin. + */ +export const getPluginsByPrefix = (prefix) => { + const dependenciesKeys = Object.keys(packageJson.dependencies); + return dependenciesKeys.reduce( + (pluginsFiltered, pluginName) => { + if (pluginName.startsWith(`@openedx-plugins/${prefix}`)) { + const pluginFormatted = pluginName.split('/')[1]; + const pluginData = { + id: pluginFormatted, + name: pluginFormatted, + }; + return [...pluginsFiltered, pluginData]; + } + return pluginsFiltered; + }, + [], + ); +}; diff --git a/src/components/PluggableComponent/utils.test.js b/src/components/PluggableComponent/utils.test.js new file mode 100644 index 00000000..a70257b0 --- /dev/null +++ b/src/components/PluggableComponent/utils.test.js @@ -0,0 +1,52 @@ +import { isPluginAvailable, getPluginsByPrefix } from './utils'; + +jest.mock('../../../package.json', () => ({ + dependencies: { + '@openedx-plugins/plugin-a': '1.0.0', + '@openedx-plugins/prefix-plugin-b': '1.2.3', + 'some-other-dependency': '2.3.4', + '@openedx-plugins/prefix-plugin-c': '3.4.5', + '@another-prefix/plugin-d': '4.5.6', + }, +})); + +describe('Utils', () => { + describe('isPluginAvailable util', () => { + test('returns true if a plugin is installed', async () => { + const checkBoxPlugin = await isPluginAvailable('communications-app-test-component'); + expect(checkBoxPlugin).toBe(true); + }); + + test('returns false if a plugin is not installed', async () => { + const nonexistentPlugin = await isPluginAvailable('nonexistentPlugin'); + expect(nonexistentPlugin).toBe(false); + }); + + test('returns false if an empty string is provided as plugin name', async () => { + const emptyPlugin = await isPluginAvailable(''); + expect(emptyPlugin).toBe(false); + }); + + test('returns false if null is provided as plugin name', async () => { + const nullPLugin = await isPluginAvailable(null); + expect(nullPLugin).toBe(false); + }); + }); + + describe('getPluginsByPrefix', () => { + test('should return an array of plugin names filtered by the given prefix', () => { + const plugins = getPluginsByPrefix('prefix'); + const mockExpectedResult = [ + { id: 'prefix-plugin-b', name: 'prefix-plugin-b' }, + { id: 'prefix-plugin-c', name: 'prefix-plugin-c' }, + ]; + expect(plugins).toEqual(mockExpectedResult); + }); + + test('should return an empty array if no plugins match the given prefix', () => { + const plugins = getPluginsByPrefix('nonexistent'); + expect(plugins).toHaveLength(0); + expect(plugins).toEqual([]); + }); + }); +}); diff --git a/src/components/bulk-email-tool/BulkEmailTool.jsx b/src/components/bulk-email-tool/BulkEmailTool.jsx index 1ebec152..eff86591 100644 --- a/src/components/bulk-email-tool/BulkEmailTool.jsx +++ b/src/components/bulk-email-tool/BulkEmailTool.jsx @@ -7,10 +7,11 @@ import { Container } from '@openedx/paragon'; import { FormattedMessage } from '@edx/frontend-platform/i18n'; import BulkEmailTaskManager from './bulk-email-task-manager/BulkEmailTaskManager'; import NavigationTabs from '../navigation-tabs/NavigationTabs'; -import BulkEmailForm from './bulk-email-form'; +import BulkEmailForm from './bulk-email-form/BulkEmailForm'; import { CourseMetadataContext } from '../page-container/PageContainer'; import { BulkEmailProvider } from './bulk-email-context'; import BackToInstructor from '../navigation-tabs/BackToInstructor'; +import PluggableComponent from '../PluggableComponent'; export default function BulkEmailTool() { const { courseId } = useParams(); @@ -40,7 +41,12 @@ export default function BulkEmailTool() { />
- + + +
diff --git a/src/components/bulk-email-tool/bulk-email-form/BulkEmailForm.jsx b/src/components/bulk-email-tool/bulk-email-form/BulkEmailForm.jsx deleted file mode 100644 index 29de06d7..00000000 --- a/src/components/bulk-email-tool/bulk-email-form/BulkEmailForm.jsx +++ /dev/null @@ -1,404 +0,0 @@ -/* eslint-disable react/no-unstable-nested-components */ -import React, { useContext, useEffect, useState } from 'react'; -import PropTypes from 'prop-types'; -import { - Button, - Form, Icon, StatefulButton, Toast, useToggle, -} from '@openedx/paragon'; -import { - SpinnerSimple, Cancel, Send, Event, Check, -} from '@openedx/paragon/icons'; -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; -import classNames from 'classnames'; -import { getConfig } from '@edx/frontend-platform'; -import TextEditor from '../text-editor/TextEditor'; -import BulkEmailRecipient from './bulk-email-recipient'; -import TaskAlertModal from '../task-alert-modal'; -import useTimeout from '../../../utils/useTimeout'; -import useMobileResponsive from '../../../utils/useMobileResponsive'; -import ScheduleEmailForm from './ScheduleEmailForm'; -import messages from './messages'; -import { BulkEmailContext } from '../bulk-email-context'; -import { - addRecipient, - clearEditor, - clearErrorState, - handleEditorChange, - removeRecipient, -} from './data/actions'; -import { editScheduledEmailThunk, postBulkEmailThunk } from './data/thunks'; -import { getScheduledBulkEmailThunk } from '../bulk-email-task-manager/bulk-email-scheduled-emails-table/data/thunks'; - -import './bulkEmailForm.scss'; - -export const FORM_SUBMIT_STATES = { - DEFAULT: 'default', - PENDING: 'pending', - COMPLETE: 'complete', - COMPLETE_SCHEDULE: 'completed_schedule', - SCHEDULE: 'schedule', - RESCHEDULE: 'reschedule', - ERROR: 'error', -}; - -const FORM_ACTIONS = { - POST: 'POST', - PATCH: 'PATCH', -}; - -function BulkEmailForm(props) { - const { - courseId, - cohorts, - courseModes, - intl, - } = props; - const [{ editor }, dispatch] = useContext(BulkEmailContext); - const [emailFormStatus, setEmailFormStatus] = useState(FORM_SUBMIT_STATES.DEFAULT); - const [emailFormValidation, setEmailFormValidation] = useState({ - // set these as true on initialization, to prevent invalid messages from prematurely showing - subject: true, - body: true, - recipients: true, - schedule: true, - }); - const [isTaskAlertOpen, openTaskAlert, closeTaskAlert] = useToggle(false); - const [isScheduled, toggleScheduled] = useState(false); - const isMobile = useMobileResponsive(); - - /** - * Since we are working with both an old and new API endpoint, the body for the POST - * and the PATCH have different signatures. Therefore, based on the action required, we need to - * format the data properly to be accepted on the back end. - * @param {*} action "POST" or "PATCH" of the FORM_ACTIONS constant - * @returns formatted Data - */ - const formatDataForFormAction = (action) => { - if (action === FORM_ACTIONS.POST) { - const emailData = new FormData(); - emailData.append('action', 'send'); - emailData.append('send_to', JSON.stringify(editor.emailRecipients)); - emailData.append('subject', editor.emailSubject); - emailData.append('message', editor.emailBody); - if (isScheduled) { - emailData.append('schedule', new Date(`${editor.scheduleDate} ${editor.scheduleTime}`).toISOString()); - } - return emailData; - } - if (action === FORM_ACTIONS.PATCH) { - return { - email: { - targets: editor.emailRecipients, - subject: editor.emailSubject, - message: editor.emailBody, - id: editor.emailId, - }, - schedule: isScheduled ? new Date(`${editor.scheduleDate} ${editor.scheduleTime}`).toISOString() : null, - }; - } - return {}; - }; - - /** - * This function resets the form based on what state the form is currently in. Used after - * successfully sending or scheduling and email, or on error. - * - * @param {Boolean} error If true, resets just the state of the form, and not the editor. - * if false, reset the form completely, and wipe all email data form the form. - */ - const resetEmailForm = (error) => { - if (error) { - dispatch(clearErrorState()); - } else { - dispatch(clearEditor()); - } - }; - - /** - * Allows for a delayed form reset, to give the user time to process completion and error - * states before reseting the form. - */ - const delayedEmailFormReset = useTimeout( - () => resetEmailForm(editor.errorRetrievingData), - 3000, - ); - - const onFormChange = (event) => dispatch(handleEditorChange(event.target.name, event.target.value)); - - const onRecipientChange = (event) => { - if (event.target.checked) { - dispatch(addRecipient(event.target.value)); - // if "All Learners" is checked then we want to remove any cohorts, verified learners, and audit learners - if (event.target.value === 'learners') { - editor.emailRecipients.forEach(recipient => { - if (/^cohort/.test(recipient) || /^track/.test(recipient)) { - dispatch(removeRecipient(recipient)); - } - }); - } - } else { - dispatch(removeRecipient(event.target.value)); - } - }; - - const validateDateTime = (date, time) => { - if (isScheduled) { - const now = new Date(); - const newSchedule = new Date(`${editor.scheduleDate} ${editor.scheduleTime}`); - return !!date && !!time && newSchedule > now; - } - return true; - }; - - const validateEmailForm = () => { - const subjectValid = editor.emailSubject.length !== 0; - const bodyValid = editor.emailBody.length !== 0; - const recipientsValid = editor.emailRecipients.length !== 0; - const scheduleValid = validateDateTime(editor.scheduleDate, editor.scheduleTime); - setEmailFormValidation({ - subject: subjectValid, - recipients: recipientsValid, - body: bodyValid, - schedule: scheduleValid, - }); - return subjectValid && bodyValid && recipientsValid && scheduleValid; - }; - - const createEmailTask = async () => { - if (validateEmailForm()) { - if (editor.editMode) { - const editedEmail = formatDataForFormAction(FORM_ACTIONS.PATCH); - await dispatch(editScheduledEmailThunk(editedEmail, courseId, editor.schedulingId)); - } else { - const emailData = formatDataForFormAction(FORM_ACTIONS.POST); - await dispatch(postBulkEmailThunk(emailData, courseId)); - } - dispatch(getScheduledBulkEmailThunk(courseId, 1)); - } - }; - - /** - * State manager for the various states the form can be in at any given time. - * The states of the form are based off various pieces of the editor store, and - * calculates what state and whether to reset the form based on these booleans. - * Any time the form needs to change state, the conditions for that state change should - * placed here to prevent unecessary rerenders and implicit/flakey state update batching. - */ - useEffect(() => { - if (editor.isLoading) { - setEmailFormStatus(FORM_SUBMIT_STATES.PENDING); - return; - } - if (editor.errorRetrievingData) { - setEmailFormStatus(FORM_SUBMIT_STATES.ERROR); - delayedEmailFormReset(); - return; - } - if (editor.formComplete) { - if (isScheduled) { - setEmailFormStatus(FORM_SUBMIT_STATES.COMPLETE_SCHEDULE); - } else { - setEmailFormStatus(FORM_SUBMIT_STATES.COMPLETE); - } - delayedEmailFormReset(); - return; - } - if (editor.editMode === true) { - toggleScheduled(true); - setEmailFormStatus(FORM_SUBMIT_STATES.RESCHEDULE); - } else if (isScheduled) { - setEmailFormStatus(FORM_SUBMIT_STATES.SCHEDULE); - } else { - setEmailFormStatus(FORM_SUBMIT_STATES.DEFAULT); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isScheduled, editor.editMode, editor.isLoading, editor.errorRetrievingData, editor.formComplete]); - - const AlertMessage = () => ( - <> -

{intl.formatMessage(messages.bulkEmailTaskAlertRecipients, { subject: editor.emailSubject })}

-
    - {editor.emailRecipients.map((group) => ( -
  • {group}
  • - ))} -
- {!isScheduled && ( -

- {intl.formatMessage(messages.bulkEmailInstructionsCaution)} - {intl.formatMessage(messages.bulkEmailInstructionsCautionMessage)} -

- )} - - ); - - const EditMessage = () => ( - <> -

- {intl.formatMessage(messages.bulkEmailTaskAlertEditingDate, { - dateTime: new Date(`${editor.scheduleDate} ${editor.scheduleTime}`).toLocaleString(), - })} -

-

- {intl.formatMessage(messages.bulkEmailTaskAlertEditingSubject, { - subject: editor.emailSubject, - })} -

-

{intl.formatMessage(messages.bulkEmailTaskAlertEditingTo)}

-
    - {editor.emailRecipients.map((group) => ( -
  • {group}
  • - ))} -
-

{intl.formatMessage(messages.bulkEmailTaskAlertEditingWarning)}

- {!isScheduled && ( -

- {intl.formatMessage(messages.bulkEmailInstructionsCaution)} - {intl.formatMessage(messages.bulkEmailInstructionsCautionMessage)} -

- )} - - ); - - return ( -
- { - closeTaskAlert(); - if (event.target.name === 'continue') { - createEmailTask(); - } - }} - /> -
- - - {intl.formatMessage(messages.bulkEmailSubjectLabel)} - - - {intl.formatMessage(messages.bulkEmailFormSubjectTip)} - - {!emailFormValidation.subject && ( - - {intl.formatMessage(messages.bulkEmailFormSubjectError)} - - )} - - - {intl.formatMessage(messages.bulkEmailBodyLabel)} - dispatch(handleEditorChange('emailBody', value))} value={editor.emailBody} /> - {!emailFormValidation.body && ( - - {intl.formatMessage(messages.bulkEmailFormBodyError)} - - )} - -
-

{intl.formatMessage(messages.bulkEmailInstructionsProofreading)}

-
- - {getConfig().SCHEDULE_EMAIL_SECTION && ( -
- toggleScheduled((prev) => !prev)} - disabled={emailFormStatus === FORM_SUBMIT_STATES.PENDING} - > - {intl.formatMessage(messages.bulkEmailFormScheduleBox)} - -
- )} - {isScheduled && ( - - )} -
- {editor.editMode && } - { - event.preventDefault(); - openTaskAlert(); - }} - state={emailFormStatus} - icons={{ - [FORM_SUBMIT_STATES.DEFAULT]: , - [FORM_SUBMIT_STATES.SCHEDULE]: , - [FORM_SUBMIT_STATES.RESCHEDULE]: , - [FORM_SUBMIT_STATES.PENDING]: , - [FORM_SUBMIT_STATES.COMPLETE]: , - [FORM_SUBMIT_STATES.COMPLETE_SCHEDULE]: , - [FORM_SUBMIT_STATES.ERROR]: , - }} - labels={{ - [FORM_SUBMIT_STATES.DEFAULT]: intl.formatMessage(messages.bulkEmailSubmitButtonDefault), - [FORM_SUBMIT_STATES.SCHEDULE]: intl.formatMessage(messages.bulkEmailSubmitButtonSchedule), - [FORM_SUBMIT_STATES.RESCHEDULE]: intl.formatMessage(messages.bulkEmailSubmitButtonReschedule), - [FORM_SUBMIT_STATES.PENDING]: intl.formatMessage(messages.bulkEmailSubmitButtonPending), - [FORM_SUBMIT_STATES.COMPLETE]: intl.formatMessage(messages.bulkEmailSubmitButtonComplete), - [FORM_SUBMIT_STATES.COMPLETE_SCHEDULE]: intl.formatMessage( - messages.bulkEmailSubmitButtonCompleteSchedule, - ), - [FORM_SUBMIT_STATES.ERROR]: intl.formatMessage(messages.bulkEmailSubmitButtonError), - }} - disabledStates={[ - FORM_SUBMIT_STATES.PENDING, - FORM_SUBMIT_STATES.COMPLETE, - FORM_SUBMIT_STATES.COMPLETE_SCHEDULE, - ]} - /> - resetEmailForm(emailFormStatus === FORM_SUBMIT_STATES.ERROR)} - > - {emailFormStatus === FORM_SUBMIT_STATES.ERROR && intl.formatMessage(messages.bulkEmailFormError)} - {emailFormStatus === FORM_SUBMIT_STATES.COMPLETE && intl.formatMessage(messages.bulkEmailFormSuccess)} - {emailFormStatus === FORM_SUBMIT_STATES.COMPLETE_SCHEDULE - && intl.formatMessage(messages.bulkEmailFormScheduledSuccess)} - -
-
- -
- ); -} - -BulkEmailForm.defaultProps = { - cohorts: [], -}; - -BulkEmailForm.propTypes = { - courseId: PropTypes.string.isRequired, - cohorts: PropTypes.arrayOf(PropTypes.string), - intl: intlShape.isRequired, - courseModes: PropTypes.arrayOf( - PropTypes.shape({ - slug: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, - }), - ).isRequired, -}; - -export default injectIntl(BulkEmailForm); diff --git a/src/components/bulk-email-tool/bulk-email-form/BulkEmailForm/context/index.js b/src/components/bulk-email-tool/bulk-email-form/BulkEmailForm/context/index.js new file mode 100644 index 00000000..669e840f --- /dev/null +++ b/src/components/bulk-email-tool/bulk-email-form/BulkEmailForm/context/index.js @@ -0,0 +1,11 @@ +import contextFactory from '@communications-app/src/utils/contextFactory'; + +import { INITIAL_STATE, reducer } from './reducer'; + +export const { + useSelector, + withContextProvider, + useDispatch, + DispatchContext, + StateContext, +} = contextFactory(reducer, INITIAL_STATE); diff --git a/src/components/bulk-email-tool/bulk-email-form/BulkEmailForm/context/index.test.js b/src/components/bulk-email-tool/bulk-email-form/BulkEmailForm/context/index.test.js new file mode 100644 index 00000000..e9288204 --- /dev/null +++ b/src/components/bulk-email-tool/bulk-email-form/BulkEmailForm/context/index.test.js @@ -0,0 +1,36 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { useContext } from 'react'; + +import { INITIAL_STATE } from './reducer'; + +import { + useSelector, useDispatch, StateContext, DispatchContext, +} from '.'; + +describe('BulkEmailForm stateContext', () => { + test('useSelector returns the state', () => { + const { result } = renderHook(() => useSelector((state) => state)); + expect(result.current).toEqual(INITIAL_STATE); + }); + + test('Context contains the initial value', () => { + const { + result: { current: stateContextValue }, + } = renderHook(() => useContext(StateContext)); + const { + result: { current: dispatchContextValue }, + } = renderHook(() => useContext(DispatchContext)); + expect(stateContextValue).toEqual(INITIAL_STATE); + expect(dispatchContextValue).toBeTruthy(); + }); + + test("useDispatch returns the context's dispatch", () => { + const { + result: { current: hookDispatch }, + } = renderHook(() => useDispatch()); + const { + result: { current: dispatchContextValue }, + } = renderHook(() => useContext(DispatchContext)); + expect(hookDispatch).toEqual(dispatchContextValue); + }); +}); diff --git a/src/components/bulk-email-tool/bulk-email-form/BulkEmailForm/context/reducer.js b/src/components/bulk-email-tool/bulk-email-form/BulkEmailForm/context/reducer.js new file mode 100644 index 00000000..181f315d --- /dev/null +++ b/src/components/bulk-email-tool/bulk-email-form/BulkEmailForm/context/reducer.js @@ -0,0 +1,49 @@ +import { produce } from 'immer'; + +export const INITIAL_STATE = { + form: { + isFormValid: true, + isFormSubmitted: false, + scheduleValid: true, + isScheduled: false, + isEditMode: false, + formStatus: 'default', + isScheduleButtonClicked: false, + courseId: '', + cohorts: '', + scheduleDate: '', + scheduleTime: '', + isScheduledSubmitted: false, + emailId: '', + schedulingId: '', + emailRecipients: [], + subject: '', + body: '', + }, +}; +const ActionTypes = { + UPDATE_FORM: 'UPDATE_FORM', + RESET_FORM: 'RESET_FORM', +}; + +export const actionCreators = { + updateForm: (updates) => ({ type: ActionTypes.UPDATE_FORM, updates }), + resetForm: () => ({ type: ActionTypes.RESET_FORM }), +}; + +// eslint-disable-next-line consistent-return +export const reducer = produce((draft, action) => { + switch (action.type) { + case ActionTypes.UPDATE_FORM: { + Object.assign(draft.form, action.updates); + break; + } + + case ActionTypes.RESET_FORM: { + return INITIAL_STATE; + } + + default: + break; + } +}, INITIAL_STATE); diff --git a/src/components/bulk-email-tool/bulk-email-form/BulkEmailForm/context/reducer.test.js b/src/components/bulk-email-tool/bulk-email-form/BulkEmailForm/context/reducer.test.js new file mode 100644 index 00000000..efc41295 --- /dev/null +++ b/src/components/bulk-email-tool/bulk-email-form/BulkEmailForm/context/reducer.test.js @@ -0,0 +1,25 @@ +import { reducer, actionCreators, INITIAL_STATE } from './reducer'; + +describe('BulkEmailForm reducer tests', () => { + test('should update the form correctly', () => { + const state = reducer(INITIAL_STATE, actionCreators.updateForm({ emailId: 'example@email.com', newKey: 'test' })); + expect(state.form.emailId).toBe('example@email.com'); + expect(state.form.newKey).toBe('test'); + }); + + test('should reset the form state', () => { + const { form } = INITIAL_STATE; + const updatedState = { ...INITIAL_STATE, form: { ...form, emailId: 'example@email.com' } }; + const state = reducer(updatedState, actionCreators.resetForm()); + expect(state).toEqual(INITIAL_STATE); + }); + + test('should update and reset the form state', () => { + const state = reducer(INITIAL_STATE, actionCreators.updateForm({ emailId: 'example@email.com', newKey: 'test' })); + expect(state.form.emailId).toBe('example@email.com'); + expect(state.form.newKey).toBe('test'); + const stateRestored = reducer(state, actionCreators.resetForm()); + expect(stateRestored).toEqual(INITIAL_STATE); + expect(stateRestored.form.newKey).toBeUndefined(); + }); +}); diff --git a/src/components/bulk-email-tool/bulk-email-form/BulkEmailForm/index.jsx b/src/components/bulk-email-tool/bulk-email-form/BulkEmailForm/index.jsx new file mode 100644 index 00000000..80fb365e --- /dev/null +++ b/src/components/bulk-email-tool/bulk-email-form/BulkEmailForm/index.jsx @@ -0,0 +1,122 @@ +import { useContext } from 'react'; +import classNames from 'classnames'; +import PropTypes from 'prop-types'; +import useDeepCompareEffect from 'use-deep-compare-effect'; +import { + Form, + Spinner, + useToggle, +} from '@openedx/paragon'; +import { BulkEmailContext } from '../../bulk-email-context'; +import useMobileResponsive from '../../../../utils/useMobileResponsive'; +import PluggableComponent from '../../../PluggableComponent'; + +import { withContextProvider, useDispatch } from './context'; +import { actionCreators as formActions } from './context/reducer'; + +const BulkEmailForm = ({ courseId, cohorts, courseModes }) => { + const isMobile = useMobileResponsive(); + const [{ editor }] = useContext(BulkEmailContext); + const [isTaskAlertOpen, openTaskAlert, closeTaskAlert] = useToggle(false); + const dispatch = useDispatch(); + + useDeepCompareEffect(() => { + /* istanbul ignore next */ + if (editor.editMode) { + const newRecipientsValue = editor.emailRecipients; + const newSubjectValue = editor.emailSubject; + const newBodyValue = editor.emailBody; + const newScheduleDate = editor.scheduleDate; + const newScheduleTime = editor.scheduleTime; + const newEmailId = editor.emailId; + const newSchedulingId = editor.schedulingId; + + dispatch(formActions.updateForm({ + isEditMode: true, + formStatus: 'reschedule', + isScheduled: true, + emailId: newEmailId, + schedulingId: newSchedulingId, + scheduleDate: newScheduleDate, + scheduleTime: newScheduleTime, + emailRecipients: newRecipientsValue, + subject: newSubjectValue, + body: newBodyValue, + })); + } + }, [editor, dispatch]); + + return ( +
+
+ + + + + + + + + )} + /> + + + + + + +
+ ); +}; + +BulkEmailForm.defaultProps = { + cohorts: [], + courseModes: [], +}; + +BulkEmailForm.propTypes = { + courseId: PropTypes.string.isRequired, + cohorts: PropTypes.arrayOf(PropTypes.string), + courseModes: PropTypes.arrayOf( + PropTypes.shape({ + slug: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + }), + ), +}; + +export default withContextProvider(BulkEmailForm); diff --git a/src/components/bulk-email-tool/bulk-email-form/BulkEmailForm/index.test.jsx b/src/components/bulk-email-tool/bulk-email-form/BulkEmailForm/index.test.jsx new file mode 100644 index 00000000..20855b8a --- /dev/null +++ b/src/components/bulk-email-tool/bulk-email-form/BulkEmailForm/index.test.jsx @@ -0,0 +1,143 @@ +import React from 'react'; +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import { IntlProvider } from 'react-intl'; +import { + render, waitFor, screen, fireEvent, +} from '@testing-library/react'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { getConfig } from '@edx/frontend-platform'; + +import { BulkEmailProvider } from '../../bulk-email-context'; +import cohortFactory from '../data/__factories__/bulkEmailFormCohort.factory'; + +import BulkEmailForm from '.'; + +// eslint-disable-next-line react/prop-types +jest.mock('../../text-editor/TextEditor', () => ({ value, onChange }) => ( +