diff --git a/Makefile b/Makefile index 5c2f29356c..3d705bfb10 100644 --- a/Makefile +++ b/Makefile @@ -35,13 +35,12 @@ pull_translations: cd src/i18n/messages \ && atlas pull $(ATLAS_OPTIONS) \ translations/frontend-component-ai-translations/src/i18n/messages:frontend-component-ai-translations \ - translations/frontend-lib-content-components/src/i18n/messages:frontend-lib-content-components \ translations/frontend-platform/src/i18n/messages:frontend-platform \ translations/paragon/src/i18n/messages:paragon \ translations/frontend-component-footer/src/i18n/messages:frontend-component-footer \ translations/frontend-app-course-authoring/src/i18n/messages:frontend-app-course-authoring - $(intl_imports) frontend-component-ai-translations frontend-lib-content-components frontend-platform paragon frontend-component-footer frontend-app-course-authoring + $(intl_imports) frontend-component-ai-translations frontend-platform paragon frontend-component-footer frontend-app-course-authoring # This target is used by Travis. validate-no-uncommitted-package-lock-changes: diff --git a/README.rst b/README.rst index d0680a32e5..0b2d30be71 100644 --- a/README.rst +++ b/README.rst @@ -145,10 +145,6 @@ Feature Description When a corresponding waffle flag is set, upon editing a block in Studio, the view is rendered by this MFE instead of by the XBlock's authoring view. The user remains in Studio. -.. note:: - - The new editors themselves are currently implemented in a repository outside ``openedx``: `frontend-lib-content-components `_, a dependency of this MFE. This repository is slated to be moved to the ``openedx`` org, however. - Feature: New Proctoring Exams View ================================== diff --git a/package-lock.json b/package-lock.json index 9503b8a0b4..f985d5030b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,11 @@ "version": "0.1.0", "license": "AGPL-3.0", "dependencies": { + "@codemirror/lang-html": "^6.0.0", + "@codemirror/lang-xml": "^6.0.0", + "@codemirror/lint": "^6.2.1", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", "@dnd-kit/core": "^6.1.0", "@dnd-kit/modifiers": "^7.0.0", "@dnd-kit/sortable": "^8.0.0", @@ -17,7 +22,6 @@ "@edx/frontend-component-footer": "^14.0.3", "@edx/frontend-component-header": "^5.3.3", "@edx/frontend-enterprise-hotjar": "^2.0.0", - "@edx/frontend-lib-content-components": "^2.6.0", "@edx/frontend-platform": "^8.0.3", "@edx/openedx-atlas": "^0.6.0", "@openedx-plugins/course-app-calculator": "file:plugins/course-apps/calculator", @@ -32,16 +36,22 @@ "@openedx-plugins/course-app-xpert_unit_summary": "file:plugins/course-apps/xpert_unit_summary", "@openedx/frontend-plugin-framework": "^1.2.1", "@openedx/paragon": "^22.5.1", + "@redux-devtools/extension": "^3.3.0", "@reduxjs/toolkit": "1.9.7", "@tanstack/react-query": "4.36.1", + "@tinymce/tinymce-react": "^3.14.0", "classnames": "2.5.1", + "codemirror": "^6.0.0", "email-validator": "2.0.4", + "fast-xml-parser": "^4.0.10", "file-saver": "^2.0.5", "formik": "2.4.6", + "frontend-components-tinymce-advanced-plugins": "^1.0.3", "jszip": "^3.10.1", "lodash": "4.17.21", "meilisearch": "^0.41.0", "moment": "2.30.1", + "moment-shortformat": "^2.1.0", "npm": "^10.8.1", "prop-types": "^15.8.1", "react": "17.0.2", @@ -49,6 +59,7 @@ "react-dom": "17.0.2", "react-error-boundary": "^4.0.13", "react-helmet": "^6.1.0", + "react-onclickoutside": "^6.13.0", "react-redux": "7.2.9", "react-responsive": "9.0.2", "react-router": "6.23.1", @@ -57,14 +68,20 @@ "react-textarea-autosize": "^8.5.3", "react-transition-group": "4.4.5", "redux": "4.0.5", + "redux-logger": "^3.0.6", + "redux-mock-store": "^1.5.4", + "redux-thunk": "^2.4.1", + "reselect": "^4.1.5", "start": "^5.1.0", + "tinymce": "^5.10.4", "universal-cookie": "^4.0.4", "uuid": "^3.4.0", + "xmlchecker": "^0.1.0", "yup": "0.31.1" }, "devDependencies": { "@edx/browserslist-config": "1.2.0", - "@edx/react-unit-test-utils": "2.1.1", + "@edx/react-unit-test-utils": "3.0.0", "@edx/reactifex": "^1.0.3", "@edx/stylelint-config-edx": "2.3.3", "@edx/typescript-config": "^1.0.1", @@ -73,6 +90,7 @@ "@testing-library/react": "12.1.5", "@testing-library/react-hooks": "^8.0.1", "@testing-library/user-event": "^13.2.1", + "@types/lodash": "^4.17.7", "axios": "^0.28.0", "axios-mock-adapter": "1.22.0", "eslint-import-resolver-webpack": "^0.13.8", @@ -2123,6 +2141,7 @@ }, "node_modules/@edx/browserslist-config": { "version": "1.2.0", + "dev": true, "license": "AGPL-3.0" }, "node_modules/@edx/eslint-config": { @@ -2256,103 +2275,6 @@ "react-router-dom": "^6.0.0" } }, - "node_modules/@edx/frontend-lib-content-components": { - "version": "2.6.5", - "license": "AGPL-3.0", - "dependencies": { - "@codemirror/lang-html": "^6.0.0", - "@codemirror/lang-xml": "^6.0.0", - "@codemirror/lint": "^6.2.1", - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.0.0", - "@dnd-kit/core": "^6.0.8", - "@dnd-kit/sortable": "^7.0.2", - "@dnd-kit/utilities": "^3.2.1", - "@edx/browserslist-config": "^1.1.1", - "@reduxjs/toolkit": "^1.8.1", - "@tinymce/tinymce-react": "^3.14.0", - "babel-polyfill": "6.26.0", - "classnames": "^2.5.1", - "codemirror": "^6.0.0", - "fast-xml-parser": "^4.0.10", - "frontend-components-tinymce-advanced-plugins": "^1.0.3", - "lodash-es": "^4.17.21", - "lodash.flatten": "^4.4.0", - "moment": "^2.29.4", - "moment-shortformat": "^2.1.0", - "react-dropzone": "^14.2.3", - "react-onclickoutside": "^6.13.0", - "react-redux": "^7.2.8", - "react-responsive": "8.2.0", - "react-transition-group": "4.4.2", - "redux": "4.1.2", - "redux-devtools-extension": "^2.13.9", - "redux-logger": "^3.0.6", - "redux-mock-store": "^1.5.4", - "redux-thunk": "^2.4.1", - "reselect": "^4.1.5", - "tinymce": "^5.10.4", - "video-react": "^0.15.0", - "video.js": "^7.18.1", - "xmlchecker": "^0.1.0" - }, - "peerDependencies": { - "@edx/frontend-platform": "^7.0.1 || ^8.0.0", - "@openedx/paragon": "^21.5.7 || ^22.0.0", - "prop-types": "^15.5.10", - "react": "^16.14.0 || ^17.0.0", - "react-dom": "^16.14.0 || ^17.0.0" - } - }, - "node_modules/@edx/frontend-lib-content-components/node_modules/@dnd-kit/sortable": { - "version": "7.0.2", - "license": "MIT", - "dependencies": { - "@dnd-kit/utilities": "^3.2.0", - "tslib": "^2.0.0" - }, - "peerDependencies": { - "@dnd-kit/core": "^6.0.7", - "react": ">=16.8.0" - } - }, - "node_modules/@edx/frontend-lib-content-components/node_modules/react-responsive": { - "version": "8.2.0", - "license": "MIT", - "dependencies": { - "hyphenate-style-name": "^1.0.0", - "matchmediaquery": "^0.3.0", - "prop-types": "^15.6.1", - "shallow-equal": "^1.1.0" - }, - "engines": { - "node": ">= 0.10" - }, - "peerDependencies": { - "react": ">=16.8.0" - } - }, - "node_modules/@edx/frontend-lib-content-components/node_modules/react-transition-group": { - "version": "4.4.2", - "license": "BSD-3-Clause", - "dependencies": { - "@babel/runtime": "^7.5.5", - "dom-helpers": "^5.0.1", - "loose-envify": "^1.4.0", - "prop-types": "^15.6.2" - }, - "peerDependencies": { - "react": ">=16.6.0", - "react-dom": ">=16.6.0" - } - }, - "node_modules/@edx/frontend-lib-content-components/node_modules/redux": { - "version": "4.1.2", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.9.2" - } - }, "node_modules/@edx/frontend-platform": { "version": "8.1.1", "license": "AGPL-3.0", @@ -2434,7 +2356,7 @@ } }, "node_modules/@edx/react-unit-test-utils": { - "version": "2.1.1", + "version": "3.0.0", "dev": true, "license": "AGPL-3.0", "dependencies": { @@ -4314,6 +4236,17 @@ "url": "https://opencollective.com/popperjs" } }, + "node_modules/@redux-devtools/extension": { + "version": "3.3.0", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2", + "immutable": "^4.3.4" + }, + "peerDependencies": { + "redux": "^3.1.0 || ^4.0.0 || ^5.0.0" + } + }, "node_modules/@reduxjs/toolkit": { "version": "1.9.7", "license": "MIT", @@ -5061,6 +4994,11 @@ "version": "0.0.29", "license": "MIT" }, + "node_modules/@types/lodash": { + "version": "4.17.7", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mime": { "version": "1.3.5", "license": "MIT" @@ -5445,49 +5383,6 @@ "license": "ISC", "peer": true }, - "node_modules/@videojs/http-streaming": { - "version": "2.16.3", - "license": "Apache-2.0", - "dependencies": { - "@babel/runtime": "^7.12.5", - "@videojs/vhs-utils": "3.0.5", - "aes-decrypter": "3.1.3", - "global": "^4.4.0", - "m3u8-parser": "4.8.0", - "mpd-parser": "^0.22.1", - "mux.js": "6.0.1", - "video.js": "^6 || ^7" - }, - "engines": { - "node": ">=8", - "npm": ">=5" - }, - "peerDependencies": { - "video.js": "^6 || ^7" - } - }, - "node_modules/@videojs/vhs-utils": { - "version": "3.0.5", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.12.5", - "global": "^4.4.0", - "url-toolkit": "^2.2.1" - }, - "engines": { - "node": ">=8", - "npm": ">=5" - } - }, - "node_modules/@videojs/xhr": { - "version": "2.6.0", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.5.5", - "global": "~4.4.0", - "is-function": "^1.0.1" - } - }, "node_modules/@webassemblyjs/ast": { "version": "1.12.1", "license": "MIT", @@ -5642,13 +5537,6 @@ } } }, - "node_modules/@xmldom/xmldom": { - "version": "0.8.10", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - } - }, "node_modules/@xtuc/ieee754": { "version": "1.2.0", "license": "BSD-3-Clause" @@ -5732,16 +5620,6 @@ "node": ">=8.9" } }, - "node_modules/aes-decrypter": { - "version": "3.1.3", - "license": "Apache-2.0", - "dependencies": { - "@babel/runtime": "^7.12.5", - "@videojs/vhs-utils": "^3.0.5", - "global": "^4.4.0", - "pkcs7": "^1.0.4" - } - }, "node_modules/agent-base": { "version": "6.0.2", "license": "MIT", @@ -8035,9 +7913,6 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/dom-walk": { - "version": "0.1.2" - }, "node_modules/domelementtype": { "version": "2.3.0", "funding": [ @@ -10025,14 +9900,6 @@ "version": "0.4.1", "license": "BSD-2-Clause" }, - "node_modules/global": { - "version": "4.4.0", - "license": "MIT", - "dependencies": { - "min-document": "^2.19.0", - "process": "^0.11.10" - } - }, "node_modules/global-modules": { "version": "2.0.0", "license": "MIT", @@ -10734,9 +10601,6 @@ "node": ">=8" } }, - "node_modules/individual": { - "version": "2.0.0" - }, "node_modules/inflight": { "version": "1.0.6", "license": "ISC", @@ -11000,10 +10864,6 @@ "node": ">=8" } }, - "node_modules/is-function": { - "version": "1.0.2", - "license": "MIT" - }, "node_modules/is-generator-fn": { "version": "2.1.0", "license": "MIT", @@ -12446,10 +12306,6 @@ "version": "3.1.2", "license": "MIT" }, - "node_modules/keycode": { - "version": "2.2.1", - "license": "MIT" - }, "node_modules/keyv": { "version": "4.5.4", "license": "MIT", @@ -12599,10 +12455,6 @@ "version": "4.0.8", "license": "MIT" }, - "node_modules/lodash.flatten": { - "version": "4.4.0", - "license": "MIT" - }, "node_modules/lodash.isequal": { "version": "4.5.0", "dev": true, @@ -12629,10 +12481,6 @@ "dev": true, "license": "MIT" }, - "node_modules/lodash.throttle": { - "version": "4.1.1", - "license": "MIT" - }, "node_modules/lodash.truncate": { "version": "4.4.2", "dev": true, @@ -12692,15 +12540,6 @@ "lz-string": "bin/bin.js" } }, - "node_modules/m3u8-parser": { - "version": "4.8.0", - "license": "Apache-2.0", - "dependencies": { - "@babel/runtime": "^7.12.5", - "@videojs/vhs-utils": "^3.0.5", - "global": "^4.4.0" - } - }, "node_modules/magic-string": { "version": "0.30.11", "license": "MIT", @@ -12967,12 +12806,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/min-document": { - "version": "2.19.0", - "dependencies": { - "dom-walk": "^0.1.0" - } - }, "node_modules/min-indent": { "version": "1.0.1", "dev": true, @@ -13079,19 +12912,6 @@ "color-name": "^1.1.4" } }, - "node_modules/mpd-parser": { - "version": "0.22.1", - "license": "Apache-2.0", - "dependencies": { - "@babel/runtime": "^7.12.5", - "@videojs/vhs-utils": "^3.0.5", - "@xmldom/xmldom": "^0.8.3", - "global": "^4.4.0" - }, - "bin": { - "mpd-to-m3u8-json": "bin/parse.js" - } - }, "node_modules/mrmime": { "version": "2.0.0", "license": "MIT", @@ -13118,21 +12938,6 @@ "version": "0.0.8", "license": "ISC" }, - "node_modules/mux.js": { - "version": "6.0.1", - "license": "Apache-2.0", - "dependencies": { - "@babel/runtime": "^7.11.2", - "global": "^4.4.0" - }, - "bin": { - "muxjs-transmux": "bin/transmux.js" - }, - "engines": { - "node": ">=8", - "npm": ">=5" - } - }, "node_modules/nanoid": { "version": "3.3.7", "funding": [ @@ -16121,16 +15926,6 @@ "node": ">= 6" } }, - "node_modules/pkcs7": { - "version": "1.0.4", - "license": "Apache-2.0", - "dependencies": { - "@babel/runtime": "^7.5.5" - }, - "bin": { - "pkcs7": "bin/cli.js" - } - }, "node_modules/pkg-dir": { "version": "7.0.0", "license": "MIT", @@ -16959,13 +16754,6 @@ "dev": true, "license": "MIT" }, - "node_modules/process": { - "version": "0.11.10", - "license": "MIT", - "engines": { - "node": ">= 0.6.0" - } - }, "node_modules/process-nextick-args": { "version": "2.0.1", "license": "MIT" @@ -18034,13 +17822,6 @@ "symbol-observable": "^1.2.0" } }, - "node_modules/redux-devtools-extension": { - "version": "2.13.9", - "license": "MIT", - "peerDependencies": { - "redux": "^3.1.0 || ^4.0.0" - } - }, "node_modules/redux-logger": { "version": "3.0.6", "license": "MIT", @@ -18326,13 +18107,6 @@ "queue-microtask": "^1.2.2" } }, - "node_modules/rust-result": { - "version": "1.0.0", - "license": "MIT", - "dependencies": { - "individual": "^2.0.0" - } - }, "node_modules/rxjs": { "version": "7.8.1", "license": "Apache-2.0", @@ -18374,12 +18148,6 @@ ], "license": "MIT" }, - "node_modules/safe-json-parse": { - "version": "4.0.0", - "dependencies": { - "rust-result": "^1.0.0" - } - }, "node_modules/safe-regex-test": { "version": "1.0.3", "license": "MIT", @@ -20393,10 +20161,6 @@ "requires-port": "^1.0.0" } }, - "node_modules/url-toolkit": { - "version": "2.2.5", - "license": "Apache-2.0" - }, "node_modules/use-callback-ref": { "version": "1.3.2", "license": "MIT", @@ -20531,51 +20295,6 @@ "node": ">= 0.8" } }, - "node_modules/video-react": { - "version": "0.15.0", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.4.5", - "classnames": "^2.2.6", - "lodash.throttle": "^4.1.1", - "prop-types": "^15.7.2", - "redux": "^4.0.1" - }, - "peerDependencies": { - "react": "^15.0.0 || ^16.0.0 || ^17.0.0", - "react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0" - } - }, - "node_modules/video.js": { - "version": "7.21.6", - "license": "Apache-2.0", - "dependencies": { - "@babel/runtime": "^7.12.5", - "@videojs/http-streaming": "2.16.3", - "@videojs/vhs-utils": "^3.0.4", - "@videojs/xhr": "2.6.0", - "aes-decrypter": "3.1.3", - "global": "^4.4.0", - "keycode": "^2.2.0", - "m3u8-parser": "4.8.0", - "mpd-parser": "0.22.1", - "mux.js": "6.0.1", - "safe-json-parse": "4.0.0", - "videojs-font": "3.2.0", - "videojs-vtt.js": "^0.15.5" - } - }, - "node_modules/videojs-font": { - "version": "3.2.0", - "license": "Apache-2.0" - }, - "node_modules/videojs-vtt.js": { - "version": "0.15.5", - "license": "Apache-2.0", - "dependencies": { - "global": "^4.3.1" - } - }, "node_modules/w3c-keyname": { "version": "2.2.8", "license": "MIT" @@ -21277,7 +20996,6 @@ "version": "0.1.0", "peerDependencies": { "@edx/frontend-app-course-authoring": "*", - "@edx/frontend-lib-content-components": "*", "@edx/frontend-platform": "*", "@openedx/paragon": "*", "@reduxjs/toolkit": "*", diff --git a/package.json b/package.json index f3daa13742..7c59b263c6 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,11 @@ "url": "https://github.com/openedx/frontend-app-course-authoring/issues" }, "dependencies": { + "@codemirror/lang-html": "^6.0.0", + "@codemirror/lang-xml": "^6.0.0", + "@codemirror/lint": "^6.2.1", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", "@dnd-kit/core": "^6.1.0", "@dnd-kit/modifiers": "^7.0.0", "@dnd-kit/sortable": "^8.0.0", @@ -45,7 +50,6 @@ "@edx/frontend-component-footer": "^14.0.3", "@edx/frontend-component-header": "^5.3.3", "@edx/frontend-enterprise-hotjar": "^2.0.0", - "@edx/frontend-lib-content-components": "^2.6.0", "@edx/frontend-platform": "^8.0.3", "@edx/openedx-atlas": "^0.6.0", "@openedx-plugins/course-app-calculator": "file:plugins/course-apps/calculator", @@ -60,16 +64,22 @@ "@openedx-plugins/course-app-xpert_unit_summary": "file:plugins/course-apps/xpert_unit_summary", "@openedx/frontend-plugin-framework": "^1.2.1", "@openedx/paragon": "^22.5.1", + "@redux-devtools/extension": "^3.3.0", "@reduxjs/toolkit": "1.9.7", "@tanstack/react-query": "4.36.1", + "@tinymce/tinymce-react": "^3.14.0", "classnames": "2.5.1", + "codemirror": "^6.0.0", "email-validator": "2.0.4", + "fast-xml-parser": "^4.0.10", "file-saver": "^2.0.5", "formik": "2.4.6", + "frontend-components-tinymce-advanced-plugins": "^1.0.3", "jszip": "^3.10.1", "lodash": "4.17.21", "meilisearch": "^0.41.0", "moment": "2.30.1", + "moment-shortformat": "^2.1.0", "npm": "^10.8.1", "prop-types": "^15.8.1", "react": "17.0.2", @@ -77,6 +87,7 @@ "react-dom": "17.0.2", "react-error-boundary": "^4.0.13", "react-helmet": "^6.1.0", + "react-onclickoutside": "^6.13.0", "react-redux": "7.2.9", "react-responsive": "9.0.2", "react-router": "6.23.1", @@ -85,14 +96,20 @@ "react-textarea-autosize": "^8.5.3", "react-transition-group": "4.4.5", "redux": "4.0.5", + "redux-logger": "^3.0.6", + "redux-mock-store": "^1.5.4", + "redux-thunk": "^2.4.1", + "reselect": "^4.1.5", "start": "^5.1.0", + "tinymce": "^5.10.4", "universal-cookie": "^4.0.4", "uuid": "^3.4.0", + "xmlchecker": "^0.1.0", "yup": "0.31.1" }, "devDependencies": { "@edx/browserslist-config": "1.2.0", - "@edx/react-unit-test-utils": "2.1.1", + "@edx/react-unit-test-utils": "3.0.0", "@edx/reactifex": "^1.0.3", "@edx/stylelint-config-edx": "2.3.3", "@edx/typescript-config": "^1.0.1", @@ -101,6 +118,7 @@ "@testing-library/react": "12.1.5", "@testing-library/react-hooks": "^8.0.1", "@testing-library/user-event": "^13.2.1", + "@types/lodash": "^4.17.7", "axios": "^0.28.0", "axios-mock-adapter": "1.22.0", "eslint-import-resolver-webpack": "^0.13.8", diff --git a/plugins/course-apps/live/Settings.jsx b/plugins/course-apps/live/Settings.jsx index a8f09257b5..ed409d997b 100644 --- a/plugins/course-apps/live/Settings.jsx +++ b/plugins/course-apps/live/Settings.jsx @@ -3,10 +3,10 @@ import { useDispatch, useSelector } from 'react-redux'; import { camelCase } from 'lodash'; import { Icon } from '@openedx/paragon'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; -import { SelectableBox } from '@edx/frontend-lib-content-components'; import PropTypes from 'prop-types'; import * as Yup from 'yup'; import { useNavigate } from 'react-router-dom'; +import SelectableBox from 'CourseAuthoring/editors/sharedComponents/SelectableBox'; import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal'; import { useModel } from 'CourseAuthoring/generic/model-store'; import Loading from 'CourseAuthoring/generic/Loading'; diff --git a/plugins/course-apps/live/package.json b/plugins/course-apps/live/package.json index 50f38b725d..6fbd074feb 100644 --- a/plugins/course-apps/live/package.json +++ b/plugins/course-apps/live/package.json @@ -4,7 +4,6 @@ "description": "Live course configuration for courses using it", "peerDependencies": { "@edx/frontend-app-course-authoring": "*", - "@edx/frontend-lib-content-components": "*", "@edx/frontend-platform": "*", "@openedx/paragon": "*", "@reduxjs/toolkit": "*", diff --git a/renovate.json b/renovate.json index dae6f07d08..29e7a5d344 100644 --- a/renovate.json +++ b/renovate.json @@ -19,15 +19,6 @@ "matchPackagePatterns": ["@edx", "@openedx"], "matchUpdateTypes": ["minor", "patch"], "automerge": false - }, - { - "matchPackagePatterns": ["@edx/frontend-lib-content-components"], - "matchUpdateTypes": ["minor", "patch"], - "automerge": false, - "schedule": [ - "after 1am", - "before 11pm" - ] } ] } diff --git a/src/CourseAuthoringRoutes.test.jsx b/src/CourseAuthoringRoutes.test.jsx index 3a38fe7c24..b72e340c16 100644 --- a/src/CourseAuthoringRoutes.test.jsx +++ b/src/CourseAuthoringRoutes.test.jsx @@ -21,9 +21,10 @@ jest.mock('react-router-dom', () => ({ }), })); -// Mock the TinyMceWidget from frontend-lib-content-components -jest.mock('@edx/frontend-lib-content-components', () => ({ - TinyMceWidget: () =>
Widget
, +// Mock the TinyMceWidget +jest.mock('./editors/sharedComponents/TinyMceWidget', () => ({ + __esModule: true, // Required to mock a default export + default: () =>
Widget
, Footer: () =>
Footer
, prepareEditorRef: jest.fn(() => ({ refReady: true, diff --git a/src/advanced-settings/AdvancedSettings.jsx b/src/advanced-settings/AdvancedSettings.jsx index 565bec3b7c..67bd62b55f 100644 --- a/src/advanced-settings/AdvancedSettings.jsx +++ b/src/advanced-settings/AdvancedSettings.jsx @@ -6,7 +6,7 @@ import { } from '@openedx/paragon'; import { CheckCircle, Info, Warning } from '@openedx/paragon/icons'; import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n'; -import Placeholder from '@edx/frontend-lib-content-components'; +import Placeholder from '../editors/Placeholder'; import AlertProctoringError from '../generic/AlertProctoringError'; import { useModel } from '../generic/model-store'; diff --git a/src/certificates/Certificates.jsx b/src/certificates/Certificates.jsx index fd67f74542..ef199f0992 100644 --- a/src/certificates/Certificates.jsx +++ b/src/certificates/Certificates.jsx @@ -1,7 +1,7 @@ import { Helmet } from 'react-helmet'; import PropTypes from 'prop-types'; -import Placeholder from '@edx/frontend-lib-content-components'; +import Placeholder from '../editors/Placeholder'; import { RequestStatus } from '../data/constants'; import Loading from '../generic/Loading'; import useCertificates from './hooks/useCertificates'; diff --git a/src/content-tags-drawer/ContentTagsCollapsible.jsx b/src/content-tags-drawer/ContentTagsCollapsible.jsx index 66bd7e100a..345fda8e27 100644 --- a/src/content-tags-drawer/ContentTagsCollapsible.jsx +++ b/src/content-tags-drawer/ContentTagsCollapsible.jsx @@ -12,9 +12,10 @@ import { Icon, } from '@openedx/paragon'; import { Tag, KeyboardArrowDown, KeyboardArrowUp } from '@openedx/paragon/icons'; -import { SelectableBox } from '@edx/frontend-lib-content-components'; import { useIntl } from '@edx/frontend-platform/i18n'; import { debounce } from 'lodash'; + +import SelectableBox from '../editors/sharedComponents/SelectableBox'; import messages from './messages'; import ContentTagsDropDownSelector from './ContentTagsDropDownSelector'; diff --git a/src/content-tags-drawer/ContentTagsDropDownSelector.jsx b/src/content-tags-drawer/ContentTagsDropDownSelector.jsx index 394ab6b56e..cce94ca3dc 100644 --- a/src/content-tags-drawer/ContentTagsDropDownSelector.jsx +++ b/src/content-tags-drawer/ContentTagsDropDownSelector.jsx @@ -5,13 +5,13 @@ import { Spinner, Button, } from '@openedx/paragon'; -import { SelectableBox } from '@edx/frontend-lib-content-components'; import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n'; import { ArrowDropDown, ArrowDropUp, Add } from '@openedx/paragon/icons'; import PropTypes from 'prop-types'; -import messages from './messages'; +import SelectableBox from '../editors/sharedComponents/SelectableBox'; import { useTaxonomyTagsData } from './data/apiHooks'; +import messages from './messages'; const HighlightedText = ({ text, highlight }) => { if (!highlight) { diff --git a/src/course-outline/page-alerts/PageAlerts.jsx b/src/course-outline/page-alerts/PageAlerts.jsx index be7e981350..172e407749 100644 --- a/src/course-outline/page-alerts/PageAlerts.jsx +++ b/src/course-outline/page-alerts/PageAlerts.jsx @@ -4,7 +4,6 @@ import { uniqBy } from 'lodash'; import { getConfig } from '@edx/frontend-platform'; import { useDispatch, useSelector } from 'react-redux'; import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; -import { ErrorAlert } from '@edx/frontend-lib-content-components'; import { Campaign as CampaignIcon, InfoOutline as InfoOutlineIcon, @@ -16,6 +15,7 @@ import { } from '@openedx/paragon'; import { Link } from 'react-router-dom'; +import ErrorAlert from '../../editors/sharedComponents/ErrorAlerts/ErrorAlert'; import { RequestStatus } from '../../data/constants'; import AlertMessage from '../../generic/alert-message'; import AlertProctoringError from '../../generic/AlertProctoringError'; diff --git a/src/course-unit/CourseUnit.jsx b/src/course-unit/CourseUnit.jsx index 6857626d1c..2b54f876e6 100644 --- a/src/course-unit/CourseUnit.jsx +++ b/src/course-unit/CourseUnit.jsx @@ -7,8 +7,8 @@ import { getConfig } from '@edx/frontend-platform'; import { useIntl, injectIntl } from '@edx/frontend-platform/i18n'; import { Warning as WarningIcon } from '@openedx/paragon/icons'; import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; -import { DraggableList } from '@edx/frontend-lib-content-components'; +import DraggableList from '../editors/sharedComponents/DraggableList'; import { getProcessingNotification } from '../generic/processing-notification/data/selectors'; import SubHeader from '../generic/sub-header/SubHeader'; import { RequestStatus } from '../data/constants'; diff --git a/src/course-updates/CourseUpdates.test.jsx b/src/course-updates/CourseUpdates.test.jsx index 8fdc223309..387d3b3c26 100644 --- a/src/course-updates/CourseUpdates.test.jsx +++ b/src/course-updates/CourseUpdates.test.jsx @@ -44,8 +44,9 @@ jest.mock('@tinymce/tinymce-react', () => { }; }); -jest.mock('@edx/frontend-lib-content-components', () => ({ - TinyMceWidget: () =>
Widget
, +jest.mock('../editors/sharedComponents/TinyMceWidget', () => ({ + __esModule: true, // Required to mock a default export + default: () =>
Widget
, prepareEditorRef: jest.fn(() => ({ refReady: true, setEditorRef: jest.fn().mockName('prepareEditorRef.setEditorRef'), diff --git a/src/course-updates/update-form/UpdateForm.test.jsx b/src/course-updates/update-form/UpdateForm.test.jsx index 65eb81a4ff..b48e07374d 100644 --- a/src/course-updates/update-form/UpdateForm.test.jsx +++ b/src/course-updates/update-form/UpdateForm.test.jsx @@ -32,8 +32,9 @@ jest.mock('@tinymce/tinymce-react', () => { }; }); -jest.mock('@edx/frontend-lib-content-components', () => ({ - TinyMceWidget: () =>
Widget
, +jest.mock('../../editors/sharedComponents/TinyMceWidget', () => ({ + __esModule: true, // Required to mock a default export + default: () =>
Widget
, prepareEditorRef: jest.fn(() => ({ refReady: true, setEditorRef: jest.fn().mockName('prepareEditorRef.setEditorRef'), diff --git a/src/custom-pages/CustomPages.jsx b/src/custom-pages/CustomPages.jsx index 5d16dbf713..f5392abf8b 100644 --- a/src/custom-pages/CustomPages.jsx +++ b/src/custom-pages/CustomPages.jsx @@ -18,11 +18,9 @@ import { Container, } from '@openedx/paragon'; import { Add, SpinnerSimple } from '@openedx/paragon/icons'; -import Placeholder, { - DraggableList, - SortableItem, - ErrorAlert, -} from '@edx/frontend-lib-content-components'; +import Placeholder from '../editors/Placeholder'; +import DraggableList, { SortableItem } from '../editors/sharedComponents/DraggableList'; +import ErrorAlert from '../editors/sharedComponents/ErrorAlerts/ErrorAlert'; import { RequestStatus } from '../data/constants'; import { useModels, useModel } from '../generic/model-store'; diff --git a/src/custom-pages/EditModal.jsx b/src/custom-pages/EditModal.jsx index 0f540c74ec..e89b39b827 100644 --- a/src/custom-pages/EditModal.jsx +++ b/src/custom-pages/EditModal.jsx @@ -2,7 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { getConfig } from '@edx/frontend-platform'; -import { EditorPage } from '@edx/frontend-lib-content-components'; +import EditorPage from '../editors/EditorPage'; const EditModal = ({ pageId, diff --git a/src/editors/Editor.jsx b/src/editors/Editor.jsx new file mode 100644 index 0000000000..044c773f26 --- /dev/null +++ b/src/editors/Editor.jsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { useDispatch } from 'react-redux'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from '@edx/frontend-platform/i18n'; + +import messages from './messages'; +import * as hooks from './hooks'; + +import supportedEditors from './supportedEditors'; + +const Editor = ({ + learningContextId, + blockType, + blockId, + lmsEndpointUrl, + studioEndpointUrl, + onClose, + returnFunction, +}) => { + const dispatch = useDispatch(); + hooks.initializeApp({ + dispatch, + data: { + blockId, + blockType, + learningContextId, + lmsEndpointUrl, + studioEndpointUrl, + }, + }); + + const EditorComponent = supportedEditors[blockType]; + return ( +
+
+ {(EditorComponent !== undefined) + ? + : } +
+
+ ); +}; +Editor.defaultProps = { + blockId: null, + learningContextId: null, + lmsEndpointUrl: null, + onClose: null, + returnFunction: null, + studioEndpointUrl: null, +}; + +Editor.propTypes = { + blockId: PropTypes.string, + blockType: PropTypes.string.isRequired, + learningContextId: PropTypes.string, + lmsEndpointUrl: PropTypes.string, + onClose: PropTypes.func, + returnFunction: PropTypes.func, + studioEndpointUrl: PropTypes.string, +}; + +export default Editor; diff --git a/src/editors/Editor.test.jsx b/src/editors/Editor.test.jsx new file mode 100644 index 0000000000..fa19e60689 --- /dev/null +++ b/src/editors/Editor.test.jsx @@ -0,0 +1,55 @@ +import 'CourseAuthoring/editors/setupEditorTest'; +import React from 'react'; +import { useDispatch } from 'react-redux'; +import { shallow } from '@edx/react-unit-test-utils'; +import Editor from './Editor'; +import supportedEditors from './supportedEditors'; +import * as hooks from './hooks'; +import { blockTypes } from './data/constants/app'; + +jest.mock('./hooks', () => ({ + initializeApp: jest.fn(), +})); + +jest.mock('./containers/TextEditor', () => 'TextEditor'); +jest.mock('./containers/VideoEditor', () => 'VideoEditor'); +jest.mock('./containers/ProblemEditor', () => 'ProblemEditor'); + +const initData = { + blockId: 'block-v1:edX+DemoX+Demo_Course+type@html+block@030e35c4756a4ddc8d40b95fbbfff4d4', + blockType: blockTypes.html, + learningContextId: 'course-v1:edX+DemoX+Demo_Course', + lmsEndpointUrl: 'evenfakerurl.com', + studioEndpointUrl: 'fakeurl.com', +}; +const props = { + initialize: jest.fn(), + onClose: jest.fn().mockName('props.onClose'), + courseId: 'course-v1:edX+DemoX+Demo_Course', + ...initData, +}; + +let el; +describe('Editor', () => { + describe('render', () => { + test('snapshot: renders correct editor given blockType (html -> TextEditor)', () => { + expect(shallow().snapshot).toMatchSnapshot(); + }); + test('presents error message if no relevant editor found and ref ready', () => { + expect(shallow().snapshot).toMatchSnapshot(); + }); + test.each(Object.values(blockTypes))('renders %p editor when ref is ready', (blockType) => { + el = shallow(); + expect(el.shallowWrapper.props.children.props.children.type).toBe(supportedEditors[blockType]); + }); + }); + describe('behavior', () => { + it('calls initializeApp hook with dispatch, and passed data', () => { + el = shallow(); + expect(hooks.initializeApp).toHaveBeenCalledWith({ + dispatch: useDispatch(), + data: initData, + }); + }); + }); +}); diff --git a/src/editors/EditorContainer.jsx b/src/editors/EditorContainer.jsx index c9e821daf7..34ca5ba631 100644 --- a/src/editors/EditorContainer.jsx +++ b/src/editors/EditorContainer.jsx @@ -1,9 +1,10 @@ import React from 'react'; import PropTypes from 'prop-types'; import { useParams } from 'react-router-dom'; -import { EditorPage } from '@edx/frontend-lib-content-components'; import { getConfig } from '@edx/frontend-platform'; +import EditorPage from './EditorPage'; + const EditorContainer = ({ courseId, }) => { diff --git a/src/editors/EditorContainer.test.jsx b/src/editors/EditorContainer.test.jsx index bee812eff5..a6186050ae 100644 --- a/src/editors/EditorContainer.test.jsx +++ b/src/editors/EditorContainer.test.jsx @@ -2,8 +2,6 @@ import React from 'react'; import { shallow } from '@edx/react-unit-test-utils'; import EditorContainer from './EditorContainer'; -jest.mock('@edx/frontend-lib-content-components', () => ({ EditorPage: () => 'HeaderTitle' })); - jest.mock('react-router', () => ({ ...jest.requireActual('react-router'), // use actual for all non-hook parts useParams: () => ({ diff --git a/src/editors/EditorPage.jsx b/src/editors/EditorPage.jsx new file mode 100644 index 0000000000..60de6e1cc6 --- /dev/null +++ b/src/editors/EditorPage.jsx @@ -0,0 +1,58 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Provider } from 'react-redux'; + +import store from './data/store'; +import Editor from './Editor'; +import ErrorBoundary from './sharedComponents/ErrorBoundary'; + +const EditorPage = ({ + courseId, + blockType, + blockId, + lmsEndpointUrl, + studioEndpointUrl, + onClose, + returnFunction, +}) => ( + + + + + +); +EditorPage.defaultProps = { + blockId: null, + courseId: null, + lmsEndpointUrl: null, + onClose: null, + returnFunction: null, + studioEndpointUrl: null, +}; + +EditorPage.propTypes = { + blockId: PropTypes.string, + blockType: PropTypes.string.isRequired, + courseId: PropTypes.string, + lmsEndpointUrl: PropTypes.string, + onClose: PropTypes.func, + returnFunction: PropTypes.func, + studioEndpointUrl: PropTypes.string, +}; + +export default EditorPage; diff --git a/src/editors/EditorPage.test.jsx b/src/editors/EditorPage.test.jsx new file mode 100644 index 0000000000..24dfffe293 --- /dev/null +++ b/src/editors/EditorPage.test.jsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { shallow } from '@edx/react-unit-test-utils'; +import EditorPage from './EditorPage'; + +const props = { + courseId: 'course-v1:edX+DemoX+Demo_Course', + blockType: 'html', + blockId: 'block-v1:edX+DemoX+Demo_Course+type@html+block@030e35c4756a4ddc8d40b95fbbfff4d4', + lmsEndpointUrl: 'evenfakerurl.com', + studioEndpointUrl: 'fakeurl.com', + onClose: jest.fn().mockName('props.onClose'), +}; +jest.mock('react-redux', () => ({ + Provider: 'Provider', + connect: (mapStateToProps, mapDispatchToProps) => (component) => ({ + mapStateToProps, + mapDispatchToProps, + component, + }), +})); +jest.mock('./Editor', () => 'Editor'); + +describe('Editor Page', () => { + describe('snapshots', () => { + test('rendering correctly with expected Input', () => { + expect(shallow().snapshot).toMatchSnapshot(); + }); + test('props besides blockType default to null', () => { + expect(shallow().snapshot).toMatchSnapshot(); + }); + }); +}); diff --git a/src/editors/Placeholder.jsx b/src/editors/Placeholder.jsx new file mode 100644 index 0000000000..33cebdb51c --- /dev/null +++ b/src/editors/Placeholder.jsx @@ -0,0 +1,13 @@ +import React from 'react'; + +const Placeholder = () => ( +
+

+ Under Construction +
+ Coming Soon +

+
+); + +export default Placeholder; diff --git a/src/editors/Placeholder.test.jsx b/src/editors/Placeholder.test.jsx new file mode 100644 index 0000000000..186bc9d781 --- /dev/null +++ b/src/editors/Placeholder.test.jsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import TestRenderer from 'react-test-renderer'; +import { AppContext } from '@edx/frontend-platform/react'; +import { Context as ResponsiveContext } from 'react-responsive'; + +import Placeholder from './Placeholder'; + +describe('', () => { + it('renders correctly', () => { + const component = ( + + + + + + + + ); + + const wrapper = TestRenderer.create(component); + + expect(wrapper.toJSON()).toMatchSnapshot(); + }); +}); diff --git a/src/editors/VideoSelector.jsx b/src/editors/VideoSelector.jsx new file mode 100644 index 0000000000..4711b76cfc --- /dev/null +++ b/src/editors/VideoSelector.jsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { useDispatch } from 'react-redux'; +import PropTypes from 'prop-types'; +import VideoGallery from './containers/VideoGallery'; +import * as hooks from './hooks'; + +const VideoSelector = ({ + blockId, + learningContextId, + lmsEndpointUrl, + studioEndpointUrl, +}) => { + const dispatch = useDispatch(); + hooks.initializeApp({ + dispatch, + data: { + blockId, + blockType: 'video', + learningContextId, + lmsEndpointUrl, + studioEndpointUrl, + }, + }); + return ( + + ); +}; + +VideoSelector.propTypes = { + blockId: PropTypes.string.isRequired, + learningContextId: PropTypes.string.isRequired, + lmsEndpointUrl: PropTypes.string.isRequired, + studioEndpointUrl: PropTypes.string.isRequired, +}; + +export default VideoSelector; diff --git a/src/editors/VideoSelector.test.jsx b/src/editors/VideoSelector.test.jsx new file mode 100644 index 0000000000..f3d0e60ae3 --- /dev/null +++ b/src/editors/VideoSelector.test.jsx @@ -0,0 +1,41 @@ +import 'CourseAuthoring/editors/setupEditorTest'; +import React from 'react'; +import { useDispatch } from 'react-redux'; +import { shallow } from '@edx/react-unit-test-utils'; +import * as hooks from './hooks'; +import VideoSelector from './VideoSelector'; + +jest.mock('./hooks', () => ({ + initializeApp: jest.fn(), +})); + +jest.mock('./containers/VideoGallery', () => 'VideoGallery'); + +const props = { + blockId: 'block-v1:edX+DemoX+Demo_Course+type@html+block@030e35c4756a4ddc8d40b95fbbfff4d4', + learningContextId: 'course-v1:edX+DemoX+Demo_Course', + lmsEndpointUrl: 'evenfakerurl.com', + studioEndpointUrl: 'fakeurl.com', +}; + +const initData = { + blockType: 'video', + ...props, +}; + +describe('Video Selector', () => { + describe('render', () => { + test('rendering correctly with expected Input', () => { + expect(shallow().snapshot).toMatchSnapshot(); + }); + }); + describe('behavior', () => { + it('calls initializeApp hook with dispatch, and passed data', () => { + shallow(); + expect(hooks.initializeApp).toHaveBeenCalledWith({ + dispatch: useDispatch(), + data: initData, + }); + }); + }); +}); diff --git a/src/editors/VideoSelectorPage.jsx b/src/editors/VideoSelectorPage.jsx new file mode 100644 index 0000000000..0d9609b045 --- /dev/null +++ b/src/editors/VideoSelectorPage.jsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { Provider } from 'react-redux'; +import PropTypes from 'prop-types'; +import ErrorBoundary from './sharedComponents/ErrorBoundary'; +import VideoSelector from './VideoSelector'; +import store from './data/store'; + +const VideoSelectorPage = ({ + blockId, + courseId, + lmsEndpointUrl, + studioEndpointUrl, +}) => ( + + + + + +); + +VideoSelectorPage.defaultProps = { + blockId: null, + courseId: null, + lmsEndpointUrl: null, + studioEndpointUrl: null, +}; + +VideoSelectorPage.propTypes = { + blockId: PropTypes.string, + courseId: PropTypes.string, + lmsEndpointUrl: PropTypes.string, + studioEndpointUrl: PropTypes.string, +}; + +export default VideoSelectorPage; diff --git a/src/editors/VideoSelectorPage.test.jsx b/src/editors/VideoSelectorPage.test.jsx new file mode 100644 index 0000000000..5f55f7c337 --- /dev/null +++ b/src/editors/VideoSelectorPage.test.jsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { shallow } from '@edx/react-unit-test-utils'; +import VideoSelectorPage from './VideoSelectorPage'; + +const props = { + blockId: 'block-v1:edX+DemoX+Demo_Course+type@html+block@030e35c4756a4ddc8d40b95fbbfff4d4', + courseId: 'course-v1:edX+DemoX+Demo_Course', + lmsEndpointUrl: 'evenfakerurl.com', + studioEndpointUrl: 'fakeurl.com', +}; + +jest.mock('react-redux', () => ({ + Provider: 'Provider', + connect: (mapStateToProps, mapDispatchToProps) => (component) => ({ + mapStateToProps, + mapDispatchToProps, + component, + }), +})); +jest.mock('./VideoSelector', () => 'VideoSelector'); + +describe('Video Selector Page', () => { + describe('snapshots', () => { + test('rendering correctly with expected Input', () => { + expect(shallow().snapshot).toMatchSnapshot(); + }); + test('rendering with props to null', () => { + expect(shallow().snapshot).toMatchSnapshot(); + }); + }); +}); diff --git a/src/editors/__snapshots__/Editor.test.jsx.snap b/src/editors/__snapshots__/Editor.test.jsx.snap new file mode 100644 index 0000000000..5c9cb23a39 --- /dev/null +++ b/src/editors/__snapshots__/Editor.test.jsx.snap @@ -0,0 +1,36 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Editor render presents error message if no relevant editor found and ref ready 1`] = ` +
+
+ +
+
+`; + +exports[`Editor render snapshot: renders correct editor given blockType (html -> TextEditor) 1`] = ` +
+
+ +
+
+`; diff --git a/src/editors/__snapshots__/EditorContainer.test.jsx.snap b/src/editors/__snapshots__/EditorContainer.test.jsx.snap index e6c223bef8..c742c7a606 100644 --- a/src/editors/__snapshots__/EditorContainer.test.jsx.snap +++ b/src/editors/__snapshots__/EditorContainer.test.jsx.snap @@ -9,6 +9,8 @@ exports[`Editor Container snapshots rendering correctly with expected Input 1`] blockType="html" courseId="cOuRsEId" lmsEndpointUrl="http://localhost:18000" + onClose={null} + returnFunction={null} studioEndpointUrl="http://localhost:18010" /> diff --git a/src/editors/__snapshots__/EditorPage.test.jsx.snap b/src/editors/__snapshots__/EditorPage.test.jsx.snap new file mode 100644 index 0000000000..7e15005764 --- /dev/null +++ b/src/editors/__snapshots__/EditorPage.test.jsx.snap @@ -0,0 +1,59 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Editor Page snapshots props besides blockType default to null 1`] = ` + + + + + +`; + +exports[`Editor Page snapshots rendering correctly with expected Input 1`] = ` + + + + + +`; diff --git a/src/editors/__snapshots__/Placeholder.test.jsx.snap b/src/editors/__snapshots__/Placeholder.test.jsx.snap new file mode 100644 index 0000000000..f504cf0632 --- /dev/null +++ b/src/editors/__snapshots__/Placeholder.test.jsx.snap @@ -0,0 +1,13 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders correctly 1`] = ` +
+

+ Under Construction +
+ Coming Soon +

+
+`; diff --git a/src/editors/__snapshots__/VideoSelector.test.jsx.snap b/src/editors/__snapshots__/VideoSelector.test.jsx.snap new file mode 100644 index 0000000000..d067c4a41b --- /dev/null +++ b/src/editors/__snapshots__/VideoSelector.test.jsx.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Video Selector render rendering correctly with expected Input 1`] = ``; diff --git a/src/editors/__snapshots__/VideoSelectorPage.test.jsx.snap b/src/editors/__snapshots__/VideoSelectorPage.test.jsx.snap new file mode 100644 index 0000000000..3a4ddcaaa1 --- /dev/null +++ b/src/editors/__snapshots__/VideoSelectorPage.test.jsx.snap @@ -0,0 +1,53 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Video Selector Page snapshots rendering correctly with expected Input 1`] = ` + + + + + +`; + +exports[`Video Selector Page snapshots rendering with props to null 1`] = ` + + + + + +`; diff --git a/src/editors/containers/EditorContainer/__snapshots__/index.test.jsx.snap b/src/editors/containers/EditorContainer/__snapshots__/index.test.jsx.snap new file mode 100644 index 0000000000..49598b47ee --- /dev/null +++ b/src/editors/containers/EditorContainer/__snapshots__/index.test.jsx.snap @@ -0,0 +1,159 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EditorContainer component render snapshot: initialized. enable save and pass to header 1`] = ` +
+ + + + } + footerAction={null} + headerComponent={null} + isFullscreenScroll={true} + isOpen={false} + size="md" + title="Exit the editor?" + > + + + +
+

+ +

+ +
+
+ +

+ My test content +

+
+ +
+`; + +exports[`EditorContainer component render snapshot: not initialized. disable save and pass to header 1`] = ` +
+ + + + } + footerAction={null} + headerComponent={null} + isFullscreenScroll={true} + isOpen={false} + size="md" + title="Exit the editor?" + > + + + +
+

+ +

+ +
+
+ + +
+`; diff --git a/src/editors/containers/EditorContainer/components/EditorFooter/__snapshots__/index.test.jsx.snap b/src/editors/containers/EditorContainer/components/EditorFooter/__snapshots__/index.test.jsx.snap new file mode 100644 index 0000000000..dbfca713db --- /dev/null +++ b/src/editors/containers/EditorContainer/components/EditorFooter/__snapshots__/index.test.jsx.snap @@ -0,0 +1,114 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EditorFooter render snapshot: default args (disableSave: false, saveFailed: false) 1`] = ` +
+ + + + + + +
+`; + +exports[`EditorFooter render snapshot: save disabled. Show button spinner 1`] = ` +
+ + + + + + +
+`; + +exports[`EditorFooter render snapshot: save failed. Show error message 1`] = ` +
+ + + + + + + + + +
+`; diff --git a/src/editors/containers/EditorContainer/components/EditorFooter/index.jsx b/src/editors/containers/EditorContainer/components/EditorFooter/index.jsx new file mode 100644 index 0000000000..20c2f2d560 --- /dev/null +++ b/src/editors/containers/EditorContainer/components/EditorFooter/index.jsx @@ -0,0 +1,64 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { + Spinner, + ActionRow, + Button, + ModalDialog, + Toast, +} from '@openedx/paragon'; +import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n'; + +import messages from './messages'; + +const EditorFooter = ({ + clearSaveFailed, + disableSave, + onCancel, + onSave, + saveFailed, + // injected + intl, +}) => ( +
+ {saveFailed && ( + + + + )} + + + + + + +
+); + +EditorFooter.propTypes = { + clearSaveFailed: PropTypes.func.isRequired, + disableSave: PropTypes.bool.isRequired, + onCancel: PropTypes.func.isRequired, + onSave: PropTypes.func.isRequired, + saveFailed: PropTypes.bool.isRequired, + // injected + intl: intlShape.isRequired, +}; + +export const EditorFooterInternal = EditorFooter; // For testing only +export default injectIntl(EditorFooter); diff --git a/src/editors/containers/EditorContainer/components/EditorFooter/index.test.jsx b/src/editors/containers/EditorContainer/components/EditorFooter/index.test.jsx new file mode 100644 index 0000000000..aaa78980a2 --- /dev/null +++ b/src/editors/containers/EditorContainer/components/EditorFooter/index.test.jsx @@ -0,0 +1,33 @@ +import 'CourseAuthoring/editors/setupEditorTest'; +import React from 'react'; +import { shallow } from '@edx/react-unit-test-utils'; + +import { formatMessage } from '../../../../testUtils'; +import { EditorFooterInternal as EditorFooter } from '.'; + +jest.mock('../../hooks', () => ({ + nullMethod: jest.fn().mockName('hooks.nullMethod'), +})); + +describe('EditorFooter', () => { + const props = { + intl: { formatMessage }, + disableSave: false, + onCancel: jest.fn().mockName('args.onCancel'), + onSave: jest.fn().mockName('args.onSave'), + saveFailed: false, + }; + describe('render', () => { + test('snapshot: default args (disableSave: false, saveFailed: false)', () => { + expect(shallow().snapshot).toMatchSnapshot(); + }); + + test('snapshot: save disabled. Show button spinner', () => { + expect(shallow().snapshot).toMatchSnapshot(); + }); + + test('snapshot: save failed. Show error message', () => { + expect(shallow().snapshot).toMatchSnapshot(); + }); + }); +}); diff --git a/src/editors/containers/EditorContainer/components/EditorFooter/messages.js b/src/editors/containers/EditorContainer/components/EditorFooter/messages.js new file mode 100644 index 0000000000..ce7503ff14 --- /dev/null +++ b/src/editors/containers/EditorContainer/components/EditorFooter/messages.js @@ -0,0 +1,32 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + + contentSaveFailed: { + id: 'authoring.editorfooter.save.error', + defaultMessage: 'Error: Content save failed. Please check recent changes and try again later.', + description: 'Error message displayed when content fails to save.', + }, + cancelButtonAriaLabel: { + id: 'authoring.editorfooter.cancelButton.ariaLabel', + defaultMessage: 'Discard changes and return to learning context', + description: 'Screen reader label for cancel button', + }, + cancelButtonLabel: { + id: 'authoring.editorfooter.cancelButton.label', + defaultMessage: 'Cancel', + description: 'Label for cancel button', + }, + saveButtonAriaLabel: { + id: 'authoring.editorfooter.savebutton.ariaLabel', + defaultMessage: 'Save changes and return to learning context', + description: 'Screen reader label for save button', + }, + saveButtonLabel: { + id: 'authoring.editorfooter.savebutton.label', + defaultMessage: 'Save', + description: 'Label for Save button', + }, +}); + +export default messages; diff --git a/src/editors/containers/EditorContainer/components/TitleHeader/EditConfirmationButtons.jsx b/src/editors/containers/EditorContainer/components/TitleHeader/EditConfirmationButtons.jsx new file mode 100644 index 0000000000..6b53531cf2 --- /dev/null +++ b/src/editors/containers/EditorContainer/components/TitleHeader/EditConfirmationButtons.jsx @@ -0,0 +1,40 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { IconButtonWithTooltip, ButtonGroup, Icon } from '@openedx/paragon'; +import { Check, Close } from '@openedx/paragon/icons'; +import { useIntl } from '@edx/frontend-platform/i18n'; + +import messages from './messages'; + +const EditConfirmationButtons = ({ + updateTitle, + cancelEdit, +}) => { + const intl = useIntl(); + return ( + + + + + ); +}; + +EditConfirmationButtons.propTypes = { + updateTitle: PropTypes.func.isRequired, + cancelEdit: PropTypes.func.isRequired, +}; + +export default EditConfirmationButtons; diff --git a/src/editors/containers/EditorContainer/components/TitleHeader/EditConfirmationButtons.test.jsx b/src/editors/containers/EditorContainer/components/TitleHeader/EditConfirmationButtons.test.jsx new file mode 100644 index 0000000000..e69ff16b5c --- /dev/null +++ b/src/editors/containers/EditorContainer/components/TitleHeader/EditConfirmationButtons.test.jsx @@ -0,0 +1,19 @@ +import 'CourseAuthoring/editors/setupEditorTest'; +import React from 'react'; +import { shallow } from '@edx/react-unit-test-utils'; +import { formatMessage } from '../../../../testUtils'; + +import EditConfirmationButtons from './EditConfirmationButtons'; + +describe('EditConfirmationButtons', () => { + const props = { + intl: { formatMessage }, + updateTitle: jest.fn().mockName('args.updateTitle'), + cancelEdit: jest.fn().mockName('args.cancelEdit'), + }; + describe('snapshot', () => { + test('snapshot', () => { + expect(shallow().snapshot).toMatchSnapshot(); + }); + }); +}); diff --git a/src/editors/containers/EditorContainer/components/TitleHeader/EditableHeader.jsx b/src/editors/containers/EditorContainer/components/TitleHeader/EditableHeader.jsx new file mode 100644 index 0000000000..ba212f162c --- /dev/null +++ b/src/editors/containers/EditorContainer/components/TitleHeader/EditableHeader.jsx @@ -0,0 +1,45 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { Form } from '@openedx/paragon'; +import EditConfirmationButtons from './EditConfirmationButtons'; + +const EditableHeader = ({ + handleChange, + updateTitle, + handleKeyDown, + inputRef, + localTitle, + cancelEdit, +}) => ( + updateTitle(e)}> + } + onChange={handleChange} + onKeyDown={handleKeyDown} + placeholder="Title" + ref={inputRef} + value={localTitle} + /> + +); +EditableHeader.defaultProps = { + inputRef: null, +}; +EditableHeader.propTypes = { + inputRef: PropTypes.oneOfType([ + PropTypes.func, + // eslint-disable-next-line react/forbid-prop-types + PropTypes.shape({ current: PropTypes.any }), + ]), + handleChange: PropTypes.func.isRequired, + updateTitle: PropTypes.func.isRequired, + handleKeyDown: PropTypes.func.isRequired, + localTitle: PropTypes.string.isRequired, + cancelEdit: PropTypes.func.isRequired, +}; + +export const EditableHeaderInternal = EditableHeader; // For testing only +export default EditableHeader; diff --git a/src/editors/containers/EditorContainer/components/TitleHeader/EditableHeader.test.jsx b/src/editors/containers/EditorContainer/components/TitleHeader/EditableHeader.test.jsx new file mode 100644 index 0000000000..349b89b5aa --- /dev/null +++ b/src/editors/containers/EditorContainer/components/TitleHeader/EditableHeader.test.jsx @@ -0,0 +1,33 @@ +import 'CourseAuthoring/editors/setupEditorTest'; +import React from 'react'; +import { shallow } from '@edx/react-unit-test-utils'; +import { Form } from '@openedx/paragon'; +import { EditableHeaderInternal as EditableHeader } from './EditableHeader'; +import EditConfirmationButtons from './EditConfirmationButtons'; + +describe('EditableHeader', () => { + const props = { + handleChange: jest.fn().mockName('args.handleChange'), + updateTitle: jest.fn().mockName('args.updateTitle'), + handleKeyDown: jest.fn().mockName('args.handleKeyDown'), + inputRef: jest.fn().mockName('args.inputRef'), + localTitle: 'test-title-text', + cancelEdit: jest.fn().mockName('args.cancelEdit'), + }; + let el; + beforeEach(() => { + el = shallow(); + }); + + describe('snapshot', () => { + test('snapshot', () => { + expect(el.snapshot).toMatchSnapshot(); + }); + test('displays Edit Icon', () => { + const formControl = el.instance.findByType(Form.Control)[0]; + expect(formControl.props.trailingElement).toMatchObject( + , + ); + }); + }); +}); diff --git a/src/editors/containers/EditorContainer/components/TitleHeader/__snapshots__/EditConfirmationButtons.test.jsx.snap b/src/editors/containers/EditorContainer/components/TitleHeader/__snapshots__/EditConfirmationButtons.test.jsx.snap new file mode 100644 index 0000000000..5ad61806cb --- /dev/null +++ b/src/editors/containers/EditorContainer/components/TitleHeader/__snapshots__/EditConfirmationButtons.test.jsx.snap @@ -0,0 +1,19 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EditConfirmationButtons snapshot snapshot 1`] = ` + + + + +`; diff --git a/src/editors/containers/EditorContainer/components/TitleHeader/__snapshots__/EditableHeader.test.jsx.snap b/src/editors/containers/EditorContainer/components/TitleHeader/__snapshots__/EditableHeader.test.jsx.snap new file mode 100644 index 0000000000..0d202841d6 --- /dev/null +++ b/src/editors/containers/EditorContainer/components/TitleHeader/__snapshots__/EditableHeader.test.jsx.snap @@ -0,0 +1,26 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EditableHeader snapshot snapshot 1`] = ` + + + } + value="test-title-text" + /> + +`; diff --git a/src/editors/containers/EditorContainer/components/TitleHeader/__snapshots__/index.test.jsx.snap b/src/editors/containers/EditorContainer/components/TitleHeader/__snapshots__/index.test.jsx.snap new file mode 100644 index 0000000000..bcc8a044ed --- /dev/null +++ b/src/editors/containers/EditorContainer/components/TitleHeader/__snapshots__/index.test.jsx.snap @@ -0,0 +1,32 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`TitleHeader snapshots editing 1`] = ` + +`; + +exports[`TitleHeader snapshots initialized 1`] = ` +
+ + { + "useSelector": [Function], + } + + +
+`; + +exports[`TitleHeader snapshots not initialized 1`] = `"Loading..."`; diff --git a/src/editors/containers/EditorContainer/components/TitleHeader/hooks.js b/src/editors/containers/EditorContainer/components/TitleHeader/hooks.js new file mode 100644 index 0000000000..d64801c071 --- /dev/null +++ b/src/editors/containers/EditorContainer/components/TitleHeader/hooks.js @@ -0,0 +1,76 @@ +import React from 'react'; +import { useSelector } from 'react-redux'; + +import { actions, selectors } from '../../../../data/redux'; +import * as textEditorHooks from '../../hooks'; +// This 'module' self-import hack enables mocking during tests. +// See src/editors/decisions/0005-internal-editor-testability-decisions.md. The whole approach to how hooks are tested +// should be re-thought and cleaned up to avoid this pattern. +// eslint-disable-next-line import/no-self-import +import * as module from './hooks'; + +export const { navigateCallback } = textEditorHooks; + +export const state = { + // eslint-disable-next-line react-hooks/rules-of-hooks + localTitle: (args) => React.useState(args), +}; + +export const hooks = { + isEditing: () => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const [isEditing, setIsEditing] = React.useState(false); + return { + isEditing, + startEditing: () => setIsEditing(true), + stopEditing: () => setIsEditing(false), + }; + }, + + localTitle: ({ dispatch, stopEditing }) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const title = useSelector(selectors.app.displayTitle); + const [localTitle, setLocalTitle] = module.state.localTitle(title); + return { + updateTitle: (e) => { + if (localTitle.length <= 0) { + setLocalTitle(title); + stopEditing(); + } else if (!e.currentTarget.contains(e.relatedTarget)) { + dispatch(actions.app.setBlockTitle(localTitle)); + stopEditing(); + } + }, + handleChange: (e) => setLocalTitle(e.target.value), + cancelEdit: () => { + setLocalTitle(title); + stopEditing(); + }, + localTitle, + }; + }, +}; + +export const localTitleHooks = ({ dispatch }) => { + const { isEditing, startEditing, stopEditing } = module.hooks.isEditing(); + const { + localTitle, + handleChange, + updateTitle, + cancelEdit, + } = module.hooks.localTitle({ + dispatch, + stopEditing, + }); + return { + isEditing, + startEditing, + stopEditing, + cancelEdit, + localTitle, + updateTitle, + handleChange, + + inputRef: React.createRef(), + }; +}; diff --git a/src/editors/containers/EditorContainer/components/TitleHeader/hooks.test.js b/src/editors/containers/EditorContainer/components/TitleHeader/hooks.test.js new file mode 100644 index 0000000000..8698282021 --- /dev/null +++ b/src/editors/containers/EditorContainer/components/TitleHeader/hooks.test.js @@ -0,0 +1,155 @@ +import 'CourseAuthoring/editors/setupEditorTest'; +import React from 'react'; +import { useSelector } from 'react-redux'; + +import { actions, selectors } from '../../../../data/redux'; +import { MockUseState } from '../../../../testUtils'; +import * as hooks from './hooks'; + +jest.mock('react', () => { + const updateState = jest.fn(); + return { + updateState, + useState: jest.fn(val => ([{ state: val }, (newVal) => updateState({ val, newVal })])), + createRef: jest.fn(val => ({ ref: val })), + }; +}); + +jest.mock('../../hooks', () => ({ + navigateCallback: (args) => ({ navigateCallback: args }), +})); + +jest.mock('../../../../data/redux', () => ({ + actions: { + app: { + setBlockTitle: (args) => ({ setBlockTitle: args }), + }, + }, + selectors: { + app: { + displayTitle: (state) => ({ displayTitle: state }), + }, + }, +})); + +const state = new MockUseState(hooks); + +describe('TitleHeader hooks', () => { + let output; + let dispatch; + beforeEach(() => { + dispatch = jest.fn(); + }); + describe('state hooks', () => { + state.testGetter(state.keys.localTitle); + }); + describe('non-state hooks', () => { + beforeEach(() => { state.mock(); }); + afterEach(() => { state.restore(); }); + + describe('isEditing', () => { + beforeEach(() => { + output = hooks.hooks.isEditing(); + }); + test('returns isEditing field, defaulted to false', () => { + expect(output.isEditing).toEqual({ state: false }); + }); + test('startEditing calls the setter function with true', () => { + output.startEditing(); + expect(React.updateState).toHaveBeenCalledWith({ val: false, newVal: true }); + }); + test('stopEditing calls the setter function with false', () => { + output.stopEditing(); + expect(React.updateState).toHaveBeenCalledWith({ val: false, newVal: false }); + }); + }); + + describe('localTitle', () => { + let stopEditing; + beforeEach(() => { + stopEditing = jest.fn(); + output = hooks.hooks.localTitle({ dispatch, stopEditing }); + }); + test('returns the state value for localTitle, defaulted to displayTitle', () => { + expect(output.localTitle).toEqual(useSelector(selectors.app.displayTitle)); + }); + describe('updateTitle hook', () => { + it('calls setBlockTitle with localTitle, and stopEditing', () => { + const div = document.createElement('div'); + const mockEvent = { currentTarget: div }; + output.updateTitle(mockEvent); + expect(dispatch).toHaveBeenCalledWith(actions.app.setBlockTitle(output.localTitle)); + expect(stopEditing).toHaveBeenCalled(); + }); + }); + describe('handleChange', () => { + it('calls setLocalTitle with the event target value', () => { + const value = 'SOME VALUe'; + output.handleChange({ target: { value } }); + expect(state.setState[state.keys.localTitle]).toHaveBeenCalledWith(value); + }); + }); + describe('cancelEdit', () => { + it('calls setLocalTitle with previously stored title, and stopEditing', () => { + output.cancelEdit(); + expect(state.setState[state.keys.localTitle]).toHaveBeenCalledWith(useSelector(selectors.app.displayTitle)); + expect(stopEditing).toHaveBeenCalled(); + }); + }); + }); + describe('local title hooks', () => { + let oldHooks; + const values = { + isEditing: 'ISeDITING', + startEditing: 'STARTeDITING', + stopEditing: 'STOPeDITING', + handleChange: 'HANDLEcHANGE', + localTitle: 'LOCALtITLE', + cancelEdit: 'CANCelEDit', + }; + const newHooks = { + isEditing: () => ({ + isEditing: values.isEditing, + startEditing: values.startEditing, + stopEditing: values.stopEditing, + }), + localTitle: jest.fn((args) => ({ + updateTitle: args, + handleChange: values.handleChange, + localTitle: values.localTitle, + cancelEdit: values.cancelEdit, + })), + handleKeyDown: jest.fn(args => ({ handleKeyDown: args })), + }; + beforeEach(() => { + oldHooks = hooks.hooks; + hooks.hooks.isEditing = newHooks.isEditing; + hooks.hooks.localTitle = newHooks.localTitle; + hooks.hooks.handleKeyDown = newHooks.handleKeyDown; + + output = hooks.localTitleHooks({ dispatch }); + }); + afterEach(() => { + // eslint-disable-next-line no-import-assign + hooks.hooks = oldHooks; + }); + it('returns isEditing, startEditing, and stopEditing, tied to the isEditing hook', () => { + expect(output.isEditing).toEqual(values.isEditing); + expect(output.startEditing).toEqual(values.startEditing); + expect(output.stopEditing).toEqual(values.stopEditing); + }); + it('returns localTitle, updateTitle, handleChange, and cancelEdit tied to the localTitle hook', () => { + expect(output.updateTitle).toEqual({ + dispatch, + stopEditing: values.stopEditing, + }); + expect(output.handleChange).toEqual(values.handleChange); + expect(output.localTitle).toEqual(values.localTitle); + expect(output.cancelEdit).toEqual(values.cancelEdit); + }); + it('returns a new ref for inputRef', () => { + expect(output.inputRef).toEqual({ ref: undefined }); + }); + }); + }); +}); diff --git a/src/editors/containers/EditorContainer/components/TitleHeader/index.jsx b/src/editors/containers/EditorContainer/components/TitleHeader/index.jsx new file mode 100644 index 0000000000..8e3f017bce --- /dev/null +++ b/src/editors/containers/EditorContainer/components/TitleHeader/index.jsx @@ -0,0 +1,74 @@ +import React from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import PropTypes from 'prop-types'; + +import { Icon, IconButton, Truncate } from '@openedx/paragon'; +import { EditOutline } from '@openedx/paragon/icons'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; + +import { selectors } from '../../../../data/redux'; +import { localTitleHooks } from './hooks'; +import messages from './messages'; +import EditableHeader from './EditableHeader'; + +const TitleHeader = ({ + isInitialized, + // injected + intl, +}) => { + if (!isInitialized) { return intl.formatMessage(messages.loading); } + // eslint-disable-next-line react-hooks/rules-of-hooks + const dispatch = useDispatch(); + // eslint-disable-next-line react-hooks/rules-of-hooks + const title = useSelector(selectors.app.displayTitle); + + const { + inputRef, + isEditing, + handleChange, + handleKeyDown, + localTitle, + startEditing, + cancelEdit, + updateTitle, + } = localTitleHooks({ dispatch }); + + if (isEditing) { + return ( + + ); + } + return ( +
+ + {title} + + +
+ ); +}; +TitleHeader.defaultProps = {}; +TitleHeader.propTypes = { + isInitialized: PropTypes.bool.isRequired, + // injected + intl: intlShape.isRequired, +}; + +export const TitleHeaderInternal = TitleHeader; // For testing only +export default injectIntl(TitleHeader); diff --git a/src/editors/containers/EditorContainer/components/TitleHeader/index.test.jsx b/src/editors/containers/EditorContainer/components/TitleHeader/index.test.jsx new file mode 100644 index 0000000000..6366e1c758 --- /dev/null +++ b/src/editors/containers/EditorContainer/components/TitleHeader/index.test.jsx @@ -0,0 +1,62 @@ +import 'CourseAuthoring/editors/setupEditorTest'; +import React from 'react'; +import { useDispatch } from 'react-redux'; +import { shallow } from '@edx/react-unit-test-utils'; + +import { formatMessage } from '../../../../testUtils'; +import { localTitleHooks } from './hooks'; +import { TitleHeaderInternal as TitleHeader } from '.'; + +jest.mock('./hooks', () => ({ + localTitleHooks: jest.fn(), +})); +jest.mock('@openedx/paragon', () => ({ + ...jest.requireActual('@openedx/paragon'), + Truncate: ({ children }) =>
{children}
, // eslint-disable-line react/prop-types + IconButton: 'IconButton', + Icon: 'Icon', +})); +jest.mock('./EditableHeader'); + +describe('TitleHeader', () => { + const props = { + intl: { formatMessage }, + isInitialized: false, + setTitle: jest.fn().mockName('args.setTitle'), + title: 'html', + }; + const localTitleHooksProps = { + inputRef: jest.fn().mockName('localTitleHooks.inputRef'), + isEditing: false, + handleChange: jest.fn().mockName('localTitleHooks.handleChange'), + handleKeyDown: jest.fn().mockName('localTitleHooks.handleKeyDown'), + localTitle: 'TeST LocALtitLE', + startEditing: jest.fn().mockName('localTitleHooks.startEditing'), + updateTitle: jest.fn().mockName('localTitleHooks.updateTitle'), + }; + + describe('behavior', () => { + it(' calls localTitleHooks with initialization args', () => { + localTitleHooks.mockReturnValue(localTitleHooksProps); + shallow(); + const dispatch = useDispatch(); + expect(localTitleHooks).toHaveBeenCalledWith({ + dispatch, + }); + }); + }); + + describe('snapshots', () => { + test('not initialized', () => { + expect(shallow().snapshot).toMatchSnapshot(); + }); + test('initialized', () => { + localTitleHooks.mockReturnValue(localTitleHooksProps); + expect(shallow().shallowWrapper).toMatchSnapshot(); + }); + test('editing', () => { + localTitleHooks.mockReturnValue({ ...localTitleHooksProps, isEditing: true }); + expect(shallow().snapshot).toMatchSnapshot(); + }); + }); +}); diff --git a/src/editors/containers/EditorContainer/components/TitleHeader/messages.js b/src/editors/containers/EditorContainer/components/TitleHeader/messages.js new file mode 100644 index 0000000000..54d92ac16b --- /dev/null +++ b/src/editors/containers/EditorContainer/components/TitleHeader/messages.js @@ -0,0 +1,32 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + + loading: { + id: 'authoring.texteditor.title.loading', + defaultMessage: 'Loading...', + description: 'Message displayed while loading content', + }, + cancelChangesLabel: { + id: 'authoring.texteditor.header.cancelChangesLabel', + defaultMessage: 'Cancel Changes and Return to Learning Context', + description: 'Screen reader label title for icon button to return to learning context', + }, + editTitleLabel: { + id: 'authoring.texteditor.header.editTitleLabel', + defaultMessage: 'Edit Title', + description: 'Screen reader label title for icon button to edit the xblock title', + }, + cancelTitleEdit: { + id: 'authoring.texteditor.header.cancelTitleEdit', + defaultMessage: 'Cancel', + description: 'Screen reader label title for icon button to edit the xblock title', + }, + saveTitleEdit: { + id: 'authoring.texteditor.header.saveTitleEdit', + defaultMessage: 'Save', + description: 'Screen reader label title for icon button to edit the xblock title', + }, +}); + +export default messages; diff --git a/src/editors/containers/EditorContainer/hooks.js b/src/editors/containers/EditorContainer/hooks.js new file mode 100644 index 0000000000..72b8a0e20f --- /dev/null +++ b/src/editors/containers/EditorContainer/hooks.js @@ -0,0 +1,80 @@ +import { useState } from 'react'; +import { useSelector } from 'react-redux'; + +import analyticsEvt from '../../data/constants/analyticsEvt'; +import { RequestKeys } from '../../data/constants/requests'; +import { selectors } from '../../data/redux'; +import { StrictDict } from '../../utils'; +import * as appHooks from '../../hooks'; +// This 'module' self-import hack enables mocking during tests. +// See src/editors/decisions/0005-internal-editor-testability-decisions.md. The whole approach to how hooks are tested +// should be re-thought and cleaned up to avoid this pattern. +// eslint-disable-next-line import/no-self-import +import * as module from './hooks'; + +export const { + clearSaveError, + navigateCallback, + nullMethod, + saveBlock, +} = appHooks; + +export const state = StrictDict({ + // eslint-disable-next-line react-hooks/rules-of-hooks + isCancelConfirmModalOpen: (val) => useState(val), +}); + +export const handleSaveClicked = ({ + dispatch, + getContent, + validateEntry, + returnFunction, +}) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const returnUrl = useSelector(selectors.app.returnUrl); + const destination = returnFunction ? '' : returnUrl; + // eslint-disable-next-line react-hooks/rules-of-hooks + const analytics = useSelector(selectors.app.analytics); + + return () => saveBlock({ + analytics, + content: getContent({ dispatch }), + destination, + dispatch, + returnFunction, + validateEntry, + }); +}; + +export const cancelConfirmModalToggle = () => { + const [isCancelConfirmOpen, setIsOpen] = module.state.isCancelConfirmModalOpen(false); + return { + isCancelConfirmOpen, + openCancelConfirmModal: () => setIsOpen(true), + closeCancelConfirmModal: () => setIsOpen(false), + }; +}; + +export const handleCancel = ({ onClose, returnFunction }) => { + if (onClose) { + return onClose; + } + // eslint-disable-next-line react-hooks/rules-of-hooks + const returnUrl = useSelector(selectors.app.returnUrl); + return navigateCallback({ + returnFunction, + // eslint-disable-next-line react-hooks/rules-of-hooks + destination: returnFunction ? '' : returnUrl, + analyticsEvent: analyticsEvt.editorCancelClick, + // eslint-disable-next-line react-hooks/rules-of-hooks + analytics: useSelector(selectors.app.analytics), + }); +}; + +// eslint-disable-next-line react-hooks/rules-of-hooks +export const isInitialized = () => useSelector(selectors.app.isInitialized); + +// eslint-disable-next-line react-hooks/rules-of-hooks +export const saveFailed = () => useSelector((rootState) => ( + selectors.requests.isFailed(rootState, { requestKey: RequestKeys.saveBlock }) +)); diff --git a/src/editors/containers/EditorContainer/hooks.test.jsx b/src/editors/containers/EditorContainer/hooks.test.jsx new file mode 100644 index 0000000000..e73534f5b3 --- /dev/null +++ b/src/editors/containers/EditorContainer/hooks.test.jsx @@ -0,0 +1,150 @@ +import 'CourseAuthoring/editors/setupEditorTest'; +import * as reactRedux from 'react-redux'; +import { MockUseState } from '../../testUtils'; + +import { RequestKeys } from '../../data/constants/requests'; +import { selectors } from '../../data/redux'; + +import * as appHooks from '../../hooks'; +import * as hooks from './hooks'; +import analyticsEvt from '../../data/constants/analyticsEvt'; + +const hookState = new MockUseState(hooks); + +jest.mock('../../data/redux', () => ({ + selectors: { + app: { + isInitialized: (state) => ({ isInitialized: state }), + images: (state) => ({ images: state }), + }, + requests: { + isFailed: (...args) => ({ requestFailed: args }), + }, + }, +})); +jest.mock('../../hooks', () => ({ + ...jest.requireActual('../../hooks'), + navigateCallback: jest.fn((args) => ({ navigateCallback: args })), + saveBlock: jest.fn((args) => ({ saveBlock: args })), +})); + +const dispatch = jest.fn(); +describe('EditorContainer hooks', () => { + describe('forwarded hooks', () => { + it('forwards clearSaveError from app hooks', () => { + expect(hooks.clearSaveError).toEqual(appHooks.clearSaveError); + }); + it('forwards navigateCallback from app hooks', () => { + expect(hooks.navigateCallback).toEqual(appHooks.navigateCallback); + }); + it('forwards nullMethod from app hooks', () => { + expect(hooks.nullMethod).toEqual(appHooks.nullMethod); + }); + it('forwards saveBlock from app hooks', () => { + expect(hooks.saveBlock).toEqual(appHooks.saveBlock); + }); + }); + describe('local hooks', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + describe('handleSaveClicked', () => { + it('returns callback to saveBlock with dispatch and content from setAssetToStaticUrl', () => { + const getContent = () => 'myTestContentValue'; + const setAssetToStaticUrl = () => 'myTestContentValue'; + const validateEntry = () => 'vaLIdAteENTry'; + const output = hooks.handleSaveClicked({ + getContent, + images: { + portableUrl: '/static/sOmEuiMAge.jpeg', + displayName: 'sOmEuiMAge', + }, + destination: 'testDEsTURL', + analytics: 'soMEanALytics', + dispatch, + validateEntry, + }); + output(); + expect(appHooks.saveBlock).toHaveBeenCalledWith({ + content: setAssetToStaticUrl(reactRedux.useSelector(selectors.app.images), getContent), + destination: reactRedux.useSelector(selectors.app.returnUrl), + analytics: reactRedux.useSelector(selectors.app.analytics), + dispatch, + validateEntry, + }); + }); + }); + + describe('cancelConfirmModalToggle', () => { + const hookKey = hookState.keys.isCancelConfirmModalOpen; + beforeEach(() => { + jest.clearAllMocks(); + }); + describe('state hook', () => { + hookState.testGetter(hookKey); + }); + describe('using state', () => { + beforeEach(() => { + hookState.mock(); + }); + afterEach(() => { + hookState.restore(); + }); + + describe('cancelConfirmModalToggle', () => { + let hook; + beforeEach(() => { + hook = hooks.cancelConfirmModalToggle(); + }); + test('isCancelConfirmOpen: state value', () => { + expect(hook.isCancelConfirmOpen).toEqual(hookState.stateVals[hookKey]); + }); + test('openCancelConfirmModal: calls setter with true', () => { + hook.openCancelConfirmModal(); + expect(hookState.setState[hookKey]).toHaveBeenCalledWith(true); + }); + test('closeCancelConfirmModal: calls setter with false', () => { + hook.closeCancelConfirmModal(); + expect(hookState.setState[hookKey]).toHaveBeenCalledWith(false); + }); + }); + }); + }); + + describe('handleCancel', () => { + it('calls navigateCallback to returnUrl if onClose is not passed', () => { + expect(hooks.handleCancel({})).toEqual( + appHooks.navigateCallback({ + destination: reactRedux.useSelector(selectors.app.returnUrl), + analyticsEvent: analyticsEvt.editorCancelClick, + analytics: reactRedux.useSelector(selectors.app.analytics), + }), + ); + }); + it('calls onClose and not navigateCallback if onClose is passed', () => { + const onClose = () => 'my close value'; + expect(hooks.handleCancel({ onClose })).toEqual(onClose); + expect(appHooks.navigateCallback).not.toHaveBeenCalled(); + }); + }); + describe('isInitialized', () => { + it('forwards selectors.app.isInitialized', () => { + expect(hooks.isInitialized()).toEqual( + reactRedux.useSelector(selectors.app.isInitialized), + ); + }); + }); + describe('saveFailed', () => { + it('forwards requests.isFailed selector for saveBlock request', () => { + const testState = { some: 'state' }; + const testValue = 'Some data'; + reactRedux.useSelector.mockReturnValueOnce(testValue); + expect(hooks.saveFailed()).toEqual(testValue); + const [[cb]] = reactRedux.useSelector.mock.calls; + expect(cb(testState)).toEqual( + selectors.requests.isFailed(testState, { requestKey: RequestKeys.saveBlock }), + ); + }); + }); + }); +}); diff --git a/src/editors/containers/EditorContainer/index.jsx b/src/editors/containers/EditorContainer/index.jsx new file mode 100644 index 0000000000..5c80cbce79 --- /dev/null +++ b/src/editors/containers/EditorContainer/index.jsx @@ -0,0 +1,103 @@ +import React from 'react'; +import { useDispatch } from 'react-redux'; +import PropTypes from 'prop-types'; + +import { + Icon, ModalDialog, IconButton, Button, +} from '@openedx/paragon'; +import { Close } from '@openedx/paragon/icons'; +import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n'; + +import BaseModal from '../../sharedComponents/BaseModal'; +import EditorFooter from './components/EditorFooter'; +import TitleHeader from './components/TitleHeader'; +import * as hooks from './hooks'; +import messages from './messages'; +import './index.scss'; + +const EditorContainer = ({ + children, + getContent, + onClose, + validateEntry, + returnFunction, + // injected + intl, +}) => { + const dispatch = useDispatch(); + const isInitialized = hooks.isInitialized(); + const { isCancelConfirmOpen, openCancelConfirmModal, closeCancelConfirmModal } = hooks.cancelConfirmModalToggle(); + const handleCancel = hooks.handleCancel({ onClose, returnFunction }); + return ( +
+ { + handleCancel(); + if (returnFunction) { + closeCancelConfirmModal(); + } + }} + > + + + )} + isOpen={isCancelConfirmOpen} + close={closeCancelConfirmModal} + title={intl.formatMessage(messages.cancelConfirmTitle)} + > + + + +
+

+ +

+ +
+
+ + {isInitialized && children} + + +
+ ); +}; +EditorContainer.defaultProps = { + onClose: null, + returnFunction: null, + validateEntry: null, +}; +EditorContainer.propTypes = { + children: PropTypes.node.isRequired, + getContent: PropTypes.func.isRequired, + onClose: PropTypes.func, + returnFunction: PropTypes.func, + validateEntry: PropTypes.func, + // injected + intl: intlShape.isRequired, +}; + +export const EditorContainerInternal = EditorContainer; // For testing only +export default injectIntl(EditorContainer); diff --git a/src/editors/containers/EditorContainer/index.scss b/src/editors/containers/EditorContainer/index.scss new file mode 100644 index 0000000000..27f72bb2fd --- /dev/null +++ b/src/editors/containers/EditorContainer/index.scss @@ -0,0 +1,6 @@ +// fix double scrollbars +.editor-container { + .pgn__modal-body { + overflow: visible; + } +} diff --git a/src/editors/containers/EditorContainer/index.test.jsx b/src/editors/containers/EditorContainer/index.test.jsx new file mode 100644 index 0000000000..5360fdb7de --- /dev/null +++ b/src/editors/containers/EditorContainer/index.test.jsx @@ -0,0 +1,68 @@ +import 'CourseAuthoring/editors/setupEditorTest'; +import { shallow } from '@edx/react-unit-test-utils'; +import { useDispatch } from 'react-redux'; + +import { EditorContainerInternal as EditorContainer } from '.'; +import * as hooks from './hooks'; +import { formatMessage } from '../../testUtils'; + +const props = { + getContent: jest.fn().mockName('props.getContent'), + onClose: jest.fn().mockName('props.onClose'), + validateEntry: jest.fn().mockName('props.validateEntry'), + returnFunction: jest.fn().mockName('props.returnFunction'), + // inject + intl: { formatMessage }, +}; + +jest.mock('./hooks', () => ({ + clearSaveError: jest.fn().mockName('hooks.clearSaveError'), + isInitialized: jest.fn().mockReturnValue(true), + handleCancel: (args) => ({ handleCancel: args }), + handleSaveClicked: (args) => ({ handleSaveClicked: args }), + saveFailed: jest.fn().mockName('hooks.saveFailed'), + cancelConfirmModalToggle: jest.fn(() => ({ + isCancelConfirmOpen: false, + openCancelConfirmModal: jest.fn().mockName('openCancelConfirmModal'), + closeCancelConfirmModal: jest.fn().mockName('closeCancelConfirmModal'), + })), +})); + +let el; + +describe('EditorContainer component', () => { + describe('render', () => { + const testContent = (

My test content

); + test('snapshot: not initialized. disable save and pass to header', () => { + hooks.isInitialized.mockReturnValueOnce(false); + expect( + shallow({testContent}).snapshot, + ).toMatchSnapshot(); + }); + test('snapshot: initialized. enable save and pass to header', () => { + expect( + shallow({testContent}).snapshot, + ).toMatchSnapshot(); + }); + describe('behavior inspection', () => { + beforeEach(() => { + el = shallow({testContent}); + }); + test('save behavior is linked to footer onSave', () => { + const expected = hooks.handleSaveClicked({ + dispatch: useDispatch(), + getContent: props.getContent, + validateEntry: props.validateEntry, + returnFunction: props.returnFunction, + }); + expect(el.shallowWrapper.props.children[3] + .props.onSave).toEqual(expected); + }); + test('behavior is linked to clearSaveError', () => { + const expected = hooks.clearSaveError({ dispatch: useDispatch() }); + expect(el.shallowWrapper.props.children[3] + .props.clearSaveFailed).toEqual(expected); + }); + }); + }); +}); diff --git a/src/editors/containers/EditorContainer/messages.js b/src/editors/containers/EditorContainer/messages.js new file mode 100644 index 0000000000..b8301ca810 --- /dev/null +++ b/src/editors/containers/EditorContainer/messages.js @@ -0,0 +1,22 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + + cancelConfirmTitle: { + id: 'authoring.editorContainer.cancelConfirm.title', + defaultMessage: 'Exit the editor?', + description: 'Label for modal confirming cancellation', + }, + cancelConfirmDescription: { + id: 'authoring.editorContainer.cancelConfirm.description', + defaultMessage: 'Are you sure you want to exit the editor? Any unsaved changes will be lost.', + description: 'Description text for modal confirming cancellation', + }, + okButtonLabel: { + id: 'authoring.editorContainer.okButton.label', + defaultMessage: 'OK', + description: 'Label for OK button', + }, +}); + +export default messages; diff --git a/src/editors/containers/GameEditor/index.jsx b/src/editors/containers/GameEditor/index.jsx new file mode 100644 index 0000000000..e14c3bad0f --- /dev/null +++ b/src/editors/containers/GameEditor/index.jsx @@ -0,0 +1,106 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/* eslint-disable no-unused-vars */ +/* eslint-disable import/extensions */ +/* eslint-disable import/no-unresolved */ +/** + * This is an example component for an xblock Editor + * It uses pre-existing components to handle the saving of a the result of a function into the xblock's data. + * To use run npm run-script addXblock + */ + +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; + +import { Spinner } from '@openedx/paragon'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; + +import EditorContainer from '../EditorContainer'; +// This 'module' self-import hack enables mocking during tests. +// See src/editors/decisions/0005-internal-editor-testability-decisions.md. The whole approach to how hooks are tested +// should be re-thought and cleaned up to avoid this pattern. +// eslint-disable-next-line import/no-self-import +import * as module from '.'; +import { actions, selectors } from '../../data/redux'; +import { RequestKeys } from '../../data/constants/requests'; + +export const hooks = { + getContent: () => ({ + some: 'content', + }), +}; + +export const thumbEditor = ({ + onClose, + // redux + blockValue, + lmsEndpointUrl, + blockFailed, + blockFinished, + initializeEditor, + exampleValue, + // inject + intl, +}) => ( + +
+ {exampleValue} +
+
+ {!blockFinished + ? ( +
+ +
+ ) + : ( +

+ Your Editor Goes here. + You can get at the xblock data with the blockValue field. + here is what is in your xblock: {JSON.stringify(blockValue)} +

+ )} +
+
+); +thumbEditor.defaultProps = { + blockValue: null, + lmsEndpointUrl: null, +}; +thumbEditor.propTypes = { + onClose: PropTypes.func.isRequired, + // redux + blockValue: PropTypes.shape({ + data: PropTypes.shape({ data: PropTypes.string }), + }), + lmsEndpointUrl: PropTypes.string, + blockFailed: PropTypes.bool.isRequired, + blockFinished: PropTypes.bool.isRequired, + initializeEditor: PropTypes.func.isRequired, + // inject + intl: intlShape.isRequired, +}; + +export const mapStateToProps = (state) => ({ + blockValue: selectors.app.blockValue(state), + lmsEndpointUrl: selectors.app.lmsEndpointUrl(state), + blockFailed: selectors.requests.isFailed(state, { requestKey: RequestKeys.fetchBlock }), + blockFinished: selectors.requests.isFinished(state, { requestKey: RequestKeys.fetchBlock }), + // TODO fill with redux state here if needed + exampleValue: selectors.game.exampleValue(state), +}); + +export const mapDispatchToProps = { + initializeEditor: actions.app.initializeEditor, + // TODO fill with dispatches here if needed +}; + +export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(thumbEditor)); diff --git a/src/editors/containers/ProblemEditor/__snapshots__/index.test.jsx.snap b/src/editors/containers/ProblemEditor/__snapshots__/index.test.jsx.snap new file mode 100644 index 0000000000..0370230fc0 --- /dev/null +++ b/src/editors/containers/ProblemEditor/__snapshots__/index.test.jsx.snap @@ -0,0 +1,25 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ProblemEditor snapshots block failed, message appears 1`] = ` +
+ +
+`; + +exports[`ProblemEditor snapshots renders as expected with default behavior 1`] = ` +
+ +
+`; diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/AnswerOption.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/AnswerOption.jsx new file mode 100644 index 0000000000..8ac30a31c4 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/AnswerOption.jsx @@ -0,0 +1,146 @@ +import React, { memo } from 'react'; +import { connect, useDispatch } from 'react-redux'; +import PropTypes from 'prop-types'; +import { + Collapsible, + Icon, + IconButton, + Form, +} from '@openedx/paragon'; +import { FeedbackOutline, DeleteOutline } from '@openedx/paragon/icons'; +import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import messages from './messages'; +import { selectors } from '../../../../../data/redux'; +import { answerOptionProps } from '../../../../../data/services/cms/types'; +import Checker from './components/Checker'; +import { FeedbackBox } from './components/Feedback'; +import * as hooks from './hooks'; +import { ProblemTypeKeys } from '../../../../../data/constants/problem'; +import ExpandableTextArea from '../../../../../sharedComponents/ExpandableTextArea'; + +const AnswerOption = ({ + answer, + hasSingleAnswer, + // injected + intl, + // redux + problemType, +}) => { + const dispatch = useDispatch(); + const removeAnswer = hooks.removeAnswer({ answer, dispatch }); + const setAnswer = hooks.setAnswer({ answer, hasSingleAnswer, dispatch }); + const setAnswerTitle = hooks.setAnswerTitle({ + answer, + hasSingleAnswer, + dispatch, + problemType, + }); + const setSelectedFeedback = hooks.setSelectedFeedback({ answer, hasSingleAnswer, dispatch }); + const setUnselectedFeedback = hooks.setUnselectedFeedback({ answer, hasSingleAnswer, dispatch }); + const { isFeedbackVisible, toggleFeedback } = hooks.useFeedback(answer); + + const getInputArea = () => { + if ([ProblemTypeKeys.SINGLESELECT, ProblemTypeKeys.MULTISELECT].includes(problemType)) { + return ( + + ); + } + if (problemType !== ProblemTypeKeys.NUMERIC || !answer.isAnswerRange) { + return ( + + ); + } + // Return Answer Range View + return ( +
+ +
+ +
+
+ + ); + }; + + return ( + +
+ +
+
+ {getInputArea()} + + + +
+
+ + + + +
+
+ ); +}; + +AnswerOption.propTypes = { + answer: answerOptionProps.isRequired, + hasSingleAnswer: PropTypes.bool.isRequired, + // injected + intl: intlShape.isRequired, + // redux + problemType: PropTypes.string.isRequired, +}; + +export const mapStateToProps = (state) => ({ + problemType: selectors.problem.problemType(state), +}); + +export const mapDispatchToProps = {}; +export const AnswerOptionInternal = AnswerOption; // For testing only +export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(memo(AnswerOption))); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/AnswerOption.test.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/AnswerOption.test.jsx new file mode 100644 index 0000000000..7ef58f6d9a --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/AnswerOption.test.jsx @@ -0,0 +1,76 @@ +import 'CourseAuthoring/editors/setupEditorTest'; +import React from 'react'; +import { shallow } from '@edx/react-unit-test-utils'; +import { formatMessage } from '../../../../../testUtils'; +import { selectors } from '../../../../../data/redux'; +import { AnswerOptionInternal as AnswerOption, mapStateToProps } from './AnswerOption'; + +jest.mock('../../../../../data/redux', () => ({ + __esModule: true, + default: jest.fn(), + selectors: { + problem: { + answers: jest.fn(state => ({ answers: state })), + problemType: jest.fn(state => ({ problemType: state })), + }, + }, + thunkActions: { + video: jest.fn(), + }, +})); + +describe('AnswerOption', () => { + const answerWithOnlyFeedback = { + id: 'A', + title: 'Answer 1', + correct: true, + selectedFeedback: 'some feedback', + }; + const answerWithSelectedUnselectedFeedback = { + id: 'A', + title: 'Answer 1', + correct: true, + selectedFeedback: 'selected feedback', + unselectedFeedback: 'unselected feedback', + }; + const answerRange = { + id: 'A', + title: 'Answer 1', + correct: true, + selectedFeedback: 'selected feedback', + unselectedFeedback: 'unselected feedback', + isAnswerRange: true, + }; + + const props = { + hasSingleAnswer: false, + answer: answerWithOnlyFeedback, + // inject + intl: { formatMessage }, + // redux + problemType: 'multiplechoiceresponse', + }; + describe('render', () => { + test('snapshot: renders correct option with feedback', () => { + expect(shallow().snapshot).toMatchSnapshot(); + }); + test('snapshot: renders correct option with selected unselected feedback', () => { + expect(shallow().snapshot).toMatchSnapshot(); + }); + test('snapshot: renders correct option with numeric input problem', () => { + expect(shallow().snapshot).toMatchSnapshot(); + }); + test('snapshot: renders correct option with numeric input problem and answer range', () => { + expect(shallow().snapshot).toMatchSnapshot(); + }); + }); + + describe('mapStateToProps', () => { + const testState = { A: 'pple', B: 'anana', C: 'ucumber' }; + test('problemType from problem.problemType', () => { + expect( + mapStateToProps(testState).problemType, + ).toEqual(selectors.problem.problemType(testState)); + }); + }); +}); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/AnswersContainer.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/AnswersContainer.jsx new file mode 100644 index 0000000000..1a5d547cb4 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/AnswersContainer.jsx @@ -0,0 +1,99 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { FormattedMessage } from '@edx/frontend-platform/i18n'; + +import { Dropdown, Icon } from '@openedx/paragon'; +import { Add } from '@openedx/paragon/icons'; +import messages from './messages'; +import { useAnswerContainer, isSingleAnswerProblem } from './hooks'; +import { actions, selectors } from '../../../../../data/redux'; +import { answerOptionProps } from '../../../../../data/services/cms/types'; +import AnswerOption from './AnswerOption'; +import Button from '../../../../../sharedComponents/Button'; +import { ProblemTypeKeys } from '../../../../../data/constants/problem'; + +const AnswersContainer = ({ + problemType, + // Redux + answers, + addAnswer, + addAnswerRange, + updateField, +}) => { + const hasSingleAnswer = isSingleAnswerProblem(problemType); + + useAnswerContainer({ answers, problemType, updateField }); + + return ( +
+ {answers.map((answer) => ( + + ))} + + {problemType !== ProblemTypeKeys.NUMERIC ? ( + + + ) : ( + + + + + + + + + + 1 || (answers.length === 1 && answers[0].isAnswerRange) ? 'disabled' : ''}`} + > + + + + + )} +
+ ); +}; + +AnswersContainer.propTypes = { + problemType: PropTypes.string.isRequired, + answers: PropTypes.arrayOf(answerOptionProps).isRequired, + addAnswer: PropTypes.func.isRequired, + addAnswerRange: PropTypes.func.isRequired, + updateField: PropTypes.func.isRequired, +}; + +export const mapStateToProps = (state) => ({ + answers: selectors.problem.answers(state), +}); + +export const mapDispatchToProps = { + addAnswer: actions.problem.addAnswer, + addAnswerRange: actions.problem.addAnswerRange, + updateField: actions.problem.updateField, +}; + +export const AnswersContainerInternal = AnswersContainer; // For testing only +export default connect(mapStateToProps, mapDispatchToProps)(AnswersContainer); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/AnswersContainer.test.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/AnswersContainer.test.jsx new file mode 100644 index 0000000000..c52933af2d --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/AnswersContainer.test.jsx @@ -0,0 +1,165 @@ +/* eslint-disable react/prop-types */ +import 'CourseAuthoring/editors/setupEditorTest'; +import React from 'react'; +import { shallow } from '@edx/react-unit-test-utils'; +import { act, render, waitFor } from '@testing-library/react'; + +import { actions, selectors } from '../../../../../data/redux'; + +import { AnswersContainerInternal as AnswersContainer, mapStateToProps, mapDispatchToProps } from './AnswersContainer'; +import { ProblemTypeKeys } from '../../../../../data/constants/problem'; + +jest.mock('@edx/frontend-platform/i18n', () => ({ + FormattedMessage: ({ defaultMessage }) => (

{defaultMessage}

), + defineMessages: m => m, + injectIntl: (args) => args, + intlShape: {}, + getLocale: jest.fn(), +})); + +jest.mock('./AnswerOption', () => function mockAnswerOption() { + return
MockAnswerOption
; +}); + +jest.mock('../../../../../data/redux', () => ({ + actions: { + problem: { + updateField: jest.fn().mockName('actions.problem.updateField'), + addAnswer: jest.fn().mockName('actions.problem.addAnswer'), + }, + }, + selectors: { + problem: { + answers: jest.fn(state => ({ answers: state })), + }, + }, +})); +describe('AnswersContainer', () => { + const props = { + answers: [], + updateField: jest.fn(), + addAnswer: jest.fn(), + }; + describe('render', () => { + test('snapshot: renders correct default', () => { + act(() => { + expect(shallow().snapshot).toMatchSnapshot(); + }); + }); + test('snapshot: renders correctly with answers', () => { + act(() => { + expect(shallow( + , + ).snapshot).toMatchSnapshot(); + }); + }); + test('snapshot: numeric problems: answer range/answer select button: empty', () => { + act(() => { + const emptyAnswerProps = { + problemType: ProblemTypeKeys.NUMERIC, + answers: [], + updateField: jest.fn(), + addAnswer: jest.fn(), + addAnswerRange: jest.fn(), + }; + expect(shallow( + , + ).snapshot).toMatchSnapshot(); + }); + }); + test('snapshot: numeric problems: answer range/answer select button: Range disables the additon of more adds', () => { + act(() => { + const answerRangeProps = { + problemType: ProblemTypeKeys.NUMERIC, + answers: [{ + id: 'A', + title: 'Answer 1', + correct: true, + selectedFeedback: 'selected feedback', + unselectedFeedback: 'unselected feedback', + isAnswerRange: true, + }], + updateField: jest.fn(), + addAnswer: jest.fn(), + addAnswerRange: jest.fn(), + }; + expect(shallow( + , + ).snapshot).toMatchSnapshot(); + }); + }); + test('snapshot: numeric problems: answer range/answer select button: multiple answers disables range.', () => { + act(() => { + const answersProps = { + problemType: ProblemTypeKeys.NUMERIC, + answers: [{ + id: 'A', + title: 'Answer 1', + correct: true, + selectedFeedback: 'selected feedback', + unselectedFeedback: 'unselected feedback', + isAnswerRange: false, + }, + { + id: 'B', + title: 'Answer 1', + correct: true, + selectedFeedback: 'selected feedback', + unselectedFeedback: 'unselected feedback', + isAnswerRange: false, + }, + ], + updateField: jest.fn(), + addAnswer: jest.fn(), + addAnswerRange: jest.fn(), + }; + expect(shallow( + , + ).snapshot).toMatchSnapshot(); + }); + }); + + test('useAnswerContainer', async () => { + let container = null; + await act(async () => { + const wrapper = render( + , + ); + container = wrapper.container; + }); + + await waitFor(() => expect(container.querySelector('button')).toBeTruthy()); + await new Promise(resolve => { setTimeout(resolve, 500); }); + + expect(props.updateField).toHaveBeenCalledWith(expect.objectContaining({ correctAnswerCount: 2 })); + }); + }); + describe('mapStateToProps', () => { + const testState = { A: 'pple', B: 'anana', C: 'ucumber' }; + test('answers from problem.answers', () => { + expect( + mapStateToProps(testState).answers, + ).toEqual(selectors.problem.answers(testState)); + }); + }); + describe('mapDispatchToProps', () => { + test('updateField from actions.problem.updateField', () => { + expect(mapDispatchToProps.updateField).toEqual(actions.problem.updateField); + }); + test('updateField from actions.problem.addAnswer', () => { + expect(mapDispatchToProps.addAnswer).toEqual(actions.problem.addAnswer); + }); + }); +}); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/__snapshots__/AnswerOption.test.jsx.snap b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/__snapshots__/AnswerOption.test.jsx.snap new file mode 100644 index 0000000000..171a2ab104 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/__snapshots__/AnswerOption.test.jsx.snap @@ -0,0 +1,324 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AnswerOption render snapshot: renders correct option with feedback 1`] = ` + +
+ +
+
+ + + + +
+
+ + + + +
+
+`; + +exports[`AnswerOption render snapshot: renders correct option with numeric input problem 1`] = ` + +
+ +
+
+ + + + +
+
+ + + + +
+
+`; + +exports[`AnswerOption render snapshot: renders correct option with numeric input problem and answer range 1`] = ` + +
+ +
+
+
+ +
+ +
+
+ + + +
+
+ + + + +
+
+`; + +exports[`AnswerOption render snapshot: renders correct option with selected unselected feedback 1`] = ` + +
+ +
+
+ + + + +
+
+ + + + +
+
+`; diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/__snapshots__/AnswersContainer.test.jsx.snap b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/__snapshots__/AnswersContainer.test.jsx.snap new file mode 100644 index 0000000000..9977f0dac7 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/__snapshots__/AnswersContainer.test.jsx.snap @@ -0,0 +1,238 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AnswersContainer render snapshot: numeric problems: answer range/answer select button: Range disables the additon of more adds 1`] = ` +
+ + + + + + + + + + + + + + + +
+`; + +exports[`AnswersContainer render snapshot: numeric problems: answer range/answer select button: empty 1`] = ` +
+ + + + + + + + + + + + + + +
+`; + +exports[`AnswersContainer render snapshot: numeric problems: answer range/answer select button: multiple answers disables range. 1`] = ` +
+ + + + + + + + + + + + + + + + +
+`; + +exports[`AnswersContainer render snapshot: renders correct default 1`] = ` +
+ +
+`; + +exports[`AnswersContainer render snapshot: renders correctly with answers 1`] = ` +
+ + + +
+`; diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/components/Checker/__snapshots__/index.test.jsx.snap b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/components/Checker/__snapshots__/index.test.jsx.snap new file mode 100644 index 0000000000..8dceea7438 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/components/Checker/__snapshots__/index.test.jsx.snap @@ -0,0 +1,55 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Checker component with disabled 1`] = ` + + + + A + + +`; + +exports[`Checker component with multiple answers 1`] = ` + + + + A + + +`; + +exports[`Checker component with single answer 1`] = ` + + + + A + + +`; diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/components/Checker/index.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/components/Checker/index.jsx new file mode 100644 index 0000000000..308e175477 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/components/Checker/index.jsx @@ -0,0 +1,46 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Form } from '@openedx/paragon'; + +const Checker = ({ + hasSingleAnswer, + answer, + setAnswer, + disabled, +}) => { + let CheckerType = Form.Checkbox; + if (hasSingleAnswer) { + CheckerType = Form.Radio; + } + return ( + <> + setAnswer({ correct: e.target.checked })} + checked={answer.correct} + isValid={answer.correct} + disabled={disabled} + /> + + {answer.id} + + + ); +}; +Checker.defaultProps = { + disabled: false, +}; +Checker.propTypes = { + hasSingleAnswer: PropTypes.bool.isRequired, + answer: PropTypes.shape({ + correct: PropTypes.bool, + id: PropTypes.number, + }).isRequired, + setAnswer: PropTypes.func.isRequired, + disabled: PropTypes.bool, +}; + +export default Checker; diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/components/Checker/index.test.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/components/Checker/index.test.jsx new file mode 100644 index 0000000000..1f99057ac2 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/components/Checker/index.test.jsx @@ -0,0 +1,27 @@ +import 'CourseAuthoring/editors/setupEditorTest'; +import { shallow } from '@edx/react-unit-test-utils'; +import Checker from '.'; + +const props = { + hasSingleAnswer: true, + answer: { + id: 'A', + title: 'Answer 1', + correct: true, + selectedFeedback: 'some feedback', + }, + setAnswer: jest.fn(), +}; +describe('Checker component', () => { + test('with single answer', () => { + expect(shallow().snapshot).toMatchSnapshot(); + }); + + test('with multiple answers', () => { + expect(shallow().snapshot).toMatchSnapshot(); + }); + + test('with disabled', () => { + expect(shallow().snapshot).toMatchSnapshot(); + }); +}); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/components/Feedback/FeedbackBox.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/components/Feedback/FeedbackBox.jsx new file mode 100644 index 0000000000..c75cafe2ff --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/components/Feedback/FeedbackBox.jsx @@ -0,0 +1,67 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; + +import { answerOptionProps } from '../../../../../../../data/services/cms/types'; +import FeedbackControl from './FeedbackControl'; +import messages from './messages'; +import { ProblemTypeKeys } from '../../../../../../../data/constants/problem'; + +export const FeedbackBox = ({ + answer, + problemType, + setSelectedFeedback, + setUnselectedFeedback, + // injected + intl, +}) => { + const props = { + answer, + intl, + }; + + return ((problemType === ProblemTypeKeys.MULTISELECT) ? ( +
+ + +
+ ) : ( +
+ +
+ )); +}; +FeedbackBox.propTypes = { + answer: answerOptionProps.isRequired, + problemType: PropTypes.string.isRequired, + setAnswer: PropTypes.func.isRequired, + setSelectedFeedback: PropTypes.func.isRequired, + setUnselectedFeedback: PropTypes.func.isRequired, + intl: intlShape.isRequired, +}; + +export default injectIntl(FeedbackBox); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/components/Feedback/FeedbackBox.test.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/components/Feedback/FeedbackBox.test.jsx new file mode 100644 index 0000000000..885cb7a6d2 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/components/Feedback/FeedbackBox.test.jsx @@ -0,0 +1,28 @@ +import { shallow } from '@edx/react-unit-test-utils'; +import { FeedbackBox } from './FeedbackBox'; + +const answerWithFeedback = { + id: 'A', + title: 'Answer 1', + correct: true, + selectedFeedback: 'some feedback', + unselectedFeedback: 'unselectedFeedback', + problemType: 'sOMepRObleM', +}; + +const props = { + answer: answerWithFeedback, + intl: {}, +}; + +describe('FeedbackBox component', () => { + test('renders as expected with default props', () => { + expect(shallow().snapshot).toMatchSnapshot(); + }); + test('renders as expected with a numeric input problem', () => { + expect(shallow().snapshot).toMatchSnapshot(); + }); + test('renders as expected with a multi select problem', () => { + expect(shallow().snapshot).toMatchSnapshot(); + }); +}); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/components/Feedback/FeedbackControl.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/components/Feedback/FeedbackControl.jsx new file mode 100644 index 0000000000..e22102f5a6 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/components/Feedback/FeedbackControl.jsx @@ -0,0 +1,47 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage, intlShape } from '@edx/frontend-platform/i18n'; +import { Form } from '@openedx/paragon'; + +import { answerOptionProps } from '../../../../../../../data/services/cms/types'; +import ExpandableTextArea from '../../../../../../../sharedComponents/ExpandableTextArea'; +import messages from './messages'; + +const FeedbackControl = ({ + feedback, + onChange, + labelMessage, + labelMessageBoldUnderline, + answer, + intl, + type, +}) => ( + + + , + }} + /> + + + +); +FeedbackControl.propTypes = { + feedback: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, + labelMessage: PropTypes.string.isRequired, + labelMessageBoldUnderline: PropTypes.string.isRequired, + answer: answerOptionProps.isRequired, + type: PropTypes.string.isRequired, + intl: intlShape.isRequired, +}; + +export default FeedbackControl; diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/components/Feedback/FeedbackControl.test.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/components/Feedback/FeedbackControl.test.jsx new file mode 100644 index 0000000000..7fb51bf491 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/components/Feedback/FeedbackControl.test.jsx @@ -0,0 +1,27 @@ +import 'CourseAuthoring/editors/setupEditorTest'; +import { shallow } from '@edx/react-unit-test-utils'; +import FeedbackControl from './FeedbackControl'; + +const answerWithFeedback = { + id: 'A', + title: 'Answer 1', + correct: true, + selectedFeedback: 'some feedback', + unselectedFeedback: 'unselectedFeedback', +}; + +const props = { + answer: answerWithFeedback, + intl: { formatMessage: jest.fn() }, + setAnswer: jest.fn(), + feedback: 'feedback', + onChange: jest.fn(), + labelMessage: 'msg', + labelMessageBoldUnderline: 'msg', +}; + +describe('FeedbackControl component', () => { + test('renders', () => { + expect(shallow().snapshot).toMatchSnapshot(); + }); +}); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/components/Feedback/__snapshots__/FeedbackBox.test.jsx.snap b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/components/Feedback/__snapshots__/FeedbackBox.test.jsx.snap new file mode 100644 index 0000000000..bdc6ada0b2 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/components/Feedback/__snapshots__/FeedbackBox.test.jsx.snap @@ -0,0 +1,142 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`FeedbackBox component renders as expected with a multi select problem 1`] = ` +
+ + +
+`; + +exports[`FeedbackBox component renders as expected with a numeric input problem 1`] = ` +
+ +
+`; + +exports[`FeedbackBox component renders as expected with default props 1`] = ` +
+ +
+`; diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/components/Feedback/__snapshots__/FeedbackControl.test.jsx.snap b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/components/Feedback/__snapshots__/FeedbackControl.test.jsx.snap new file mode 100644 index 0000000000..c84124f16b --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/components/Feedback/__snapshots__/FeedbackControl.test.jsx.snap @@ -0,0 +1,37 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`FeedbackControl component renders 1`] = ` + + + + + + + , + } + } + /> + + + +`; diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/components/Feedback/index.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/components/Feedback/index.jsx new file mode 100644 index 0000000000..bce02835a9 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/components/Feedback/index.jsx @@ -0,0 +1,2 @@ +export { default as FeedbackBox } from './FeedbackBox'; +export { default as FeedbackControl } from './FeedbackControl'; diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/components/Feedback/messages.js b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/components/Feedback/messages.js new file mode 100644 index 0000000000..b8b9ad8b2f --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/components/Feedback/messages.js @@ -0,0 +1,37 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + + feedbackPlaceholder: { + id: 'authoring.answerwidget.feedback.placeholder', + defaultMessage: 'Feedback message', + description: 'Placeholder text for feedback text', + }, + feedbackToggleIconAltText: { + id: 'authoring.answerwidget.feedback.icon.alt', + defaultMessage: 'Toggle feedback', + description: 'Alt text for feedback toggle icon', + }, + selectedFeedbackLabel: { + id: 'authoring.answerwidget.feedback.selected.label', + defaultMessage: 'Show following feedback when {answerId} {boldunderline}:', + description: 'Label text for feedback if option is selected', + }, + selectedFeedbackLabelBoldUnderlineText: { + id: 'authoring.answerwidget.feedback.selected.label.boldunderline', + defaultMessage: 'is selected', + description: 'Bold & underlined text for feedback if option is selected', + }, + unSelectedFeedbackLabel: { + id: 'authoring.answerwidget.feedback.unselected.label', + defaultMessage: 'Show following feedback when {answerId} {boldunderline}:', + description: 'Label text for feedback if option is not selected', + }, + unSelectedFeedbackLabelBoldUnderlineText: { + id: 'authoring.answerwidget.feedback.unselected.label.boldunderline', + defaultMessage: 'is not selected', + description: 'Bold & underlined text for feedback if option is not selected', + }, +}); + +export default messages; diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/hooks.js b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/hooks.js new file mode 100644 index 0000000000..b1e9585cf6 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/hooks.js @@ -0,0 +1,107 @@ +import { useState, useEffect } from 'react'; +import { StrictDict } from '../../../../../utils'; +// This 'module' self-import hack enables mocking during tests. +// See src/editors/decisions/0005-internal-editor-testability-decisions.md. The whole approach to how hooks are tested +// should be re-thought and cleaned up to avoid this pattern. +// eslint-disable-next-line import/no-self-import +import * as module from './hooks'; +import { actions } from '../../../../../data/redux'; +import { ProblemTypeKeys } from '../../../../../data/constants/problem'; +import { fetchEditorContent } from '../hooks'; + +export const state = StrictDict({ + // eslint-disable-next-line react-hooks/rules-of-hooks + isFeedbackVisible: (val) => useState(val), +}); + +export const removeAnswer = ({ + answer, + dispatch, +}) => () => { + dispatch(actions.problem.deleteAnswer({ + id: answer.id, + correct: answer.correct, + editorState: fetchEditorContent({ format: '' }), + })); +}; + +export const setAnswer = ({ answer, hasSingleAnswer, dispatch }) => (payload) => { + dispatch(actions.problem.updateAnswer({ id: answer.id, hasSingleAnswer, ...payload })); +}; + +export const setAnswerTitle = ({ + answer, + hasSingleAnswer, + dispatch, + problemType, +}) => (updatedTitle) => { + let title = updatedTitle; + if ([ProblemTypeKeys.TEXTINPUT, ProblemTypeKeys.NUMERIC, ProblemTypeKeys.DROPDOWN].includes(problemType)) { + title = updatedTitle.target.value; + } + dispatch(actions.problem.updateAnswer({ id: answer.id, hasSingleAnswer, title })); +}; + +export const setSelectedFeedback = ({ answer, hasSingleAnswer, dispatch }) => (e) => { + if (e.target) { + dispatch(actions.problem.updateAnswer({ + id: answer.id, + hasSingleAnswer, + selectedFeedback: e.target.value, + })); + } +}; + +export const setUnselectedFeedback = ({ answer, hasSingleAnswer, dispatch }) => (e) => { + if (e.target) { + dispatch(actions.problem.updateAnswer({ + id: answer.id, + hasSingleAnswer, + unselectedFeedback: e.target.value, + })); + } +}; + +export const useFeedback = (answer) => { + const [isFeedbackVisible, setIsFeedbackVisible] = module.state.isFeedbackVisible(false); + useEffect(() => { + // Show feedback fields if feedback is present + const isVisible = !!answer.selectedFeedback || !!answer.unselectedFeedback; + setIsFeedbackVisible(isVisible); + }, [answer]); + + const toggleFeedback = (open) => { + // Do not allow to hide if feedback is added + const { selectedFeedback, unselectedFeedback } = fetchEditorContent({ format: '' }); + + if (!!selectedFeedback?.[answer.id] || !!unselectedFeedback?.[answer.id]) { + setIsFeedbackVisible(true); + return; + } + setIsFeedbackVisible(open); + }; + return { + isFeedbackVisible, + toggleFeedback, + }; +}; + +export const isSingleAnswerProblem = (problemType) => ( + problemType === ProblemTypeKeys.DROPDOWN +); + +export const useAnswerContainer = ({ answers, updateField }) => { + useEffect(() => { + let answerCount = 0; + answers.forEach(answer => { + if (answer.correct) { + answerCount += 1; + } + }); + updateField({ correctAnswerCount: answerCount }); + }, []); +}; + +export default { + state, removeAnswer, setAnswer, setAnswerTitle, useFeedback, isSingleAnswerProblem, useAnswerContainer, +}; diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/hooks.test.js b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/hooks.test.js new file mode 100644 index 0000000000..9614e635f3 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/hooks.test.js @@ -0,0 +1,210 @@ +import 'CourseAuthoring/editors/setupEditorTest'; +import { useEffect } from 'react'; +import { useDispatch } from 'react-redux'; + +import { actions } from '../../../../../data/redux'; +import { MockUseState } from '../../../../../testUtils'; +import { ProblemTypeKeys } from '../../../../../data/constants/problem'; +// This 'module' self-import hack enables mocking during tests. +// See src/editors/decisions/0005-internal-editor-testability-decisions.md. The whole approach to how hooks are tested +// should be re-thought and cleaned up to avoid this pattern. +// eslint-disable-next-line import/no-self-import +import * as module from './hooks'; + +jest.mock('react', () => { + const updateState = jest.fn(); + return { + useEffect: jest.fn(), + useState: jest.fn(val => ([{ state: val }, (newVal) => updateState({ val, newVal })])), + }; +}); +jest.mock('@edx/frontend-platform/i18n', () => ({ + defineMessages: m => m, +})); +jest.mock('../../../../../data/redux', () => ({ + actions: { + problem: { + deleteAnswer: (args) => ({ deleteAnswer: args }), + updateAnswer: (args) => ({ updateAnswer: args }), + }, + }, +})); + +const state = new MockUseState(module); + +let output; +const answerWithOnlyFeedback = { + id: 'A', + title: 'Answer 1', + correct: true, + selectedFeedback: 'some feedback', +}; +let windowSpy; + +describe('Answer Options Hooks', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + describe('state hooks', () => { + state.testGetter(state.keys.isFeedbackVisible); + }); + describe('removeAnswer', () => { + beforeEach(() => { + windowSpy = jest.spyOn(window, 'window', 'get'); + }); + afterEach(() => { + windowSpy.mockRestore(); + }); + const answer = { id: 'A', correct: false }; + const dispatch = useDispatch(); + it('dispatches actions.problem.deleteAnswer', () => { + windowSpy.mockImplementation(() => ({ tinymce: { editors: { 'answer-A': { getContent: () => 'string' } } } })); + module.removeAnswer({ + answer, + dispatch, + })(); + expect(dispatch).toHaveBeenCalledWith(actions.problem.deleteAnswer({ + id: answer.id, + correct: answer.correct, + editorState: { + answers: { A: 'string' }, + hints: [], + }, + })); + }); + }); + describe('setAnswer', () => { + test('it dispatches actions.problem.updateAnswer', () => { + const answer = { id: 'A' }; + const hasSingleAnswer = false; + const dispatch = useDispatch(); + const payload = { random: 'string' }; + module.setAnswer({ answer, hasSingleAnswer, dispatch })(payload); + expect(dispatch).toHaveBeenCalledWith(actions.problem.updateAnswer({ + id: answer.id, + hasSingleAnswer, + ...payload, + })); + }); + }); + describe('setAnswerTitle', () => { + test('it dispatches actions.problem.updateAnswer for numeric problem', () => { + const answer = { id: 'A' }; + const hasSingleAnswer = false; + const dispatch = useDispatch(); + const updatedTitle = { target: { value: 'string' } }; + const problemType = 'numericalresponse'; + module.setAnswerTitle({ + answer, + hasSingleAnswer, + dispatch, + problemType, + })(updatedTitle); + expect(dispatch).toHaveBeenCalledWith(actions.problem.updateAnswer({ + id: answer.id, + hasSingleAnswer, + title: updatedTitle.target.value, + })); + }); + test('it dispatches actions.problem.updateAnswer for single select problem', () => { + const answer = { id: 'A' }; + const hasSingleAnswer = false; + const dispatch = useDispatch(); + const updatedTitle = 'string'; + const problemType = 'multiplechoiceresponse'; + module.setAnswerTitle({ + answer, + hasSingleAnswer, + dispatch, + problemType, + })(updatedTitle); + expect(dispatch).toHaveBeenCalledWith(actions.problem.updateAnswer({ + id: answer.id, + hasSingleAnswer, + title: updatedTitle, + })); + }); + }); + describe('setSelectedFeedback', () => { + test('it dispatches actions.problem.updateAnswer', () => { + const answer = { id: 'A' }; + const hasSingleAnswer = false; + const dispatch = useDispatch(); + const e = { target: { value: 'string' } }; + module.setSelectedFeedback({ answer, hasSingleAnswer, dispatch })(e); + expect(dispatch).toHaveBeenCalledWith(actions.problem.updateAnswer({ + id: answer.id, + hasSingleAnswer, + selectedFeedback: e.target.value, + })); + }); + }); + describe('setUnselectedFeedback', () => { + test('it dispatches actions.problem.updateAnswer', () => { + const answer = { id: 'A' }; + const hasSingleAnswer = false; + const dispatch = useDispatch(); + const e = { target: { value: 'string' } }; + module.setUnselectedFeedback({ answer, hasSingleAnswer, dispatch })(e); + expect(dispatch).toHaveBeenCalledWith(actions.problem.updateAnswer({ + id: answer.id, + hasSingleAnswer, + unselectedFeedback: e.target.value, + })); + }); + }); + describe('useFeedback hook', () => { + beforeEach(() => { + state.mock(); + windowSpy = jest.spyOn(window, 'window', 'get'); + }); + afterEach(() => { + state.restore(); + windowSpy.mockRestore(); + }); + test('default state is false', () => { + output = module.useFeedback(answerWithOnlyFeedback); + expect(output.isFeedbackVisible).toBeFalsy(); + }); + test('when useEffect triggers, isFeedbackVisible is set to true', () => { + const key = state.keys.isFeedbackVisible; + output = module.useFeedback(answerWithOnlyFeedback); + expect(state.setState[key]).not.toHaveBeenCalled(); + const [cb] = useEffect.mock.calls[0]; + cb(); + expect(state.setState[key]).toHaveBeenCalledWith(true); + }); + test('toggleFeedback with selected feedback', () => { + const key = state.keys.isFeedbackVisible; + output = module.useFeedback(answerWithOnlyFeedback); + windowSpy.mockImplementation(() => ({ tinymce: { editors: { 'selectedFeedback-A': { getContent: () => 'string' } } } })); + output.toggleFeedback(false); + expect(state.setState[key]).toHaveBeenCalledWith(true); + }); + test('toggleFeedback with unselected feedback', () => { + const key = state.keys.isFeedbackVisible; + output = module.useFeedback(answerWithOnlyFeedback); + windowSpy.mockImplementation(() => ({ tinymce: { editors: { 'unselectedFeedback-A': { getContent: () => 'string' } } } })); + output.toggleFeedback(false); + expect(state.setState[key]).toHaveBeenCalledWith(true); + }); + test('toggleFeedback with unselected feedback', () => { + const key = state.keys.isFeedbackVisible; + output = module.useFeedback(answerWithOnlyFeedback); + windowSpy.mockImplementation(() => ({ tinymce: { editors: { 'answer-A': { getContent: () => 'string' } } } })); + output.toggleFeedback(false); + expect(state.setState[key]).toHaveBeenCalledWith(false); + }); + }); + describe('isSingleAnswerProblem()', () => { + test('singleSelect', () => { + expect(module.isSingleAnswerProblem(ProblemTypeKeys.SINGLESELECT)).toBe(false); + }); + test('multiSelect', () => { + expect(module.isSingleAnswerProblem(ProblemTypeKeys.MULTISELECT)).toBe(false); + }); + test('dropdown', () => { + expect(module.isSingleAnswerProblem(ProblemTypeKeys.DROPDOWN)).toBe(true); + }); + }); +}); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/index.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/index.jsx new file mode 100644 index 0000000000..c7e865212e --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/index.jsx @@ -0,0 +1,38 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n'; + +import messages from './messages'; +import { ProblemTypes } from '../../../../../data/constants/problem'; +import AnswersContainer from './AnswersContainer'; + +// This widget should be connected, grab all answers from store, update them as needed. +const AnswerWidget = ({ + // Redux + problemType, + // injected + intl, +}) => { + const problemStaticData = ProblemTypes[problemType]; + return ( +
+
+
+ +
+
+ {intl.formatMessage(messages.answerHelperText, { helperText: problemStaticData.description })} +
+
+ +
+ ); +}; + +AnswerWidget.propTypes = { + problemType: PropTypes.string.isRequired, + // injected + intl: intlShape.isRequired, +}; +export const AnswerWidgetInternal = AnswerWidget; // For testing only +export default injectIntl(AnswerWidget); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/messages.js b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/messages.js new file mode 100644 index 0000000000..bd2358fc7f --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/messages.js @@ -0,0 +1,77 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + answerWidgetTitle: { + id: 'authoring.answerwidget.answer.answerWidgetTitle', + defaultMessage: 'Answers', + description: 'Main title for Answers widget', + }, + answerHelperText: { + id: 'authoring.problemEditor.answerWidget.answer.answerHelperText', + defaultMessage: '{helperText}', + description: 'Helper text describing how the user should input answers', + }, + addAnswerButtonText: { + id: 'authoring.answerwidget.answer.addAnswerButton', + defaultMessage: 'Add answer', + description: 'Button text to add answer', + }, + answerTextboxPlaceholder: { + id: 'authoring.answerwidget.answer.placeholder', + defaultMessage: 'Enter an answer', + description: 'Placeholder text for answer option text', + }, + feedbackPlaceholder: { + id: 'authoring.answerwidget.feedback.placeholder', + defaultMessage: 'Feedback message', + description: 'Placeholder text for feedback text', + }, + feedbackToggleIconAltText: { + id: 'authoring.answerwidget.feedback.icon.alt', + defaultMessage: 'Toggle feedback', + description: 'Alt text for feedback toggle icon', + }, + answerDeleteIconAltText: { + id: 'authoring.answerwidget.answer.delete.icon.alt', + defaultMessage: 'Delete answer', + description: 'Alt text for delete icon', + }, + selectedFeedbackLabel: { + id: 'authoring.answerwidget.feedback.selected.label', + defaultMessage: 'Show following feedback when {answerId} {boldunderline}:', + description: 'Label text for feedback if option is selected', + }, + selectedFeedbackLabelBoldUnderlineText: { + id: 'authoring.answerwidget.feedback.selected.label.boldunderline', + defaultMessage: 'is selected', + description: 'Bold & underlined text for feedback if option is selected', + }, + unSelectedFeedbackLabel: { + id: 'authoring.answerwidget.feedback.unselected.label', + defaultMessage: 'Show following feedback when {answerId} {boldunderline}:', + description: 'Label text for feedback if option is not selected', + }, + unSelectedFeedbackLabelBoldUnderlineText: { + id: 'authoring.answerwidget.feedback.unselected.label.boldunderline', + defaultMessage: 'is not selected', + description: 'Bold & underlined text for feedback if option is not selected', + }, + + addAnswerRangeButtonText: { + id: 'authoring.answerwidget.answer.addAnswerRangeButton', + defaultMessage: 'Add answer range', + description: 'Button text to add a range of answers', + }, + answerRangeTextboxPlaceholder: { + id: 'authoring.answerwidget.answer.answerRangeTextboxPlaceholder', + defaultMessage: 'Enter an answer range', + description: 'Text to prompt the user to add an answer range to the textbox.', + }, + answerRangeHelperText: { + id: 'authoring.answerwidget.answer.answerRangeHelperText', + defaultMessage: 'Enter min and max values separated by a comma. Use a bracket to include the number next to it in the range, or a parenthesis to exclude the number. For example, to identify the correct answers as 5, 6, or 7, but not 8, specify [5,8).', + description: 'Helper text describing usage of answer ranges', + }, +}); + +export default messages; diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/ExplanationWidget/__snapshots__/index.test.jsx.snap b/src/editors/containers/ProblemEditor/components/EditProblemView/ExplanationWidget/__snapshots__/index.test.jsx.snap new file mode 100644 index 0000000000..8a9deb9303 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/ExplanationWidget/__snapshots__/index.test.jsx.snap @@ -0,0 +1,34 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SolutionWidget render snapshot: renders correct default 1`] = ` +
+
+ +
+
+ +
+ <[object Object] + editorContentHtml="This is my solution" + editorType="solution" + id="solution" + minHeight={150} + placeholder="Enter your explanation" + setEditorRef={[MockFunction prepareEditorRef.setEditorRef]} + /> +
+`; diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/ExplanationWidget/index.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/ExplanationWidget/index.jsx new file mode 100644 index 0000000000..16bdbab9a7 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/ExplanationWidget/index.jsx @@ -0,0 +1,61 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { injectIntl, FormattedMessage, intlShape } from '@edx/frontend-platform/i18n'; +import { selectors } from '../../../../../data/redux'; +import messages from './messages'; + +import TinyMceWidget from '../../../../../sharedComponents/TinyMceWidget'; +import { prepareEditorRef, replaceStaticWithAsset } from '../../../../../sharedComponents/TinyMceWidget/hooks'; + +const ExplanationWidget = ({ + // redux + settings, + learningContextId, + // injected + intl, +}) => { + const { editorRef, refReady, setEditorRef } = prepareEditorRef(); + const initialContent = settings?.solutionExplanation || ''; + const newContent = replaceStaticWithAsset({ + initialContent, + learningContextId, + }); + const solutionContent = newContent || initialContent; + if (!refReady) { return null; } + return ( +
+
+ +
+
+ +
+ +
+ ); +}; + +ExplanationWidget.propTypes = { + // redux + // eslint-disable-next-line + settings: PropTypes.any.isRequired, + learningContextId: PropTypes.string.isRequired, + // injected + intl: intlShape.isRequired, +}; +export const mapStateToProps = (state) => ({ + settings: selectors.problem.settings(state), + learningContextId: selectors.app.learningContextId(state), +}); + +export const ExplanationWidgetInternal = ExplanationWidget; // For testing only +export default injectIntl(connect(mapStateToProps)(ExplanationWidget)); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/ExplanationWidget/index.test.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/ExplanationWidget/index.test.jsx new file mode 100644 index 0000000000..062330c190 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/ExplanationWidget/index.test.jsx @@ -0,0 +1,55 @@ +import 'CourseAuthoring/editors/setupEditorTest'; +import React from 'react'; +import { shallow } from '@edx/react-unit-test-utils'; +import { formatMessage } from '../../../../../testUtils'; +import { selectors } from '../../../../../data/redux'; +import { ExplanationWidgetInternal as ExplanationWidget, mapStateToProps } from '.'; + +jest.mock('../../../../../data/redux', () => ({ + __esModule: true, + default: jest.fn(), + selectors: { + problem: { + settings: jest.fn(state => ({ question: state })), + }, + app: { + learningContextId: jest.fn(state => ({ learningContextId: state })), + }, + }, + thunkActions: { + video: { + importTranscript: jest.fn(), + }, + }, +})); + +jest.mock('../../../../../sharedComponents/TinyMceWidget/hooks', () => ({ + ...jest.requireActual('../../../../../sharedComponents/TinyMceWidget/hooks'), + prepareEditorRef: jest.fn(() => ({ + refReady: true, + setEditorRef: jest.fn().mockName('prepareEditorRef.setEditorRef'), + })), +})); + +describe('SolutionWidget', () => { + const props = { + settings: { solutionExplanation: 'This is my solution' }, + learningContextId: 'course+org+run', + // injected + intl: { formatMessage }, + }; + describe('render', () => { + test('snapshot: renders correct default', () => { + expect(shallow().snapshot).toMatchSnapshot(); + }); + }); + describe('mapStateToProps', () => { + const testState = { A: 'pple', B: 'anana', C: 'ucumber' }; + test('settings from problem.settings', () => { + expect(mapStateToProps(testState).settings).toEqual(selectors.problem.settings(testState)); + }); + test('learningContextId from app.learningContextId', () => { + expect(mapStateToProps(testState).learningContextId).toEqual(selectors.app.learningContextId(testState)); + }); + }); +}); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/ExplanationWidget/messages.js b/src/editors/containers/ProblemEditor/components/EditProblemView/ExplanationWidget/messages.js new file mode 100644 index 0000000000..462ce70c1a --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/ExplanationWidget/messages.js @@ -0,0 +1,22 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + + solutionWidgetTitle: { + id: 'authoring.problemEditor.explanationwidget.explanationWidgetTitle', + defaultMessage: 'Explanation', + description: 'Explanation Title', + }, + solutionDescriptionText: { + id: 'authoring.problemEditor.solutionwidget.solutionDescriptionText', + defaultMessage: 'Provide an explanation for the correct answer', + description: 'Description of the solution widget', + }, + placeholder: { + id: 'authoring.problemEditor.questionwidget.placeholder', + defaultMessage: 'Enter your explanation', + description: 'Placeholder text for tinyMCE editor', + }, +}); + +export default messages; diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/QuestionWidget/__snapshots__/index.test.jsx.snap b/src/editors/containers/ProblemEditor/components/EditProblemView/QuestionWidget/__snapshots__/index.test.jsx.snap new file mode 100644 index 0000000000..891bf8846f --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/QuestionWidget/__snapshots__/index.test.jsx.snap @@ -0,0 +1,25 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`QuestionWidget render snapshot: renders correct default 1`] = ` +
+
+ +
+ <[object Object] + editorContentHtml="This is my question" + editorType="question" + id="question" + minHeight={150} + placeholder="Enter your question" + setEditorRef={[MockFunction prepareEditorRef.setEditorRef]} + /> +
+`; diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/QuestionWidget/index.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/QuestionWidget/index.jsx new file mode 100644 index 0000000000..a0ecde82a1 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/QuestionWidget/index.jsx @@ -0,0 +1,57 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { injectIntl, FormattedMessage, intlShape } from '@edx/frontend-platform/i18n'; +import { selectors } from '../../../../../data/redux'; +import messages from './messages'; + +import TinyMceWidget from '../../../../../sharedComponents/TinyMceWidget'; +import { prepareEditorRef, replaceStaticWithAsset } from '../../../../../sharedComponents/TinyMceWidget/hooks'; + +const QuestionWidget = ({ + // redux + question, + learningContextId, + // injected + intl, +}) => { + const { editorRef, refReady, setEditorRef } = prepareEditorRef(); + const initialContent = question; + const newContent = replaceStaticWithAsset({ + initialContent, + learningContextId, + }); + const questionContent = newContent || initialContent; + if (!refReady) { return null; } + return ( +
+
+ +
+ +
+ ); +}; + +QuestionWidget.propTypes = { + // redux + question: PropTypes.string.isRequired, + learningContextId: PropTypes.string.isRequired, + // injected + intl: intlShape.isRequired, +}; +export const mapStateToProps = (state) => ({ + question: selectors.problem.question(state), + learningContextId: selectors.app.learningContextId(state), +}); + +export const QuestionWidgetInternal = QuestionWidget; // For testing only +export default injectIntl(connect(mapStateToProps)(QuestionWidget)); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/QuestionWidget/index.test.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/QuestionWidget/index.test.jsx new file mode 100644 index 0000000000..c867da970a --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/QuestionWidget/index.test.jsx @@ -0,0 +1,61 @@ +import 'CourseAuthoring/editors/setupEditorTest'; +import React from 'react'; +import { shallow } from '@edx/react-unit-test-utils'; +import { formatMessage } from '../../../../../testUtils'; +import { selectors } from '../../../../../data/redux'; +import { QuestionWidgetInternal as QuestionWidget, mapStateToProps } from '.'; + +jest.mock('../../../../../data/redux', () => ({ + __esModule: true, + default: jest.fn(), + actions: { + problem: { + updateQuestion: jest.fn().mockName('actions.problem.updateQuestion'), + }, + }, + selectors: { + app: { + learningContextId: jest.fn(state => ({ learningContextId: state })), + }, + problem: { + question: jest.fn(state => ({ question: state })), + }, + }, + thunkActions: { + video: { + importTranscript: jest.fn(), + }, + }, +})); + +jest.mock('../../../../../sharedComponents/TinyMceWidget/hooks', () => ({ + ...jest.requireActual('../../../../../sharedComponents/TinyMceWidget/hooks'), + prepareEditorRef: jest.fn(() => ({ + refReady: true, + setEditorRef: jest.fn().mockName('prepareEditorRef.setEditorRef'), + })), +})); + +describe('QuestionWidget', () => { + const props = { + question: 'This is my question', + updateQuestion: jest.fn(), + learningContextId: 'course+org+run', + // injected + intl: { formatMessage }, + }; + describe('render', () => { + test('snapshot: renders correct default', () => { + expect(shallow().snapshot).toMatchSnapshot(); + }); + }); + describe('mapStateToProps', () => { + const testState = { A: 'pple', B: 'anana', C: 'ucumber' }; + test('question from problem.question', () => { + expect(mapStateToProps(testState).question).toEqual(selectors.problem.question(testState)); + }); + test('learningContextId from app.learningContextId', () => { + expect(mapStateToProps(testState).learningContextId).toEqual(selectors.app.learningContextId(testState)); + }); + }); +}); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/QuestionWidget/messages.js b/src/editors/containers/ProblemEditor/components/EditProblemView/QuestionWidget/messages.js new file mode 100644 index 0000000000..a3a61fd957 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/QuestionWidget/messages.js @@ -0,0 +1,17 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + + questionWidgetTitle: { + id: 'authoring.questionwidget.question.questionWidgetTitle', + defaultMessage: 'Question', + description: 'Question Title', + }, + placeholder: { + id: 'authoring.problemEditor.questionwidget.placeholder', + defaultMessage: 'Enter your question', + description: 'Placeholder text for tinyMCE editor', + }, +}); + +export default messages; diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/CardSection.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/CardSection.jsx new file mode 100644 index 0000000000..4c0157c0e2 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/CardSection.jsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { Collapsible, Card } from '@openedx/paragon'; +import { + bool, string, node, +} from 'prop-types'; + +const CardSection = ({ + children, none, isCardCollapsibleOpen, summary, +}) => { + const show = isCardCollapsibleOpen || summary; + if (!show) { return null; } + + return ( + + + + {summary} + + + + + {children} + + + + ); +}; +CardSection.propTypes = { + none: bool, + children: node.isRequired, + summary: string, + isCardCollapsibleOpen: bool.isRequired, +}; +CardSection.defaultProps = { + none: false, + summary: null, +}; + +export default CardSection; diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/CardSection.test.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/CardSection.test.jsx new file mode 100644 index 0000000000..a11f9ea276 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/CardSection.test.jsx @@ -0,0 +1,15 @@ +import 'CourseAuthoring/editors/setupEditorTest'; +import { shallow } from '@edx/react-unit-test-utils'; +import CardSection from './CardSection'; + +describe('CardSection', () => { + test('open', () => { + expect(shallow(

Section Text

).snapshot).toMatchSnapshot(); + }); + + test('closed', () => { + expect( + shallow(

Section Text

).snapshot, + ).toMatchSnapshot(); + }); +}); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/SettingsOption.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/SettingsOption.jsx new file mode 100644 index 0000000000..929bedb245 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/SettingsOption.jsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { Collapsible, Icon, Card } from '@openedx/paragon'; +import { KeyboardArrowUp, KeyboardArrowDown } from '@openedx/paragon/icons'; +import { + arrayOf, + shape, + string, + node, + bool, +} from 'prop-types'; +import { showFullCard } from './hooks'; +import CardSection from './CardSection'; + +const SettingsOption = ({ + title, className, extraSections, children, summary, hasExpandableTextArea, ...passThroughProps +}) => { + const { isCardCollapsibleOpen, toggleCardCollapse } = showFullCard(hasExpandableTextArea); + + return ( + + + + + {title} + + + + + + + + + + + {children} + + {extraSections.map((section, index) => ( + <> + {isCardCollapsibleOpen &&
} + {/* eslint-disable-next-line react/no-array-index-key */} + + {section.children} + + + ))} +
+ ); +}; +SettingsOption.propTypes = { + title: string.isRequired, + children: node.isRequired, + className: string, + summary: string.isRequired, + extraSections: arrayOf(shape({ + children: node, + })), + hasExpandableTextArea: bool, +}; +SettingsOption.defaultProps = { + className: '', + extraSections: [], + hasExpandableTextArea: false, +}; + +export default SettingsOption; diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/SettingsOption.test.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/SettingsOption.test.jsx new file mode 100644 index 0000000000..5c9c1a17e8 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/SettingsOption.test.jsx @@ -0,0 +1,24 @@ +import 'CourseAuthoring/editors/setupEditorTest'; +import React from 'react'; +import { shallow } from '@edx/react-unit-test-utils'; +import SettingsOption from './SettingsOption'; + +describe('SettingsOption', () => { + describe('default with children', () => { + const children = (

My test content

); + test('snapshot: renders correct', () => { + expect(shallow({children}).snapshot).toMatchSnapshot(); + }); + }); + describe('with additional sections', () => { + const children = (

First Section

); + const sections = [

Second Section

,

Third Section

]; + test('snapshot: renders correct', () => { + expect(shallow( + + {children} + , + ).snapshot).toMatchSnapshot(); + }); + }); +}); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/__snapshots__/CardSection.test.jsx.snap b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/__snapshots__/CardSection.test.jsx.snap new file mode 100644 index 0000000000..f5ba5df9f8 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/__snapshots__/CardSection.test.jsx.snap @@ -0,0 +1,34 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CardSection closed 1`] = `null`; + +exports[`CardSection open 1`] = ` + + + + + summary + + + + + +

+ Section Text +

+ +
+
+`; diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/__snapshots__/SettingsOption.test.jsx.snap b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/__snapshots__/SettingsOption.test.jsx.snap new file mode 100644 index 0000000000..031121967a --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/__snapshots__/SettingsOption.test.jsx.snap @@ -0,0 +1,109 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SettingsOption default with children snapshot: renders correct 1`] = ` + + + + + + Settings Option Title + + + + + + + + + + + +

+ My test content +

+
+
+`; + +exports[`SettingsOption with additional sections snapshot: renders correct 1`] = ` + + + + + + Settings Option Title + + + + + + + + + + + +

+ First Section +

+
+ + + + + + +
+`; diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/__snapshots__/index.test.jsx.snap b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/__snapshots__/index.test.jsx.snap new file mode 100644 index 0000000000..2042e3f432 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/__snapshots__/index.test.jsx.snap @@ -0,0 +1,253 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SettingsWidget snapshot snapshot: renders Settings widget for Advanced Problem with correct widgets 1`] = ` +
+
+ +
+
+ +
+
+ +
+ +
+ + + + + +
+ + +
+ +
+
+ +
+
+ +
+
+ +
+ +
+
+`; + +exports[`SettingsWidget snapshot snapshot: renders Settings widget page 1`] = ` +
+
+ +
+
+ +
+
+ +
+ +
+ + + + + +
+ + +
+ +
+
+ +
+
+ +
+
+ +
+ +
+
+`; + +exports[`SettingsWidget snapshot snapshot: renders Settings widget page advanced settings visible 1`] = ` +
+
+ +
+
+ +
+
+ +
+ +
+ + + + + +
+ + +
+ +
+
+ +
+
+ +
+
+ +
+ +
+
+`; diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/hooks.js b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/hooks.js new file mode 100644 index 0000000000..5f10f22894 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/hooks.js @@ -0,0 +1,333 @@ +import { useState, useEffect } from 'react'; + +import _ from 'lodash'; +// This 'module' self-import hack enables mocking during tests. +// See src/editors/decisions/0005-internal-editor-testability-decisions.md. The whole approach to how hooks are tested +// should be re-thought and cleaned up to avoid this pattern. +// eslint-disable-next-line import/no-self-import +import * as module from './hooks'; +import messages from './messages'; +import { + ProblemTypeKeys, + ProblemTypes, + RichTextProblems, + ShowAnswerTypesKeys, +} from '../../../../../data/constants/problem'; +import { fetchEditorContent } from '../hooks'; + +export const state = { + // eslint-disable-next-line react-hooks/rules-of-hooks + showAdvanced: (val) => useState(val), + // eslint-disable-next-line react-hooks/rules-of-hooks + cardCollapsed: (val) => useState(val), + // eslint-disable-next-line react-hooks/rules-of-hooks + summary: (val) => useState(val), + // eslint-disable-next-line react-hooks/rules-of-hooks + showAttempts: (val) => useState(val), + // eslint-disable-next-line react-hooks/rules-of-hooks + attemptDisplayValue: (val) => useState(val), +}; + +export const showAdvancedSettingsCards = () => { + const [isAdvancedCardsVisible, setIsAdvancedCardsVisible] = module.state.showAdvanced(false); + return { + isAdvancedCardsVisible, + showAdvancedCards: () => setIsAdvancedCardsVisible(true), + }; +}; + +export const showFullCard = (hasExpandableTextArea) => { + const [isCardCollapsibleOpen, setIsCardCollapsibleOpen] = module.state.cardCollapsed(hasExpandableTextArea); + return { + isCardCollapsibleOpen, + toggleCardCollapse: () => { + if (hasExpandableTextArea) { + setIsCardCollapsibleOpen(true); + } else { + setIsCardCollapsibleOpen(!isCardCollapsibleOpen); + } + }, + }; +}; + +export const hintsCardHooks = (hints, updateSettings) => { + const [summary, setSummary] = module.state.summary({ message: messages.noHintSummary, values: {} }); + + // eslint-disable-next-line react-hooks/rules-of-hooks + useEffect(() => { + const hintsNumber = hints.length; + if (hintsNumber === 0) { + setSummary({ message: messages.noHintSummary, values: {} }); + } else { + setSummary({ message: messages.hintSummary, values: { hint: hints[0].value, count: (hintsNumber - 1) } }); + } + }, [hints]); + + const handleAdd = () => { + let newId = 0; + if (!_.isEmpty(hints)) { + newId = Math.max(...hints.map(hint => hint.id)) + 1; + } + const hint = { id: newId, value: '' }; + const modifiedHints = [...hints, hint]; + updateSettings({ hints: modifiedHints }); + }; + + return { + summary, + handleAdd, + }; +}; + +export const hintsRowHooks = (id, hints, updateSettings) => { + const handleChange = (value) => { + const modifiedHints = hints.map(hint => { + if (hint.id === id) { + return { ...hint, value }; + } + return hint; + }); + updateSettings({ hints: modifiedHints }); + }; + + const handleDelete = () => { + const modifiedHints = hints.filter((hint) => (hint.id !== id)); + updateSettings({ hints: modifiedHints }); + }; + + return { + handleChange, + handleDelete, + }; +}; + +export const resetCardHooks = (updateSettings) => { + const setReset = (value) => { + updateSettings({ showResetButton: value }); + }; + + return { + setResetTrue: () => setReset(true), + setResetFalse: () => setReset(false), + }; +}; + +export const scoringCardHooks = (scoring, updateSettings, defaultValue) => { + let loadedAttemptsNumber = scoring.attempts.number; + if ((loadedAttemptsNumber === defaultValue || !_.isFinite(loadedAttemptsNumber)) && _.isFinite(defaultValue)) { + loadedAttemptsNumber = `${defaultValue} (Default)`; + } else if (loadedAttemptsNumber === defaultValue && _.isNil(defaultValue)) { + loadedAttemptsNumber = ''; + } + const [attemptDisplayValue, setAttemptDisplayValue] = module.state.attemptDisplayValue(loadedAttemptsNumber); + + const handleUnlimitedChange = (event) => { + const isUnlimited = event.target.checked; + if (isUnlimited) { + setAttemptDisplayValue(''); + updateSettings({ scoring: { ...scoring, attempts: { number: null, unlimited: true } } }); + } else { + updateSettings({ scoring: { ...scoring, attempts: { number: null, unlimited: false } } }); + } + }; + + const handleMaxAttemptChange = (event) => { + let unlimitedAttempts = false; + let attemptNumber = parseInt(event.target.value, 10); + + if (!_.isFinite(attemptNumber) || attemptNumber === defaultValue) { + attemptNumber = null; + if (_.isFinite(defaultValue)) { + setAttemptDisplayValue(`${defaultValue} (Default)`); + } else { + setAttemptDisplayValue(''); + unlimitedAttempts = true; + } + } else if (attemptNumber <= 0) { + attemptNumber = 0; + } + + updateSettings({ scoring: { ...scoring, attempts: { number: attemptNumber, unlimited: unlimitedAttempts } } }); + }; + + const handleOnChange = (event) => { + let newMaxAttempt = parseInt(event.target.value, 10); + if (newMaxAttempt === defaultValue) { + newMaxAttempt = `${defaultValue} (Default)`; + } else if (_.isNaN(newMaxAttempt)) { + newMaxAttempt = ''; + } else if (newMaxAttempt < 0) { + newMaxAttempt = 0; + } + setAttemptDisplayValue(newMaxAttempt); + }; + + const handleWeightChange = (event) => { + let weight = parseFloat(event.target.value); + if (_.isNaN(weight) || weight < 0) { + weight = 0; + } + updateSettings({ scoring: { ...scoring, weight } }); + }; + + return { + attemptDisplayValue, + handleUnlimitedChange, + handleMaxAttemptChange, + handleOnChange, + handleWeightChange, + }; +}; + +export const useAnswerSettings = (showAnswer, updateSettings) => { + const [showAttempts, setShowAttempts] = module.state.showAttempts(false); + + const numberOfAttemptsChoice = [ + ShowAnswerTypesKeys.AFTER_SOME_NUMBER_OF_ATTEMPTS, + ]; + + useEffect(() => { + setShowAttempts(_.includes(numberOfAttemptsChoice, showAnswer.on)); + }, [showAttempts]); + + const handleShowAnswerChange = (event) => { + const { value } = event.target; + setShowAttempts(_.includes(numberOfAttemptsChoice, value)); + updateSettings({ showAnswer: { ...showAnswer, on: value } }); + }; + + const handleAttemptsChange = (event) => { + let attempts = parseInt(event.target.value, 10); + if (_.isNaN(attempts) || attempts < 0) { + attempts = 0; + } + updateSettings({ showAnswer: { ...showAnswer, afterAttempts: attempts } }); + }; + + return { + handleShowAnswerChange, + handleAttemptsChange, + showAttempts, + }; +}; + +export const timerCardHooks = (updateSettings) => ({ + handleChange: (event) => { + let time = parseInt(event.target.value, 10); + if (_.isNaN(time) || time < 0) { + time = 0; + } + updateSettings({ timeBetween: time }); + }, +}); + +export const typeRowHooks = ({ + answers, + blockTitle, + correctAnswerCount, + problemType, + setBlockTitle, + typeKey, + updateField, + updateAnswer, +}) => { + const clearPreviouslySelectedAnswers = () => { + let currentAnswerTitles; + const { selectedFeedback, unselectedFeedback, ...editorContent } = fetchEditorContent({ format: 'text' }); + if (RichTextProblems.includes(problemType)) { + currentAnswerTitles = editorContent.answers; + } + answers.forEach(answer => { + const title = currentAnswerTitles?.[answer.id] || answer.title; + if (answer.correct) { + updateAnswer({ + ...answer, + title, + selectedFeedback, + unselectedFeedback, + correct: false, + }); + } else { + updateAnswer({ + ...answer, + selectedFeedback, + unselectedFeedback, + title, + }); + } + }); + }; + + const updateAnswersToCorrect = () => { + let currentAnswerTitles; + const { selectedFeedback, unselectedFeedback, ...editorContent } = fetchEditorContent({ format: 'text' }); + if (RichTextProblems.includes(problemType)) { + currentAnswerTitles = editorContent.answers; + } + answers.forEach(answer => { + const title = currentAnswerTitles ? currentAnswerTitles[answer.id] : answer.title; + updateAnswer({ + ...answer, + title, + selectedFeedback, + unselectedFeedback, + correct: true, + }); + }); + }; + + const convertToPlainText = () => { + const { selectedFeedback, unselectedFeedback, ...editorContent } = fetchEditorContent({ format: 'text' }); + const currentAnswerTitles = editorContent.answers; + answers.forEach(answer => { + updateAnswer({ + ...answer, + selectedFeedback, + unselectedFeedback, + title: currentAnswerTitles[answer.id], + }); + }); + }; + + const onClick = () => { + // Numeric, text, and dropdowns cannot render HTML as answer values, so if switching from a single select + // or multi-select problem the rich text needs to covert to plain text + if (typeKey === ProblemTypeKeys.TEXTINPUT && RichTextProblems.includes(problemType)) { + convertToPlainText(); + } + // Dropdown problems can only have one correct answer. When there is more than one correct answer + // from a previous problem type, the correct attribute for selected answers need to be set to false. + if (typeKey === ProblemTypeKeys.DROPDOWN) { + if (correctAnswerCount > 1) { + clearPreviouslySelectedAnswers(); + } else if (RichTextProblems.includes(problemType)) { + convertToPlainText(); + } + } + // Numeric input problems can only have correct answers. Switch all answers to correct when switching + // to numeric input. + if (typeKey === ProblemTypeKeys.NUMERIC) { + updateAnswersToCorrect(); + } + + if (blockTitle === ProblemTypes[problemType].title) { + setBlockTitle(ProblemTypes[typeKey].title); + } + updateField({ problemType: typeKey }); + }; + return { + onClick, + }; +}; + +export const confirmSwitchToAdvancedEditor = ({ + switchToAdvancedEditor, + setConfirmOpen, +}) => { + switchToAdvancedEditor(); + setConfirmOpen(false); + window.scrollTo({ + top: 0, + behavior: 'smooth', + }); +}; diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/hooks.test.js b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/hooks.test.js new file mode 100644 index 0000000000..2576025a99 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/hooks.test.js @@ -0,0 +1,397 @@ +import { useEffect } from 'react'; +import { MockUseState } from '../../../../../testUtils'; +import messages from './messages'; +import { keyStore } from '../../../../../utils'; +import * as hooks from './hooks'; +import { ProblemTypeKeys, ProblemTypes } from '../../../../../data/constants/problem'; +import * as editHooks from '../hooks'; + +jest.mock('react', () => { + const updateState = jest.fn(); + return { + updateState, + useEffect: jest.fn(), + useState: jest.fn(val => ([{ state: val }, (newVal) => updateState({ val, newVal })])), + }; +}); + +jest.mock('@edx/frontend-platform/i18n', () => ({ + defineMessages: m => m, +})); + +jest.mock('../../../../../data/redux', () => ({ + actions: { + problem: { + updateSettings: (args) => ({ updateSettings: args }), + updateField: (args) => ({ updateField: args }), + updateAnswer: (args) => ({ updateAnswer: args }), + }, + }, +})); + +const state = new MockUseState(hooks); +const moduleKeys = keyStore(editHooks); + +describe('Problem settings hooks', () => { + let output; + let updateSettings; + beforeEach(() => { + updateSettings = jest.fn(); + state.mock(); + }); + afterEach(() => { + state.restore(); + useEffect.mockClear(); + }); + describe('Show advanced settings', () => { + beforeEach(() => { + output = hooks.showAdvancedSettingsCards(); + }); + test('test default state is false', () => { + expect(output.isAdvancedCardsVisible).toBeFalsy(); + }); + test('test showAdvancedCards sets state to true', () => { + output.showAdvancedCards(); + expect(state.setState[state.keys.showAdvanced]).toHaveBeenCalledWith(true); + }); + }); + describe('Show full card', () => { + beforeEach(() => { + output = hooks.showFullCard(); + }); + test('test default state is false', () => { + expect(output.isCardCollapsibleOpen).toBeFalsy(); + }); + test('test toggleCardCollapse to true', () => { + output.toggleCardCollapse(); + expect(state.setState[state.keys.cardCollapsed]).toHaveBeenCalledWith(true); + }); + test('test toggleCardCollapse to true', () => { + output = hooks.showFullCard(true); + output.toggleCardCollapse(); + expect(state.setState[state.keys.cardCollapsed]).toHaveBeenCalledWith(true); + }); + }); + + describe('Hint card hooks', () => { + test('test useEffect triggers set hints summary no hint', () => { + const hints = []; + hooks.hintsCardHooks(hints, updateSettings); + expect(state.setState[state.keys.summary]).not.toHaveBeenCalled(); + const [cb, prereqs] = useEffect.mock.calls[0]; + expect(prereqs).toStrictEqual([[]]); + cb(); + expect(state.setState[state.keys.summary]) + .toHaveBeenCalledWith({ message: messages.noHintSummary, values: {} }); + }); + test('test useEffect triggers set hints summary', () => { + const hints = [{ id: 1, value: 'hint1' }]; + output = hooks.hintsCardHooks(hints, updateSettings); + expect(state.setState[state.keys.summary]).not.toHaveBeenCalled(); + const [cb, prereqs] = useEffect.mock.calls[0]; + expect(prereqs).toStrictEqual([[{ id: 1, value: 'hint1' }]]); + cb(); + expect(state.setState[state.keys.summary]) + .toHaveBeenCalledWith({ + message: messages.hintSummary, + values: { hint: hints[0].value, count: (hints.length - 1) }, + }); + }); + test('test handleAdd triggers updateSettings', () => { + const hint1 = { id: 1, value: 'hint1' }; + const hint2 = { id: 2, value: '' }; + const hints = [hint1]; + output = hooks.hintsCardHooks(hints, updateSettings); + output.handleAdd(); + expect(updateSettings).toHaveBeenCalledWith({ hints: [hint1, hint2] }); + }); + }); + describe('Hint rows hooks', () => { + const hint1 = { id: 1, value: 'hint1' }; + const hint2 = { id: 2, value: '' }; + const value = 'modifiedHint'; + const modifiedHint = { id: 2, value }; + const hints = [hint1, hint2]; + beforeEach(() => { + output = hooks.hintsRowHooks(2, hints, updateSettings); + }); + test('test handleChange', () => { + output.handleChange(value); + expect(updateSettings).toHaveBeenCalledWith({ hints: [hint1, modifiedHint] }); + }); + test('test handleDelete', () => { + output.handleDelete(); + expect(updateSettings).toHaveBeenCalledWith({ hints: [hint1] }); + }); + }); + + describe('Reset card hooks', () => { + beforeEach(() => { + output = hooks.resetCardHooks(updateSettings); + }); + test('test setResetTrue', () => { + output.setResetTrue(); + expect(updateSettings).toHaveBeenCalledWith({ showResetButton: true }); + }); + test('test setResetFalse', () => { + output.setResetFalse(); + expect(updateSettings).toHaveBeenCalledWith({ showResetButton: false }); + }); + }); + + describe('Scoring card hooks', () => { + const scoring = { + weight: 1.5, + attempts: { + unlimited: false, + number: 5, + }, + }; + const defaultValue = 1; + test('test scoringCardHooks initializes display value when attempts.number is null', () => { + const nilScoring = { ...scoring, attempts: { unlimited: false, number: null } }; + output = hooks.scoringCardHooks(nilScoring, updateSettings, defaultValue); + expect(state.stateVals[state.keys.attemptDisplayValue]).toEqual(`${defaultValue} (Default)`); + }); + test('test scoringCardHooks initializes display value when attempts.number is blank', () => { + const nilScoring = { ...scoring, attempts: { unlimited: false, number: '' } }; + output = hooks.scoringCardHooks(nilScoring, updateSettings, defaultValue); + expect(state.stateVals[state.keys.attemptDisplayValue]).toEqual(`${defaultValue} (Default)`); + }); + test('test scoringCardHooks initializes display value when attempts.number is not null', () => { + const nonNilScoring = { ...scoring, attempts: { unlimited: false, number: 2 } }; + output = hooks.scoringCardHooks(nonNilScoring, updateSettings, defaultValue); + expect(state.stateVals[state.keys.attemptDisplayValue]).toEqual(2); + }); + test('test scoringCardHooks initializes display value when attempts.number and defaultValue is null', () => { + const nonNilScoring = { ...scoring, attempts: { unlimited: false, number: null } }; + output = hooks.scoringCardHooks(nonNilScoring, updateSettings, null); + expect(state.stateVals[state.keys.attemptDisplayValue]).toEqual(''); + }); + beforeEach(() => { + output = hooks.scoringCardHooks(scoring, updateSettings, defaultValue); + }); + test('test handleUnlimitedChange sets attempts.unlimited to true when checked', () => { + output.handleUnlimitedChange({ target: { checked: true } }); + expect(state.setState[state.keys.attemptDisplayValue]).toHaveBeenCalledWith(''); + expect(updateSettings) + .toHaveBeenCalledWith({ scoring: { ...scoring, attempts: { number: null, unlimited: true } } }); + }); + test('test handleUnlimitedChange sets attempts.unlimited to false when unchecked', () => { + output.handleUnlimitedChange({ target: { checked: false } }); + expect(updateSettings) + .toHaveBeenCalledWith({ scoring: { ...scoring, attempts: { number: null, unlimited: false } } }); + }); + test('test handleMaxAttemptChange', () => { + const value = 6; + output.handleMaxAttemptChange({ target: { value } }); + expect(updateSettings) + .toHaveBeenCalledWith({ scoring: { ...scoring, attempts: { number: value, unlimited: false } } }); + }); + test('test handleMaxAttemptChange set attempts to zero', () => { + const value = 0; + output.handleMaxAttemptChange({ target: { value } }); + expect(updateSettings) + .toHaveBeenCalledWith({ scoring: { ...scoring, attempts: { number: value, unlimited: false } } }); + }); + test('test handleMaxAttemptChange set attempts to null value when default max_attempts is present', () => { + const value = null; + output.handleMaxAttemptChange({ target: { value } }); + expect(updateSettings) + .toHaveBeenCalledWith({ scoring: { ...scoring, attempts: { number: null, unlimited: false } } }); + }); + test('test handleMaxAttemptChange set attempts to null when default value is inputted', () => { + const value = '1 (Default)'; + output.handleMaxAttemptChange({ target: { value } }); + expect(updateSettings) + .toHaveBeenCalledWith({ scoring: { ...scoring, attempts: { number: null, unlimited: false } } }); + }); + test('test handleMaxAttemptChange set attempts to non-numeric value', () => { + const value = 'abc'; + output.handleMaxAttemptChange({ target: { value } }); + expect(updateSettings) + .toHaveBeenCalledWith({ scoring: { ...scoring, attempts: { number: null, unlimited: false } } }); + }); + test('test handleMaxAttemptChange set attempts to empty value', () => { + const value = ''; + output.handleMaxAttemptChange({ target: { value } }); + expect(state.setState[state.keys.attemptDisplayValue]).toHaveBeenCalledWith(`${defaultValue} (Default)`); + expect(updateSettings) + .toHaveBeenCalledWith({ scoring: { ...scoring, attempts: { number: null, unlimited: false } } }); + }); + test('test handleMaxAttemptChange set attempts to negative value', () => { + const value = -1; + output.handleMaxAttemptChange({ target: { value } }); + expect(updateSettings) + .toHaveBeenCalledWith({ scoring: { ...scoring, attempts: { number: 0, unlimited: false } } }); + }); + test('test handleMaxAttemptChange set attempts to empty value with no default', () => { + const value = ''; + output = hooks.scoringCardHooks(scoring, updateSettings, null); + output.handleMaxAttemptChange({ target: { value } }); + expect(state.setState[state.keys.attemptDisplayValue]).toHaveBeenCalledWith(''); + expect(updateSettings) + .toHaveBeenCalledWith({ scoring: { ...scoring, attempts: { number: null, unlimited: true } } }); + }); + test('test handleOnChange', () => { + const value = 6; + output.handleOnChange({ target: { value } }); + expect(state.setState[state.keys.attemptDisplayValue]).toHaveBeenCalledWith(value); + }); + test('test handleOnChange set attempts to zero', () => { + const value = 0; + output.handleOnChange({ target: { value } }); + expect(state.setState[state.keys.attemptDisplayValue]).toHaveBeenCalledWith(value); + }); + test('test handleOnChange set attempts to default value from empty string', () => { + const value = ''; + output.handleOnChange({ target: { value } }); + expect(state.setState[state.keys.attemptDisplayValue]).toHaveBeenCalledWith(''); + }); + test('test handleOnChange set attempts to default value', () => { + const value = 1; + output.handleOnChange({ target: { value } }); + expect(state.setState[state.keys.attemptDisplayValue]).toHaveBeenCalledWith('1 (Default)'); + }); + test('test handleOnChange set attempts to non-numeric value', () => { + const value = ''; + output.handleOnChange({ target: { value } }); + expect(state.setState[state.keys.attemptDisplayValue]).toHaveBeenCalledWith(value); + }); + test('test handleOnChange set attempts to negative value', () => { + const value = -1; + output.handleOnChange({ target: { value } }); + expect(state.setState[state.keys.attemptDisplayValue]).toHaveBeenCalledWith(0); + }); + test('test handleWeightChange', () => { + const value = 2; + output.handleWeightChange({ target: { value } }); + expect(updateSettings).toHaveBeenCalledWith({ scoring: { ...scoring, weight: parseFloat(value) } }); + }); + }); + + describe('Show answer card hooks', () => { + const showAnswer = { + on: 'after_attempts', + afterAttempts: 5, + }; + beforeEach(() => { + output = hooks.useAnswerSettings(showAnswer, updateSettings); + }); + test('test handleShowAnswerChange', () => { + const value = 'always'; + output.handleShowAnswerChange({ target: { value } }); + expect(updateSettings).toHaveBeenCalledWith({ showAnswer: { ...showAnswer, on: value } }); + }); + test('test handleAttemptsChange', () => { + const value = 3; + output.handleAttemptsChange({ target: { value } }); + expect(updateSettings).toHaveBeenCalledWith({ + showAnswer: { ...showAnswer, afterAttempts: parseInt(value, 10) }, + }); + }); + }); + + describe('Timer card hooks', () => { + test('test handleChange', () => { + output = hooks.timerCardHooks(updateSettings); + const value = 5; + output.handleChange({ target: { value } }); + expect(updateSettings).toHaveBeenCalledWith({ timeBetween: value }); + }); + }); + + describe('Type row hooks', () => { + const typeRowProps = { + problemType: ProblemTypeKeys.MULTISELECT, + typeKey: ProblemTypeKeys.DROPDOWN, + blockTitle: ProblemTypes[ProblemTypeKeys.MULTISELECT].title, + setBlockTitle: jest.fn(), + updateField: jest.fn(), + updateAnswer: jest.fn(), + correctAnswerCount: 2, + answers: [ + { correct: true, id: 'a', title: '

testA

' }, + { correct: true, id: 'b', title: '

testB

' }, + { correct: false, id: 'c', title: '

testC

' }, + ], + }; + const fetchEditorContent = () => ({ + answers: { + a: 'testA', + b: 'testB', + c: 'testC', + }, + }); + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(editHooks, moduleKeys.fetchEditorContent) + .mockImplementationOnce(fetchEditorContent); + }); + test('test onClick Multi-select to Dropdown', () => { + output = hooks.typeRowHooks(typeRowProps); + output.onClick(); + expect(typeRowProps.setBlockTitle).toHaveBeenCalledWith(ProblemTypes[ProblemTypeKeys.DROPDOWN].title); + expect(typeRowProps.updateAnswer).toHaveBeenNthCalledWith(1, { ...typeRowProps.answers[0], correct: false, title: 'testA' }); + expect(typeRowProps.updateAnswer).toHaveBeenNthCalledWith(2, { ...typeRowProps.answers[1], correct: false, title: 'testB' }); + expect(typeRowProps.updateAnswer).toHaveBeenNthCalledWith(3, { ...typeRowProps.answers[2], correct: false, title: 'testC' }); + expect(typeRowProps.updateField).toHaveBeenCalledWith({ problemType: ProblemTypeKeys.DROPDOWN }); + }); + + test('test onClick Multi-select to Dropdown with one correct answer', () => { + const oneAnswerTypeRowProps = { + ...typeRowProps, + correctAnswerCount: 1, + answers: [ + { correct: true, id: 'a', title: '

testA

' }, + { correct: false, id: 'b', title: '

testB

' }, + { correct: false, id: 'c', title: '

testC

' }, + ], + }; + output = hooks.typeRowHooks(oneAnswerTypeRowProps); + output.onClick(); + expect(typeRowProps.setBlockTitle).toHaveBeenCalledWith(ProblemTypes[ProblemTypeKeys.DROPDOWN].title); + expect(typeRowProps.updateAnswer).toHaveBeenNthCalledWith(1, { ...oneAnswerTypeRowProps.answers[0], title: 'testA' }); + expect(typeRowProps.updateAnswer).toHaveBeenNthCalledWith(2, { ...oneAnswerTypeRowProps.answers[1], title: 'testB' }); + expect(typeRowProps.updateAnswer).toHaveBeenNthCalledWith(3, { ...oneAnswerTypeRowProps.answers[2], title: 'testC' }); + expect(typeRowProps.updateField).toHaveBeenCalledWith({ problemType: ProblemTypeKeys.DROPDOWN }); + }); + test('test onClick Multi-select to Numeric', () => { + output = hooks.typeRowHooks({ + ...typeRowProps, + typeKey: ProblemTypeKeys.NUMERIC, + }); + output.onClick(); + expect(typeRowProps.setBlockTitle).toHaveBeenCalledWith(ProblemTypes[ProblemTypeKeys.NUMERIC].title); + expect(typeRowProps.updateAnswer).toHaveBeenNthCalledWith(1, { ...typeRowProps.answers[0], correct: true, title: 'testA' }); + expect(typeRowProps.updateAnswer).toHaveBeenNthCalledWith(2, { ...typeRowProps.answers[1], correct: true, title: 'testB' }); + expect(typeRowProps.updateAnswer).toHaveBeenNthCalledWith(3, { ...typeRowProps.answers[2], correct: true, title: 'testC' }); + expect(typeRowProps.updateField).toHaveBeenCalledWith({ problemType: ProblemTypeKeys.NUMERIC }); + }); + + test('test onClick Multi-select to Text Input', () => { + output = hooks.typeRowHooks({ + ...typeRowProps, + typeKey: ProblemTypeKeys.TEXTINPUT, + }); + output.onClick(); + expect(typeRowProps.setBlockTitle).toHaveBeenCalledWith(ProblemTypes[ProblemTypeKeys.TEXTINPUT].title); + expect(typeRowProps.updateAnswer).toHaveBeenNthCalledWith(1, { ...typeRowProps.answers[0], title: 'testA' }); + expect(typeRowProps.updateAnswer).toHaveBeenNthCalledWith(2, { ...typeRowProps.answers[1], title: 'testB' }); + expect(typeRowProps.updateAnswer).toHaveBeenNthCalledWith(3, { ...typeRowProps.answers[2], title: 'testC' }); + expect(typeRowProps.updateField).toHaveBeenCalledWith({ problemType: ProblemTypeKeys.TEXTINPUT }); + }); + }); + test('test confirmSwitchToAdvancedEditor hook', () => { + const switchToAdvancedEditor = jest.fn(); + const setConfirmOpen = jest.fn(); + window.scrollTo = jest.fn(); + hooks.confirmSwitchToAdvancedEditor({ + switchToAdvancedEditor, + setConfirmOpen, + }); + expect(switchToAdvancedEditor).toHaveBeenCalled(); + expect(setConfirmOpen).toHaveBeenCalledWith(false); + expect(window.scrollTo).toHaveBeenCalled(); + }); +}); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/index.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/index.jsx new file mode 100644 index 0000000000..6c858b7361 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/index.jsx @@ -0,0 +1,196 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { injectIntl, FormattedMessage } from '@edx/frontend-platform/i18n'; +import { connect } from 'react-redux'; +import { + Button, Collapsible, +} from '@openedx/paragon'; +import { selectors, actions } from '../../../../../data/redux'; +import ScoringCard from './settingsComponents/ScoringCard'; +import ShowAnswerCard from './settingsComponents/ShowAnswerCard'; +import HintsCard from './settingsComponents/HintsCard'; +import ResetCard from './settingsComponents/ResetCard'; +import TimerCard from './settingsComponents/TimerCard'; +import TypeCard from './settingsComponents/TypeCard'; +import ToleranceCard from './settingsComponents/Tolerance'; +import GroupFeedbackCard from './settingsComponents/GroupFeedback/index'; +import SwitchToAdvancedEditorCard from './settingsComponents/SwitchToAdvancedEditorCard'; +import messages from './messages'; +import { showAdvancedSettingsCards } from './hooks'; + +import './index.scss'; +import { ProblemTypeKeys } from '../../../../../data/constants/problem'; +import Randomization from './settingsComponents/Randomization'; + +// This widget should be connected, grab all settings from store, update them as needed. +const SettingsWidget = ({ + problemType, + // redux + answers, + groupFeedbackList, + blockTitle, + correctAnswerCount, + settings, + setBlockTitle, + updateSettings, + updateField, + updateAnswer, + defaultSettings, +}) => { + const { isAdvancedCardsVisible, showAdvancedCards } = showAdvancedSettingsCards(); + + const feedbackCard = () => { + if ([ProblemTypeKeys.MULTISELECT].includes(problemType)) { + return ( +
+
+ ); + } + // eslint-disable-next-line react/jsx-no-useless-fragment + return (<>); + }; + + return ( +
+
+ +
+ {ProblemTypeKeys.NUMERIC === problemType + && ( +
+ +
+ )} +
+ +
+
+ +
+ {feedbackCard()} +
+ + + + + +
+ + + +
+ +
+
+ +
+ { + problemType === ProblemTypeKeys.ADVANCED && ( +
+ +
+ ) + } +
+ +
+
+ +
+
+
+
+ ); +}; + +SettingsWidget.propTypes = { + answers: PropTypes.arrayOf(PropTypes.shape({ + correct: PropTypes.bool, + id: PropTypes.string, + selectedFeedback: PropTypes.string, + title: PropTypes.string, + unselectedFeedback: PropTypes.string, + })).isRequired, + groupFeedbackList: PropTypes.arrayOf( + PropTypes.shape( + { + id: PropTypes.number, + feedback: PropTypes.string, + answers: PropTypes.arrayOf(PropTypes.string), + }, + ), + ).isRequired, + blockTitle: PropTypes.string.isRequired, + correctAnswerCount: PropTypes.number.isRequired, + problemType: PropTypes.string.isRequired, + setBlockTitle: PropTypes.func.isRequired, + updateAnswer: PropTypes.func.isRequired, + updateField: PropTypes.func.isRequired, + updateSettings: PropTypes.func.isRequired, + defaultSettings: PropTypes.shape({ + maxAttempts: PropTypes.number, + showanswer: PropTypes.string, + showResetButton: PropTypes.bool, + rerandomize: PropTypes.string, + }).isRequired, + // eslint-disable-next-line + settings: PropTypes.any.isRequired, +}; + +const mapStateToProps = (state) => ({ + groupFeedbackList: selectors.problem.groupFeedbackList(state), + settings: selectors.problem.settings(state), + answers: selectors.problem.answers(state), + blockTitle: selectors.app.blockTitle(state), + correctAnswerCount: selectors.problem.correctAnswerCount(state), + defaultSettings: selectors.problem.defaultSettings(state), +}); + +export const mapDispatchToProps = { + setBlockTitle: actions.app.setBlockTitle, + updateSettings: actions.problem.updateSettings, + updateField: actions.problem.updateField, + updateAnswer: actions.problem.updateAnswer, +}; + +export const SettingsWidgetInternal = SettingsWidget; // For testing only +export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(SettingsWidget)); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/index.scss b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/index.scss new file mode 100644 index 0000000000..ad6d2705a5 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/index.scss @@ -0,0 +1,25 @@ +.settingsCardTitleSection { + padding-bottom: 0; +} + +.halfSpacedMessage { + padding-bottom: .5rem; +} + +.spacedMessage { + padding-bottom: 1.5rem; +} + +.settingsWidget { + margin-top: 40px; + + .pgn__form-text { + font-size: small; + } +} + +.resetCard { + .resetSettingsButtons { + width: 100%; + } +} diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/index.test.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/index.test.jsx new file mode 100644 index 0000000000..a432a5730d --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/index.test.jsx @@ -0,0 +1,97 @@ +import 'CourseAuthoring/editors/setupEditorTest'; +import React from 'react'; +import { shallow } from '@edx/react-unit-test-utils'; +import { showAdvancedSettingsCards } from './hooks'; +import { SettingsWidgetInternal as SettingsWidget, mapDispatchToProps } from '.'; +import { ProblemTypeKeys } from '../../../../../data/constants/problem'; +import { actions } from '../../../../../data/redux'; + +jest.mock('./hooks', () => ({ + showAdvancedSettingsCards: jest.fn(), +})); + +jest.mock('./settingsComponents/GeneralFeedback', () => 'GeneralFeedback'); +jest.mock('./settingsComponents/GroupFeedback', () => 'GroupFeedback'); +jest.mock('./settingsComponents/Randomization', () => 'Randomization'); +jest.mock('./settingsComponents/HintsCard', () => 'HintsCard'); +jest.mock('./settingsComponents/ResetCard', () => 'ResetCard'); +jest.mock('./settingsComponents/ScoringCard', () => 'ScoringCard'); +jest.mock('./settingsComponents/ShowAnswerCard', () => 'ShowAnswerCard'); +jest.mock('./settingsComponents/SwitchToAdvancedEditorCard', () => 'SwitchToAdvancedEditorCard'); +jest.mock('./settingsComponents/TimerCard', () => 'TimerCard'); +jest.mock('./settingsComponents/TypeCard', () => 'TypeCard'); + +describe('SettingsWidget', () => { + const props = { + problemType: ProblemTypeKeys.TEXTINPUT, + settings: {}, + defaultSettings: { + maxAttempts: 2, + showanswer: 'finished', + showResetButton: false, + }, + }; + + describe('behavior', () => { + it(' calls showAdvancedSettingsCards when initialized', () => { + const showAdvancedSettingsCardsProps = { + isAdvancedCardsVisible: false, + setResetTrue: jest.fn().mockName('showAdvancedSettingsCards.setResetTrue'), + }; + showAdvancedSettingsCards.mockReturnValue(showAdvancedSettingsCardsProps); + shallow(); + expect(showAdvancedSettingsCards).toHaveBeenCalledWith(); + }); + }); + + describe('snapshot', () => { + test('snapshot: renders Settings widget page', () => { + const showAdvancedSettingsCardsProps = { + isAdvancedCardsVisible: false, + setResetTrue: jest.fn().mockName('showAdvancedSettingsCards.setResetTrue'), + }; + showAdvancedSettingsCards.mockReturnValue(showAdvancedSettingsCardsProps); + expect(shallow().snapshot).toMatchSnapshot(); + }); + test('snapshot: renders Settings widget page advanced settings visible', () => { + const showAdvancedSettingsCardsProps = { + isAdvancedCardsVisible: true, + setResetTrue: jest.fn().mockName('showAdvancedSettingsCards.setResetTrue'), + }; + showAdvancedSettingsCards.mockReturnValue(showAdvancedSettingsCardsProps); + expect(shallow().snapshot).toMatchSnapshot(); + }); + test('snapshot: renders Settings widget for Advanced Problem with correct widgets', () => { + const showAdvancedSettingsCardsProps = { + isAdvancedCardsVisible: true, + setResetTrue: jest.fn().mockName('showAdvancedSettingsCards.setResetTrue'), + }; + showAdvancedSettingsCards.mockReturnValue(showAdvancedSettingsCardsProps); + expect(shallow().snapshot).toMatchSnapshot(); + }); + }); + + describe('mapDispatchToProps', () => { + test('setBlockTitle from actions.app.setBlockTitle', () => { + expect(mapDispatchToProps.setBlockTitle).toEqual(actions.app.setBlockTitle); + }); + }); + + describe('mapDispatchToProps', () => { + test('updateSettings from actions.problem.updateSettings', () => { + expect(mapDispatchToProps.updateSettings).toEqual(actions.problem.updateSettings); + }); + }); + + describe('mapDispatchToProps', () => { + test('updateField from actions.problem.updateField', () => { + expect(mapDispatchToProps.updateField).toEqual(actions.problem.updateField); + }); + }); + + describe('mapDispatchToProps', () => { + test('updateAnswer from actions.problem.updateAnswer', () => { + expect(mapDispatchToProps.updateAnswer).toEqual(actions.problem.updateAnswer); + }); + }); +}); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/messages.js b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/messages.js new file mode 100644 index 0000000000..26c5414a1c --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/messages.js @@ -0,0 +1,192 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + + settingsWidgetTitle: { + id: 'authoring.problemeditor.settings.settingsWidgetTitle', + defaultMessage: 'Settings', + description: 'Settings Title', + }, + showAdvanceSettingsButtonText: { + id: 'authoring.problemeditor.settings.showAdvancedButton', + defaultMessage: 'Show advanced settings', + description: 'Button text to show advanced settings', + }, + settingsDeleteIconAltText: { + id: 'authoring.problemeditor.settings.delete.icon.alt', + defaultMessage: 'Delete answer', + description: 'Alt text for delete icon', + }, + advancedSettingsLinkText: { + id: 'authoring.problemeditor.settings.advancedSettingLink.text', + defaultMessage: 'Set a default value in advanced settings', + description: 'Advanced settings link text', + }, + hintSettingTitle: { + id: 'authoring.problemeditor.settings.hint.title', + defaultMessage: 'Hints', + description: 'Hint settings card title', + }, + hintInputLabel: { + id: 'authoring.problemeditor.settings.hint.inputLabel', + defaultMessage: 'Hint', + description: 'Hint text input label', + }, + addHintButtonText: { + id: 'authoring.problemeditor.settings.hint.addHintButton', + defaultMessage: 'Add hint', + description: 'Add hint button text', + }, + noHintSummary: { + id: 'authoring.problemeditor.settings.hint.noHintSummary', + defaultMessage: 'None', + description: 'Summary text for no hints', + }, + hintSummary: { + id: 'authoring.problemeditor.settings.hint.summary', + defaultMessage: '{hint} {count, plural, =0 {} other {(+# more)}}', + description: 'Summary text for hint settings', + }, + resetSettingsTitle: { + id: 'authoring.problemeditor.settings.reset.title', + defaultMessage: 'Show reset option', + description: 'Reset settings card title', + }, + resetSettingsTrue: { + id: 'authoring.problemeditor.settings.reset.true', + defaultMessage: 'True', + description: 'True option for reset', + }, + resetSettingsFalse: { + id: 'authoring.problemeditor.settings.reset.false', + defaultMessage: 'False', + description: 'False option for reset', + }, + resetSettingText: { + id: 'authoring.problemeditor.settings.reset.text', + defaultMessage: "Determines whether a 'Reset' button is shown so the user may reset their answer, generally for use in practice or formative assessments.", + description: 'Reset settings card text', + }, + scoringSettingsTitle: { + id: 'authoring.problemeditor.settings.scoring.title', + defaultMessage: 'Scoring', + description: 'Scoring settings card title', + }, + scoringAttemptsInputLabel: { + id: 'authoring.problemeditor.settings.scoring.attempts.inputLabel', + defaultMessage: 'Attempts', + description: 'Scoring attempts text input label', + }, + scoringWeightInputLabel: { + id: 'authoring.problemeditor.settings.scoring.weight.inputLabel', + defaultMessage: 'Points', + description: 'Scoring weight input label', + }, + unlimitedAttemptsSummary: { + id: 'authoring.problemeditor.settings.scoring.unlimited', + defaultMessage: 'Unlimited attempts', + description: 'Summary text for unlimited attempts', + }, + attemptsSummary: { + id: 'authoring.problemeditor.settings.scoring.attempts', + defaultMessage: '{attempts, plural, =1 {# attempt} other {# attempts}}', + description: 'Summary text for number of attempts', + }, + unlimitedAttemptsCheckboxLabel: { + id: 'authoring.problemeditor.settings.scoring.attempts.unlimitedCheckbox', + defaultMessage: 'Unlimited attempts', + description: 'Label for unlimited attempts checkbox', + }, + weightSummary: { + id: 'authoring.problemeditor.settings.scoring.weight', + defaultMessage: '{weight, plural, =0 {Ungraded} other {# points}}', + description: 'Summary text for scoring weight', + }, + scoringSettingsLabel: { + id: 'authoring.problemeditor.settings.scoring.label', + defaultMessage: 'Specify point weight and the number of answer attempts', + description: 'Descriptive text for scoring settings', + }, + attemptsHint: { + id: 'authoring.problemeditor.settings.scoring.attempts.hint', + defaultMessage: 'If a default value is not set in advanced settings, unlimited attempts are allowed', + description: 'Summary text for scoring weight', + }, + weightHint: { + id: 'authoring.problemeditor.settings.scoring.weight.hint', + defaultMessage: 'If a value is not set, the problem is worth one point', + description: 'Summary text for scoring weight', + }, + showAnswerSettingsTitle: { + id: 'authoring.problemeditor.settings.showAnswer.title', + defaultMessage: 'Show answer', + description: 'Show Answer settings card title', + }, + showAnswerAttemptsInputLabel: { + id: 'authoring.problemeditor.settings.showAnswer.attempts.inputLabel', + defaultMessage: 'Number of Attempts', + description: 'Show Answer attempts text input label', + }, + showAnswerSettingText: { + id: 'authoring.problemeditor.settings.showAnswer.text', + defaultMessage: 'Define when learners can see the correct answer.', + description: 'Show Answer settings card text', + }, + timerSettingsTitle: { + id: 'authoring.problemeditor.settings.timer.title', + defaultMessage: 'Time between attempts', + description: 'Timer settings card title', + }, + timerSummary: { + id: 'authoring.problemeditor.settings.timer.summary', + defaultMessage: '{time} seconds', + description: 'Summary text for timer settings', + }, + timerSettingText: { + id: 'authoring.problemeditor.settings.timer.text', + defaultMessage: 'Seconds a student must wait between submissions for a problem with multiple attempts.', + description: 'Timer settings card text', + }, + timerInputLabel: { + id: 'authoring.problemeditor.settings.timer.inputLabel', + defaultMessage: 'Seconds', + description: 'Timer text input label', + }, + typeSettingTitle: { + id: 'authoring.problemeditor.settings.type.title', + defaultMessage: 'Type', + description: 'Type settings card title', + }, + SwitchButtonLabel: { + id: 'authoring.problemeditor.settings.switchtoadvancededitor.label', + defaultMessage: 'Switch to advanced editor', + description: 'button to switch to the advanced mode of the editor.', + }, + ConfirmSwitchMessage: { + id: 'authoring.problemeditor.settings.switchtoadvancededitor.ConfirmSwitchMessage', + defaultMessage: 'If you use the advanced editor, this problem will be converted to OLX and you will not be able to return to the simple editor.', + description: 'message to confirm that a user wants to use the advanced editor', + }, + ConfirmSwitchMessageTitle: { + id: 'authoring.problemeditor.settings.switchtoadvancededitor.ConfirmSwitchMessageTitle', + defaultMessage: 'Convert to OLX?', + description: 'message to confirm that a user wants to use the advanced editor', + }, + ConfirmSwitchButtonLabel: { + id: 'authoring.problemeditor.settings.switchtoadvancededitor.ConfirmSwitchButtonLabel', + defaultMessage: 'Switch to advanced editor', + description: 'message to confirm that a user wants to use the advanced editor', + }, + explanationInputLabel: { + id: 'authoring.problemeditor.settings.showAnswer.explanation.inputLabel', + defaultMessage: 'Explanation', + description: 'answer explanation input label', + }, + explanationSettingText: { + id: 'authoring.problemeditor.settings.showAnswer.explanation.text', + defaultMessage: 'Provide an explanation for the correct answer.', + description: 'Solution Explanation text', + }, +}); + +export default messages; diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/GeneralFeedback/__snapshots__/index.test.jsx.snap b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/GeneralFeedback/__snapshots__/index.test.jsx.snap new file mode 100644 index 0000000000..4912079ebd --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/GeneralFeedback/__snapshots__/index.test.jsx.snap @@ -0,0 +1,35 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`RandomizationCard snapshot snapshot: renders general feedback setting card 1`] = ` + +
+ + + +
+ + + +
+`; diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/GeneralFeedback/hooks.js b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/GeneralFeedback/hooks.js new file mode 100644 index 0000000000..c168392b37 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/GeneralFeedback/hooks.js @@ -0,0 +1,41 @@ +import { useState, useEffect } from 'react'; +import _ from 'lodash'; +import messages from './messages'; +// This 'module' self-import hack enables mocking during tests. +// See src/editors/decisions/0005-internal-editor-testability-decisions.md. The whole approach to how hooks are tested +// should be re-thought and cleaned up to avoid this pattern. +// eslint-disable-next-line import/no-self-import +import * as module from './hooks'; + +export const state = { + // eslint-disable-next-line react-hooks/rules-of-hooks + summary: (val) => useState(val), +}; + +export const generalFeedbackHooks = (generalFeedback, updateSettings) => { + const [summary, setSummary] = module.state.summary({ + message: messages.noGeneralFeedbackSummary, values: {}, intl: true, + }); + + // eslint-disable-next-line react-hooks/rules-of-hooks + useEffect(() => { + if (_.isEmpty(generalFeedback)) { + setSummary({ message: messages.noGeneralFeedbackSummary, values: {}, intl: true }); + } else { + setSummary({ + message: generalFeedback, + values: {}, + intl: false, + }); + } + }, [generalFeedback]); + + const handleChange = (event) => { + updateSettings({ generalFeedback: event.target.value }); + }; + + return { + summary, + handleChange, + }; +}; diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/GeneralFeedback/hooks.test.js b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/GeneralFeedback/hooks.test.js new file mode 100644 index 0000000000..5d5c98d528 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/GeneralFeedback/hooks.test.js @@ -0,0 +1,47 @@ +import { useEffect } from 'react'; +import { MockUseState } from '../../../../../../../testUtils'; +import messages from './messages'; +import * as hooks from './hooks'; + +jest.mock('react', () => { + const updateState = jest.fn(); + return { + updateState, + useEffect: jest.fn(), + useState: jest.fn(val => ([{ state: val }, (newVal) => updateState({ val, newVal })])), + }; +}); + +jest.mock('@edx/frontend-platform/i18n', () => ({ + defineMessages: m => m, +})); + +const state = new MockUseState(hooks); + +describe('Problem settings hooks', () => { + let output; + let updateSettings; + let generalFeedback; + beforeEach(() => { + updateSettings = jest.fn(); + generalFeedback = 'sOmE_vAlUe'; + state.mock(); + }); + afterEach(() => { + state.restore(); + useEffect.mockClear(); + }); + describe('Show advanced settings', () => { + beforeEach(() => { + output = hooks.generalFeedbackHooks(generalFeedback, updateSettings); + }); + test('test default state is false', () => { + expect(output.summary.message).toEqual(messages.noGeneralFeedbackSummary); + }); + test('test showAdvancedCards sets state to true', () => { + const mockEvent = { target: { value: 'sOmE_otheR_ValUe' } }; + output.handleChange(mockEvent); + expect(updateSettings).toHaveBeenCalledWith({ generalFeedback: mockEvent.target.value }); + }); + }); +}); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/GeneralFeedback/index.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/GeneralFeedback/index.jsx new file mode 100644 index 0000000000..f7cb81aca8 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/GeneralFeedback/index.jsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { injectIntl, FormattedMessage, intlShape } from '@edx/frontend-platform/i18n'; +import { Form } from '@openedx/paragon'; +import PropTypes from 'prop-types'; +import SettingsOption from '../../SettingsOption'; +import messages from './messages'; +import { generalFeedbackHooks } from './hooks'; + +export const GeneralFeedbackCard = ({ + generalFeedback, + updateSettings, + // inject + intl, +}) => { + const { summary, handleChange } = generalFeedbackHooks(generalFeedback, updateSettings); + return ( + +
+ + + +
+ + + +
+ ); +}; + +GeneralFeedbackCard.propTypes = { + generalFeedback: PropTypes.string.isRequired, + updateSettings: PropTypes.func.isRequired, + intl: intlShape.isRequired, +}; + +export default injectIntl(GeneralFeedbackCard); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/GeneralFeedback/index.test.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/GeneralFeedback/index.test.jsx new file mode 100644 index 0000000000..f24152c649 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/GeneralFeedback/index.test.jsx @@ -0,0 +1,38 @@ +import 'CourseAuthoring/editors/setupEditorTest'; +import React from 'react'; +import { shallow } from '@edx/react-unit-test-utils'; +import { formatMessage } from '../../../../../../../testUtils'; +import { GeneralFeedbackCard } from './index'; +import { generalFeedbackHooks } from './hooks'; + +jest.mock('./hooks', () => ({ + generalFeedbackHooks: jest.fn(), +})); + +describe('RandomizationCard', () => { + const props = { + generalFeedback: 'sOmE_vAlUE', + updateSettings: jest.fn().mockName('args.updateSettings'), + intl: { formatMessage }, + }; + + const randomizationCardHooksProps = { + summary: { message: { defaultMessage: 'sUmmary' } }, + handleChange: jest.fn().mockName('randomizationCardHooks.handleChange'), + }; + + generalFeedbackHooks.mockReturnValue(randomizationCardHooksProps); + + describe('behavior', () => { + it(' calls generalFeedbackHooks with props when initialized', () => { + shallow(); + expect(generalFeedbackHooks).toHaveBeenCalledWith(props.generalFeedback, props.updateSettings); + }); + }); + + describe('snapshot', () => { + test('snapshot: renders general feedback setting card', () => { + expect(shallow().snapshot).toMatchSnapshot(); + }); + }); +}); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/GeneralFeedback/messages.js b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/GeneralFeedback/messages.js new file mode 100644 index 0000000000..acec226278 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/GeneralFeedback/messages.js @@ -0,0 +1,26 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + + generalFeebackSettingTitle: { + id: 'authoring.problemeditor.settings.generalFeebackSettingTitle', + defaultMessage: 'General Feedback', + description: 'label for general feedback setting', + }, + generalFeedbackInputLabel: { + id: 'authoring.problemeditor.settings.generalFeedbackInputLabel', + defaultMessage: 'Enter General Feedback', + description: 'label for general feedback input describing rules', + }, + generalFeedbackDescription: { + id: 'authoring.problemeditor.settings.generalFeedbackInputDescription', + defaultMessage: 'Enter the feedback to appear when a student submits a wrong answer. This will be overridden if you add answer-specific feedback.', + description: 'description for general feedback input, clariying useage', + }, + noGeneralFeedbackSummary: { + id: 'authoring.problemeditor.settings.generalFeedback.noFeedbackSummary', + defaultMessage: 'None', + description: 'message which informs use there is no general feedback set.', + }, +}); +export default messages; diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/GroupFeedback/GroupFeedbackRow.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/GroupFeedback/GroupFeedbackRow.jsx new file mode 100644 index 0000000000..53e232cbc9 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/GroupFeedback/GroupFeedbackRow.jsx @@ -0,0 +1,80 @@ +import React from 'react'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { + ActionRow, Form, Icon, IconButton, Row, +} from '@openedx/paragon'; +import { DeleteOutline } from '@openedx/paragon/icons'; +import PropTypes from 'prop-types'; +import messages from '../../messages'; + +const GroupFeedbackRow = ({ + value, + handleAnswersSelectedChange, + handleFeedbackChange, + handleDelete, + answers, + // injected + intl, +}) => ( + +
+ + +
+ +
+
+ + + {answers.map((letter) => ( + = 0} + > +
+ {letter.id} +
+
+ ))} +
+
+
+ +); + +GroupFeedbackRow.propTypes = { + answers: PropTypes.arrayOf(PropTypes.shape({ + correct: PropTypes.bool, + id: PropTypes.string, + selectedFeedback: PropTypes.string, + title: PropTypes.string, + unselectedFeedback: PropTypes.string, + })).isRequired, + handleAnswersSelectedChange: PropTypes.func.isRequired, + handleFeedbackChange: PropTypes.func.isRequired, + handleDelete: PropTypes.func.isRequired, + value: PropTypes.shape({ + id: PropTypes.number.isRequired, + answers: PropTypes.arrayOf(PropTypes.string), + feedback: PropTypes.string, + }).isRequired, + // injected + intl: intlShape.isRequired, +}; + +export const GroupFeedbackRowInternal = GroupFeedbackRow; // For testing only +export default injectIntl(GroupFeedbackRow); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/GroupFeedback/GroupFeedbackRow.test.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/GroupFeedback/GroupFeedbackRow.test.jsx new file mode 100644 index 0000000000..93be2e2664 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/GroupFeedback/GroupFeedbackRow.test.jsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { shallow } from '@edx/react-unit-test-utils'; +import { formatMessage } from '../../../../../../../testUtils'; +import { GroupFeedbackRowInternal as GroupFeedbackRow } from './GroupFeedbackRow'; + +jest.mock('@openedx/paragon', () => ({ + ...jest.requireActual('@openedx/paragon'), + Row: 'Row', + IconButton: 'IconButton', + Icon: 'Icon', + Form: { + CheckboxSet: 'Form.CheckboxSet', + Checkbox: 'Form.CheckboxSet', + Control: 'Form.Control', + }, + ActionRow: 'ActionRow', +})); +jest.mock('@openedx/paragon/icons', () => ({ + ...jest.requireActual('@openedx/paragon/icons'), + DeleteOutline: 'DeleteOutline', +})); + +describe('GroupFeedbackRow', () => { + const props = { + value: { answers: ['A', 'C'], feedback: 'sOmE FeEDBACK' }, + answers: ['A', 'B', 'C', 'D'], + handleAnswersSelectedChange: jest.fn().mockName('handleAnswersSelectedChange'), + handleFeedbackChange: jest.fn().mockName('handleFeedbackChange'), + handleDelete: jest.fn().mockName('handleDelete'), + intl: { formatMessage }, + }; + + describe('snapshot', () => { + test('snapshot: renders hints row', () => { + expect(shallow().snapshot).toMatchSnapshot(); + }); + }); +}); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/GroupFeedback/__snapshots__/GroupFeedbackRow.test.jsx.snap b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/GroupFeedback/__snapshots__/GroupFeedbackRow.test.jsx.snap new file mode 100644 index 0000000000..21c50f9825 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/GroupFeedback/__snapshots__/GroupFeedbackRow.test.jsx.snap @@ -0,0 +1,77 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`GroupFeedbackRow snapshot snapshot: renders hints row 1`] = ` +
+ + +
+ +
+
+ + + +
+ + +
+ + +
+ + +
+ + + +
+`; diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/GroupFeedback/__snapshots__/index.test.jsx.snap b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/GroupFeedback/__snapshots__/index.test.jsx.snap new file mode 100644 index 0000000000..7ba7675040 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/GroupFeedback/__snapshots__/index.test.jsx.snap @@ -0,0 +1,171 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`HintsCard snapshot snapshot: renders groupFeedbacks setting card multiple groupFeedbacks 1`] = ` + +
+ +
+ + + +
+`; + +exports[`HintsCard snapshot snapshot: renders groupFeedbacks setting card no groupFeedbacks 1`] = ` + +
+ +
+ +
+`; + +exports[`HintsCard snapshot snapshot: renders groupFeedbacks setting card one groupFeedback 1`] = ` + +
+ +
+ + +
+`; diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/GroupFeedback/hooks.js b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/GroupFeedback/hooks.js new file mode 100644 index 0000000000..4dbe89c00c --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/GroupFeedback/hooks.js @@ -0,0 +1,101 @@ +import { useState, useEffect } from 'react'; +import { isEmpty } from 'lodash'; +import messages from './messages'; +// This 'module' self-import hack enables mocking during tests. +// See src/editors/decisions/0005-internal-editor-testability-decisions.md. The whole approach to how hooks are tested +// should be re-thought and cleaned up to avoid this pattern. +// eslint-disable-next-line import/no-self-import +import * as module from './hooks'; + +export const state = { + // eslint-disable-next-line react-hooks/rules-of-hooks + summary: (val) => useState(val), +}; + +export const groupFeedbackCardHooks = (groupFeedbacks, updateSettings, answerslist) => { + const [summary, setSummary] = module.state.summary({ message: messages.noGroupFeedbackSummary, values: {} }); + + // eslint-disable-next-line react-hooks/rules-of-hooks + useEffect(() => { + if (groupFeedbacks.length === 0) { + setSummary({ message: messages.noGroupFeedbackSummary, values: {} }); + } else { + const feedbacksInList = groupFeedbacks.map(({ answers, feedback }) => { + const answerIDs = answerslist.map((a) => a.id); + const answersString = answers.filter((value) => answerIDs.includes(value)); + return `${answersString} ${feedback}\n`; + }); + setSummary({ + message: messages.groupFeedbackSummary, + values: { groupFeedback: feedbacksInList }, + }); + } + }, [groupFeedbacks, answerslist]); + + const handleAdd = () => { + let newId = 0; + if (!isEmpty(groupFeedbacks)) { + newId = Math.max(...groupFeedbacks.map(feedback => feedback.id)) + 1; + } + const groupFeedback = { id: newId, answers: [], feedback: '' }; + const modifiedGroupFeedbacks = [...groupFeedbacks, groupFeedback]; + updateSettings({ groupFeedbackList: modifiedGroupFeedbacks }); + }; + + return { + summary, + handleAdd, + }; +}; + +export const groupFeedbackRowHooks = ({ id, groupFeedbacks, updateSettings }) => { + // Hooks for the answers associated with a groupfeedback + const addSelectedAnswer = ({ value }) => { + const oldGroupFeedback = groupFeedbacks.find(x => x.id === id); + const newAnswers = [...oldGroupFeedback.answers, value]; + const newFeedback = { ...oldGroupFeedback, answers: newAnswers }; + const remainingFeedbacks = groupFeedbacks.filter((item) => (item.id !== id)); + const updatedFeedbackList = [newFeedback, ...remainingFeedbacks].sort((a, b) => a.id - b.id); + + updateSettings({ groupFeedbackList: updatedFeedbackList }); + }; + const removedSelectedAnswer = ({ value }) => { + const oldGroupFeedback = groupFeedbacks.find(x => x.id === id); + const newAnswers = oldGroupFeedback.answers.filter(item => item !== value); + const newFeedback = { ...oldGroupFeedback, answers: newAnswers }; + const remainingFeedbacks = groupFeedbacks.filter((item) => (item.id !== id)); + const updatedFeedbackList = [newFeedback, ...remainingFeedbacks].sort((a, b) => a.id - b.id); + + updateSettings({ groupFeedbackList: updatedFeedbackList }); + }; + const handleAnswersSelectedChange = (event) => { + const { checked, value } = event.target; + if (checked) { + addSelectedAnswer({ value }); + } else { + removedSelectedAnswer({ value }); + } + }; + + // Delete Button + const handleDelete = () => { + const modifiedGroupFeedbacks = groupFeedbacks.filter((item) => (item.id !== id)); + updateSettings({ groupFeedbackList: modifiedGroupFeedbacks }); + }; + + // Hooks for the feedback associated with a groupfeedback + const handleFeedbackChange = (event) => { + const { value } = event.target; + const modifiedGroupFeedback = groupFeedbacks.map(groupFeedback => { + if (groupFeedback.id === id) { + return { ...groupFeedback, feedback: value }; + } + return groupFeedback; + }); + updateSettings({ groupFeedbackList: modifiedGroupFeedback }); + }; + + return { + handleAnswersSelectedChange, handleFeedbackChange, handleDelete, + }; +}; diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/GroupFeedback/hooks.test.js b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/GroupFeedback/hooks.test.js new file mode 100644 index 0000000000..14adde6d3b --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/GroupFeedback/hooks.test.js @@ -0,0 +1,96 @@ +import { useEffect } from 'react'; +import { MockUseState } from '../../../../../../../testUtils'; +import messages from './messages'; +import * as hooks from './hooks'; + +jest.mock('react', () => { + const updateState = jest.fn(); + return { + updateState, + useEffect: jest.fn(), + useState: jest.fn(val => ([{ state: val }, (newVal) => updateState({ val, newVal })])), + }; +}); + +jest.mock('@edx/frontend-platform/i18n', () => ({ + defineMessages: m => m, +})); + +const state = new MockUseState(hooks); + +describe('groupFeedbackCardHooks', () => { + let output; + let updateSettings; + let groupFeedbacks; + beforeEach(() => { + updateSettings = jest.fn(); + groupFeedbacks = []; + state.mock(); + }); + afterEach(() => { + state.restore(); + useEffect.mockClear(); + }); + describe('Show advanced settings', () => { + beforeEach(() => { + output = hooks.groupFeedbackCardHooks(groupFeedbacks, updateSettings); + }); + test('test default state is false', () => { + expect(output.summary.message).toEqual(messages.noGroupFeedbackSummary); + }); + test('test Event adds a new feedback ', () => { + output.handleAdd(); + expect(updateSettings).toHaveBeenCalledWith({ groupFeedbackList: [{ id: 0, answers: [], feedback: '' }] }); + }); + }); +}); + +describe('groupFeedbackRowHooks', () => { + const mockId = 'iD'; + const mockAnswer = 'moCkAnsweR'; + const mockFeedback = 'mOckFEEdback'; + let groupFeedbacks; + let output; + let updateSettings; + + beforeEach(() => { + updateSettings = jest.fn(); + groupFeedbacks = [{ id: mockId, answers: [mockAnswer], feedback: mockFeedback }]; + state.mock(); + }); + afterEach(() => { + state.restore(); + useEffect.mockClear(); + }); + describe('Show advanced settings', () => { + beforeEach(() => { + output = hooks.groupFeedbackRowHooks({ id: mockId, groupFeedbacks, updateSettings }); + }); + test('test associate an answer with the feedback object', () => { + const mockNewAnswer = 'nEw VAluE'; + output.handleAnswersSelectedChange({ target: { checked: true, value: mockNewAnswer } }); + expect(updateSettings).toHaveBeenCalledWith( + { groupFeedbackList: [{ id: mockId, answers: [mockAnswer, mockNewAnswer], feedback: mockFeedback }] }, + ); + }); + test('test unassociate an answer with the feedback object', () => { + output.handleAnswersSelectedChange({ target: { checked: false, value: mockAnswer } }); + expect(updateSettings).toHaveBeenCalledWith( + { groupFeedbackList: [{ id: mockId, answers: [], feedback: mockFeedback }] }, + ); + }); + test('test update feedback text with a groupfeedback', () => { + const mockNewFeedback = 'nEw fEedBack'; + output.handleFeedbackChange({ target: { checked: false, value: mockNewFeedback } }); + expect(updateSettings).toHaveBeenCalledWith( + { groupFeedbackList: [{ id: mockId, answers: [mockAnswer], feedback: mockNewFeedback }] }, + ); + }); + test('Delete a Row from the list of feedbacks', () => { + output.handleDelete(); + expect(updateSettings).toHaveBeenCalledWith( + { groupFeedbackList: [] }, + ); + }); + }); +}); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/GroupFeedback/index.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/GroupFeedback/index.jsx new file mode 100644 index 0000000000..531be7bad4 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/GroupFeedback/index.jsx @@ -0,0 +1,67 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { injectIntl, FormattedMessage, intlShape } from '@edx/frontend-platform/i18n'; +import SettingsOption from '../../SettingsOption'; +import messages from './messages'; +import { groupFeedbackCardHooks, groupFeedbackRowHooks } from './hooks'; +import GroupFeedbackRow from './GroupFeedbackRow'; +import Button from '../../../../../../../sharedComponents/Button'; + +const GroupFeedbackCard = ({ + groupFeedbacks, + updateSettings, + answers, + // inject + intl, +}) => { + const { summary, handleAdd } = groupFeedbackCardHooks(groupFeedbacks, updateSettings, answers); + return ( + +
+ +
+ {groupFeedbacks.map((groupFeedback) => ( + + ))} + +
+ ); +}; + +GroupFeedbackCard.propTypes = { + intl: intlShape.isRequired, + groupFeedbacks: PropTypes.arrayOf(PropTypes.shape({ + id: PropTypes.number.isRequired, + feedback: PropTypes.string.isRequired, + answers: PropTypes.arrayOf(PropTypes.string).isRequired, + })).isRequired, + answers: PropTypes.arrayOf(PropTypes.shape({ + correct: PropTypes.bool, + id: PropTypes.string, + selectedFeedback: PropTypes.string, + title: PropTypes.string, + unselectedFeedback: PropTypes.string, + })).isRequired, + updateSettings: PropTypes.func.isRequired, +}; + +export const GroupFeedbackCardInternal = GroupFeedbackCard; // For testing only +export default injectIntl(GroupFeedbackCard); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/GroupFeedback/index.test.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/GroupFeedback/index.test.jsx new file mode 100644 index 0000000000..2c52195242 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/GroupFeedback/index.test.jsx @@ -0,0 +1,83 @@ +import 'CourseAuthoring/editors/setupEditorTest'; +import React from 'react'; +import { shallow } from '@edx/react-unit-test-utils'; +import { formatMessage } from '../../../../../../../testUtils'; +import { GroupFeedbackCardInternal as GroupFeedbackCard } from './index'; +import { groupFeedbackRowHooks, groupFeedbackCardHooks } from './hooks'; +import messages from './messages'; + +jest.mock('./hooks', () => ({ + groupFeedbackCardHooks: jest.fn(), + groupFeedbackRowHooks: jest.fn(), +})); + +describe('HintsCard', () => { + const answers = ['A', 'B', 'C']; + const groupFeedback1 = { + id: 1, value: 'groupFeedback1', answers: ['A', 'C'], feedback: 'sOmE FeEDBACK', + }; + const groupFeedback2 = { + id: 2, value: '', answers: ['A'], feedback: 'sOmE FeEDBACK oTher FeEdback', + }; + const groupFeedbacks0 = []; + const groupFeedbacks1 = [groupFeedback1]; + const groupFeedbacks2 = [groupFeedback1, groupFeedback2]; + const props = { + intl: { formatMessage }, + groupFeedbacks: groupFeedbacks0, + updateSettings: jest.fn().mockName('args.updateSettings'), + answers, + }; + + const groupFeedbacksRowHooksProps = { props: 'propsValue' }; + groupFeedbackRowHooks.mockReturnValue(groupFeedbacksRowHooksProps); + + describe('behavior', () => { + it(' calls groupFeedbacksCardHooks when initialized', () => { + const groupFeedbacksCardHooksProps = { + summary: { message: messages.noGroupFeedbackSummary }, + handleAdd: jest.fn().mockName('groupFeedbacksCardHooks.handleAdd'), + }; + + groupFeedbackCardHooks.mockReturnValue(groupFeedbacksCardHooksProps); + shallow(); + expect(groupFeedbackCardHooks).toHaveBeenCalledWith(groupFeedbacks0, props.updateSettings, answers); + }); + }); + + describe('snapshot', () => { + test('snapshot: renders groupFeedbacks setting card no groupFeedbacks', () => { + const groupFeedbacksCardHooksProps = { + summary: { message: messages.noGroupFeedbackSummary, values: {} }, + handleAdd: jest.fn().mockName('groupFeedbacksCardHooks.handleAdd'), + }; + + groupFeedbackCardHooks.mockReturnValue(groupFeedbacksCardHooksProps); + expect(shallow().snapshot).toMatchSnapshot(); + }); + test('snapshot: renders groupFeedbacks setting card one groupFeedback', () => { + const groupFeedbacksCardHooksProps = { + summary: { + message: messages.groupFeedbackSummary, + values: { groupFeedback: groupFeedback1.value, count: 1 }, + }, + handleAdd: jest.fn().mockName('groupFeedbacksCardHooks.handleAdd'), + }; + + groupFeedbackCardHooks.mockReturnValue(groupFeedbacksCardHooksProps); + expect(shallow().snapshot).toMatchSnapshot(); + }); + test('snapshot: renders groupFeedbacks setting card multiple groupFeedbacks', () => { + const groupFeedbacksCardHooksProps = { + summary: { + message: messages.groupFeedbackSummary, + values: { groupFeedback: groupFeedback2.value, count: 2 }, + }, + handleAdd: jest.fn().mockName('groupFeedbacksCardHooks.handleAdd'), + }; + + groupFeedbackCardHooks.mockReturnValue(groupFeedbacksCardHooksProps); + expect(shallow().snapshot).toMatchSnapshot(); + }); + }); +}); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/GroupFeedback/messages.js b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/GroupFeedback/messages.js new file mode 100644 index 0000000000..de4a5c6feb --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/GroupFeedback/messages.js @@ -0,0 +1,32 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + + noGroupFeedbackSummary: { + id: 'authoring.problemeditor.settings.GroupFeedbackSummary.nonMessage', + defaultMessage: 'None', + description: 'message to confirm that a user wants to use the advanced editor', + }, + groupFeedbackSummary: { + id: 'authoring.problemeditor.settings.GroupFeedbackSummary.message', + defaultMessage: '{groupFeedback}', + description: 'summary of current feedbacks provided for multiple problems', + }, + addGroupFeedbackButtonText: { + id: 'authoring.problemeditor.settings.addGroupFeedbackButtonText', + defaultMessage: 'Add group feedback', + description: 'addGroupFeedbackButtonText', + }, + groupFeedbackInputLabel: { + id: 'authoring.problemeditor.settings.GroupFeedbackInputLabel', + defaultMessage: 'Group feedback will appear when a student selects a specific set of answers.', + description: 'label for group feedback input', + }, + groupFeedbackSettingTitle: { + id: 'authoring.problemeditor.settings.GroupFeedbackSettingTitle', + defaultMessage: 'Group Feedback', + description: 'label for group feedback setting', + }, +}); + +export default messages; diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/HintRow.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/HintRow.jsx new file mode 100644 index 0000000000..55817d51a1 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/HintRow.jsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { + ActionRow, + Container, + Icon, + IconButton, +} from '@openedx/paragon'; +import { DeleteOutline } from '@openedx/paragon/icons'; +import PropTypes from 'prop-types'; +import messages from '../messages'; +import ExpandableTextArea from '../../../../../../sharedComponents/ExpandableTextArea'; + +const HintRow = ({ + value, + handleChange, + handleDelete, + id, + // injected + intl, +}) => ( + + + + +
+ +
+
+); + +HintRow.propTypes = { + value: PropTypes.string.isRequired, + handleChange: PropTypes.func.isRequired, + handleDelete: PropTypes.func.isRequired, + id: PropTypes.string.isRequired, + // injected + intl: intlShape.isRequired, +}; + +export const HintRowInternal = HintRow; // For testing only +export default injectIntl(HintRow); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/HintRow.test.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/HintRow.test.jsx new file mode 100644 index 0000000000..3d7673ec9f --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/HintRow.test.jsx @@ -0,0 +1,21 @@ +import 'CourseAuthoring/editors/setupEditorTest'; +import React from 'react'; +import { shallow } from '@edx/react-unit-test-utils'; +import { formatMessage } from '../../../../../../testUtils'; +import { HintRowInternal as HintRow } from './HintRow'; + +describe('HintRow', () => { + const props = { + value: 'hint_1', + handleChange: jest.fn(), + handleDelete: jest.fn(), + id: '0', + intl: { formatMessage }, + }; + + describe('snapshot', () => { + test('snapshot: renders hints row', () => { + expect(shallow().snapshot).toMatchSnapshot(); + }); + }); +}); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/HintsCard.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/HintsCard.jsx new file mode 100644 index 0000000000..cdb399d91f --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/HintsCard.jsx @@ -0,0 +1,60 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { injectIntl, FormattedMessage, intlShape } from '@edx/frontend-platform/i18n'; +import SettingsOption from '../SettingsOption'; +import { ProblemTypeKeys } from '../../../../../../data/constants/problem'; +import messages from '../messages'; +import { hintsCardHooks, hintsRowHooks } from '../hooks'; +import HintRow from './HintRow'; +import Button from '../../../../../../sharedComponents/Button'; + +const HintsCard = ({ + hints, + problemType, + updateSettings, + // inject + intl, +}) => { + const { summary, handleAdd } = hintsCardHooks(hints, updateSettings); + + if (problemType === ProblemTypeKeys.ADVANCED) { return null; } + + return ( + + {hints.map((hint) => ( + + ))} + + + ); +}; + +HintsCard.propTypes = { + intl: intlShape.isRequired, + hints: PropTypes.arrayOf(PropTypes.shape({ + id: PropTypes.string.isRequired, + value: PropTypes.string.isRequired, + })).isRequired, + problemType: PropTypes.string.isRequired, + updateSettings: PropTypes.func.isRequired, +}; + +export const HintsCardInternal = HintsCard; // For testing only +export default injectIntl(HintsCard); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/HintsCard.test.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/HintsCard.test.jsx new file mode 100644 index 0000000000..3105445b6b --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/HintsCard.test.jsx @@ -0,0 +1,80 @@ +import 'CourseAuthoring/editors/setupEditorTest'; +import React from 'react'; +import { shallow } from '@edx/react-unit-test-utils'; +import { formatMessage } from '../../../../../../testUtils'; +import { HintsCardInternal as HintsCard } from './HintsCard'; +import { hintsCardHooks, hintsRowHooks } from '../hooks'; +import messages from '../messages'; + +jest.mock('../hooks', () => ({ + hintsCardHooks: jest.fn(), + hintsRowHooks: jest.fn(), +})); + +describe('HintsCard', () => { + const hint1 = { id: 1, value: 'hint1' }; + const hint2 = { id: 2, value: '' }; + const hints0 = []; + const hints1 = [hint1]; + const hints2 = [hint1, hint2]; + const props = { + intl: { formatMessage }, + hints: hints0, + updateSettings: jest.fn().mockName('args.updateSettings'), + }; + + const hintsRowHooksProps = { + handleChange: jest.fn().mockName('hintsRowHooks.handleChange'), + handleDelete: jest.fn().mockName('hintsRowHooks.handleDelete'), + }; + hintsRowHooks.mockReturnValue(hintsRowHooksProps); + + describe('behavior', () => { + it(' calls hintsCardHooks when initialized', () => { + const hintsCardHooksProps = { + summary: { message: messages.noHintSummary, values: {} }, + handleAdd: jest.fn().mockName('hintsCardHooks.handleAdd'), + }; + + hintsCardHooks.mockReturnValue(hintsCardHooksProps); + shallow(); + expect(hintsCardHooks).toHaveBeenCalledWith(hints0, props.updateSettings); + }); + }); + + describe('snapshot', () => { + test('snapshot: renders hints setting card no hints', () => { + const hintsCardHooksProps = { + summary: { message: messages.noHintSummary, values: {} }, + handleAdd: jest.fn().mockName('hintsCardHooks.handleAdd'), + }; + + hintsCardHooks.mockReturnValue(hintsCardHooksProps); + expect(shallow().snapshot).toMatchSnapshot(); + }); + test('snapshot: renders hints setting card one hint', () => { + const hintsCardHooksProps = { + summary: { + message: messages.hintSummary, + values: { hint: hint1.value, count: 1 }, + }, + handleAdd: jest.fn().mockName('hintsCardHooks.handleAdd'), + }; + + hintsCardHooks.mockReturnValue(hintsCardHooksProps); + expect(shallow().snapshot).toMatchSnapshot(); + }); + test('snapshot: renders hints setting card multiple hints', () => { + const hintsCardHooksProps = { + summary: { + message: messages.hintSummary, + values: { hint: hint2.value, count: 2 }, + }, + handleAdd: jest.fn().mockName('hintsCardHooks.handleAdd'), + }; + + hintsCardHooks.mockReturnValue(hintsCardHooksProps); + expect(shallow().snapshot).toMatchSnapshot(); + }); + }); +}); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/Randomization/__snapshots__/index.test.jsx.snap b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/Randomization/__snapshots__/index.test.jsx.snap new file mode 100644 index 0000000000..08c3a66986 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/Randomization/__snapshots__/index.test.jsx.snap @@ -0,0 +1,157 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`RandomizationCard snapshot snapshot: renders randomization setting card with default randomization 1`] = ` + +
+ {randomization, select, + null {No Python based randomization is present in this problem.} + other {Defines when to randomize the variables specified in the associated Python script. For problems that do not randomize values, specify "Never".} + } +
+ + + + + + + + +
+`; + +exports[`RandomizationCard snapshot snapshot: renders randomization setting card with randomization defined 1`] = ` + +
+ {randomization, select, + null {No Python based randomization is present in this problem.} + other {Defines when to randomize the variables specified in the associated Python script. For problems that do not randomize values, specify "Never".} + } +
+ + + + + + + + +
+`; + +exports[`RandomizationCard snapshot snapshot: renders randomization setting card with randomization null 1`] = ` + +
+ {randomization, select, + null {No Python based randomization is present in this problem.} + other {Defines when to randomize the variables specified in the associated Python script. For problems that do not randomize values, specify "Never".} + } +
+ + + + + + + + +
+`; diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/Randomization/hooks.js b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/Randomization/hooks.js new file mode 100644 index 0000000000..888488895c --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/Randomization/hooks.js @@ -0,0 +1,31 @@ +import { useState, useEffect } from 'react'; +import { RandomizationTypes, RandomizationTypesKeys } from '../../../../../../../data/constants/problem'; +// This 'module' self-import hack enables mocking during tests. +// See src/editors/decisions/0005-internal-editor-testability-decisions.md. The whole approach to how hooks are tested +// should be re-thought and cleaned up to avoid this pattern. +// eslint-disable-next-line import/no-self-import +import * as module from './hooks'; + +export const state = { + // eslint-disable-next-line react-hooks/rules-of-hooks + summary: (val) => useState(val), +}; + +export const useRandomizationSettingStatus = ({ randomization, updateSettings }) => { + const [summary, setSummary] = module.state.summary({ + message: RandomizationTypes[RandomizationTypesKeys.NEVER], + values: {}, + }); + useEffect(() => { + setSummary({ + message: randomization ? RandomizationTypes[randomization] : RandomizationTypes[RandomizationTypesKeys.NEVER], + }); + }, [randomization]); + + const handleChange = (event) => { + updateSettings({ randomization: event.target.value }); + }; + return { summary, handleChange }; +}; + +export default useRandomizationSettingStatus; diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/Randomization/hooks.test.js b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/Randomization/hooks.test.js new file mode 100644 index 0000000000..dc4cf2cb1c --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/Randomization/hooks.test.js @@ -0,0 +1,47 @@ +import { useEffect } from 'react'; +import { MockUseState } from '../../../../../../../testUtils'; +import * as hooks from './hooks'; +import { RandomizationTypes, RandomizationTypesKeys } from '../../../../../../../data/constants/problem'; + +jest.mock('react', () => { + const updateState = jest.fn(); + return { + updateState, + useEffect: jest.fn(), + useState: jest.fn(val => ([{ state: val }, (newVal) => updateState({ val, newVal })])), + }; +}); + +jest.mock('@edx/frontend-platform/i18n', () => ({ + defineMessages: m => m, +})); + +const state = new MockUseState(hooks); + +describe('Problem settings hooks', () => { + let output; + let updateSettings; + let randomization; + beforeEach(() => { + updateSettings = jest.fn(); + randomization = 'sOmE_vAlUe'; + state.mock(); + }); + afterEach(() => { + state.restore(); + useEffect.mockClear(); + }); + describe('Show advanced settings', () => { + beforeEach(() => { + output = hooks.useRandomizationSettingStatus({ randomization, updateSettings }); + }); + test('test default state is false', () => { + expect(output.summary).toEqual({ message: RandomizationTypes[RandomizationTypesKeys.NEVER], values: {} }); + }); + test('test showAdvancedCards sets state to true', () => { + const mockEvent = { target: { value: 'sOmE_otheR_ValUe' } }; + output.handleChange(mockEvent); + expect(updateSettings).toHaveBeenCalledWith({ randomization: mockEvent.target.value }); + }); + }); +}); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/Randomization/index.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/Randomization/index.jsx new file mode 100644 index 0000000000..0b76264217 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/Randomization/index.jsx @@ -0,0 +1,62 @@ +import React from 'react'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { Form } from '@openedx/paragon'; +import PropTypes from 'prop-types'; +import SettingsOption from '../../SettingsOption'; +import messages from './messages'; +import { useRandomizationSettingStatus } from './hooks'; +import { RandomizationTypesKeys, RandomizationTypes } from '../../../../../../../data/constants/problem'; + +export const RandomizationCard = ({ + randomization, + defaultValue, + updateSettings, + // inject + intl, +}) => { + const curretRandomization = randomization || defaultValue; + const { summary, handleChange } = useRandomizationSettingStatus({ + randomization: curretRandomization, + updateSettings, + }); + return ( + +
+ {intl.formatMessage(messages.randomizationSettingText, { randomization })} +
+ + + + { + Object.values(RandomizationTypesKeys).map((randomizationType) => ( + + )) + } + + + +
+ ); +}; + +RandomizationCard.propTypes = { + defaultValue: PropTypes.string.isRequired, + randomization: PropTypes.string.isRequired, + updateSettings: PropTypes.func.isRequired, + intl: intlShape.isRequired, +}; + +export default injectIntl(RandomizationCard); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/Randomization/index.test.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/Randomization/index.test.jsx new file mode 100644 index 0000000000..d3438f6196 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/Randomization/index.test.jsx @@ -0,0 +1,49 @@ +import 'CourseAuthoring/editors/setupEditorTest'; +import React from 'react'; +import { shallow } from '@edx/react-unit-test-utils'; +import { formatMessage } from '../../../../../../../testUtils'; +import { RandomizationCard } from './index'; +import { useRandomizationSettingStatus } from './hooks'; + +jest.mock('./hooks', () => ({ + useRandomizationSettingStatus: jest.fn(), +})); + +describe('RandomizationCard', () => { + const props = { + randomization: 'sOmE_vAlUE', + defaultValue: 'default_vAlUE', + updateSettings: jest.fn().mockName('args.updateSettings'), + intl: { formatMessage }, + }; + + const randomizationCardHooksProps = { + summary: { message: { defaultMessage: 'sUmmary' } }, + handleChange: jest.fn().mockName('randomizationCardHooks.handleChange'), + }; + + useRandomizationSettingStatus.mockReturnValue(randomizationCardHooksProps); + + describe('behavior', () => { + it(' calls useRandomizationSettingStatus when initialized', () => { + shallow(); + expect(useRandomizationSettingStatus).toHaveBeenCalledWith( + { updateSettings: props.updateSettings, randomization: props.randomization }, + ); + }); + }); + + describe('snapshot', () => { + test('snapshot: renders randomization setting card with randomization defined', () => { + expect(shallow().snapshot).toMatchSnapshot(); + }); + test('snapshot: renders randomization setting card with default randomization', () => { + expect(shallow().snapshot).toMatchSnapshot(); + }); + test('snapshot: renders randomization setting card with randomization null', () => { + expect( + shallow().snapshot, + ).toMatchSnapshot(); + }); + }); +}); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/Randomization/messages.js b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/Randomization/messages.js new file mode 100644 index 0000000000..3f622bd327 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/Randomization/messages.js @@ -0,0 +1,20 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + + randomizationSettingTitle: { + id: 'authoring.problemeditor.settings.randomization.SettingTitle', + defaultMessage: 'Randomization', + description: 'Settings Title for Randomization widget', + }, + randomizationSettingText: { + id: 'authoring.problemeditor.settings.randomization.SettingText', + defaultMessage: `{randomization, select, + null {No Python based randomization is present in this problem.} + other {Defines when to randomize the variables specified in the associated Python script. For problems that do not randomize values, specify "Never".} + }`, + description: 'Description of Possibilities for value in Randomization widget', + }, +}); + +export default messages; diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/ResetCard.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/ResetCard.jsx new file mode 100644 index 0000000000..1413f092e9 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/ResetCard.jsx @@ -0,0 +1,62 @@ +import React from 'react'; +import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { Button, ButtonGroup, Hyperlink } from '@openedx/paragon'; +import PropTypes from 'prop-types'; +import { useSelector } from 'react-redux'; +import SettingsOption from '../SettingsOption'; +import messages from '../messages'; +import { resetCardHooks } from '../hooks'; +import { selectors } from '../../../../../../data/redux'; + +const ResetCard = ({ + showResetButton, + defaultValue, + updateSettings, + // inject + intl, +}) => { + const isLibrary = useSelector(selectors.app.isLibrary); + const { setResetTrue, setResetFalse } = resetCardHooks(updateSettings); + const advancedSettingsLink = `${useSelector(selectors.app.studioEndpointUrl)}/settings/advanced/${useSelector(selectors.app.learningContextId)}#show_reset_button`; + const currentResetButton = showResetButton !== null ? showResetButton : defaultValue; + return ( + +
+ + + +
+ {!isLibrary && ( +
+ + + +
+ )} + + + + +
+ ); +}; + +ResetCard.propTypes = { + showResetButton: PropTypes.bool.isRequired, + defaultValue: PropTypes.bool.isRequired, + updateSettings: PropTypes.func.isRequired, + // injected + intl: intlShape.isRequired, +}; + +export const ResetCardInternal = ResetCard; // For testing only +export default injectIntl(ResetCard); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/ResetCard.test.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/ResetCard.test.jsx new file mode 100644 index 0000000000..d3c46314fb --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/ResetCard.test.jsx @@ -0,0 +1,53 @@ +import 'CourseAuthoring/editors/setupEditorTest'; +import React from 'react'; +import { shallow } from '@edx/react-unit-test-utils'; +import { useSelector } from 'react-redux'; +import { formatMessage } from '../../../../../../testUtils'; +import { ResetCardInternal as ResetCard } from './ResetCard'; +import { resetCardHooks } from '../hooks'; + +jest.mock('../hooks', () => ({ + resetCardHooks: jest.fn(), +})); + +jest.mock('../../../../../../data/redux', () => ({ + selectors: { + app: { + studioEndpointUrl: 'sTuDioEndpOintUrl', + learningContextId: 'leArningCoNteXtId', + }, + }, +})); + +useSelector.mockImplementation((args) => args); + +describe('ResetCard', () => { + const props = { + showResetButton: false, + updateSettings: jest.fn().mockName('args.updateSettings'), + intl: { formatMessage }, + }; + + const resetCardHooksProps = { + setResetTrue: jest.fn().mockName('resetCardHooks.setResetTrue'), + setResetFalse: jest.fn().mockName('resetCardHooks.setResetFalse'), + }; + + resetCardHooks.mockReturnValue(resetCardHooksProps); + + describe('behavior', () => { + it(' calls resetCardHooks when initialized', () => { + shallow(); + expect(resetCardHooks).toHaveBeenCalledWith(props.updateSettings); + }); + }); + + describe('snapshot', () => { + test('snapshot: renders reset true setting card', () => { + expect(shallow().snapshot).toMatchSnapshot(); + }); + test('snapshot: renders reset true setting card', () => { + expect(shallow().snapshot).toMatchSnapshot(); + }); + }); +}); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/ScoringCard.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/ScoringCard.jsx new file mode 100644 index 0000000000..3935c7cb7e --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/ScoringCard.jsx @@ -0,0 +1,121 @@ +import React from 'react'; +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { Form, Hyperlink } from '@openedx/paragon'; +import { selectors } from '../../../../../../data/redux'; +import SettingsOption from '../SettingsOption'; +import messages from '../messages'; +import { scoringCardHooks } from '../hooks'; + +const ScoringCard = ({ + scoring, + defaultValue, + updateSettings, + // inject + intl, + // redux + studioEndpointUrl, + learningContextId, + isLibrary, +}) => { + const { + handleUnlimitedChange, + handleMaxAttemptChange, + handleWeightChange, + handleOnChange, + attemptDisplayValue, + } = scoringCardHooks(scoring, updateSettings, defaultValue); + + const getScoringSummary = (weight, attempts, unlimited) => { + let summary = intl.formatMessage(messages.weightSummary, { weight }); + summary += ` ${String.fromCharCode(183)} `; + summary += unlimited + ? intl.formatMessage(messages.unlimitedAttemptsSummary) + : intl.formatMessage(messages.attemptsSummary, { attempts: attempts || defaultValue }); + return summary; + }; + + return ( + +
+ +
+ + + + + + + + + + + + +
+ +
+
+
+ {!isLibrary && ( + + + + )} +
+ ); +}; + +ScoringCard.propTypes = { + intl: intlShape.isRequired, + // eslint-disable-next-line + scoring: PropTypes.any.isRequired, + updateSettings: PropTypes.func.isRequired, + defaultValue: PropTypes.number, + // redux + studioEndpointUrl: PropTypes.string.isRequired, + learningContextId: PropTypes.string, + isLibrary: PropTypes.bool.isRequired, +}; + +ScoringCard.defaultProps = { + learningContextId: null, + defaultValue: null, +}; + +export const mapStateToProps = (state) => ({ + studioEndpointUrl: selectors.app.studioEndpointUrl(state), + learningContextId: selectors.app.learningContextId(state), + isLibrary: selectors.app.isLibrary(state), +}); + +export const mapDispatchToProps = {}; + +export const ScoringCardInternal = ScoringCard; // For testing only +export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(ScoringCard)); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/ScoringCard.test.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/ScoringCard.test.jsx new file mode 100644 index 0000000000..29d8e922cf --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/ScoringCard.test.jsx @@ -0,0 +1,71 @@ +import 'CourseAuthoring/editors/setupEditorTest'; +import React from 'react'; +import { shallow } from '@edx/react-unit-test-utils'; +import { formatMessage } from '../../../../../../testUtils'; +import { scoringCardHooks } from '../hooks'; +import { ScoringCardInternal as ScoringCard } from './ScoringCard'; + +jest.mock('../hooks', () => ({ + scoringCardHooks: jest.fn(), +})); + +describe('ScoringCard', () => { + const scoring = { + weight: 1.5, + attempts: { + unlimited: false, + number: 5, + }, + updateSettings: jest.fn().mockName('args.updateSettings'), + intl: { formatMessage }, + }; + + const props = { + scoring, + intl: { formatMessage }, + defaultValue: 1, + }; + + const scoringCardHooksProps = { + handleMaxAttemptChange: jest.fn().mockName('scoringCardHooks.handleMaxAttemptChange'), + handleWeightChange: jest.fn().mockName('scoringCardHooks.handleWeightChange'), + handleOnChange: jest.fn().mockName('scoringCardHooks.handleOnChange'), + local: 5, + }; + + scoringCardHooks.mockReturnValue(scoringCardHooksProps); + + describe('behavior', () => { + it(' calls scoringCardHooks when initialized', () => { + shallow(); + expect(scoringCardHooks).toHaveBeenCalledWith(scoring, props.updateSettings, props.defaultValue); + }); + }); + + describe('snapshot', () => { + test('snapshot: scoring setting card', () => { + expect(shallow().snapshot).toMatchSnapshot(); + }); + test('snapshot: scoring setting card zero zero weight', () => { + expect(shallow().snapshot).toMatchSnapshot(); + }); + test('snapshot: scoring setting card max attempts', () => { + expect(shallow().snapshot).toMatchSnapshot(); + }); + }); +}); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/ShowAnswerCard.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/ShowAnswerCard.jsx new file mode 100644 index 0000000000..77a2163c6e --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/ShowAnswerCard.jsx @@ -0,0 +1,118 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { injectIntl, FormattedMessage, intlShape } from '@edx/frontend-platform/i18n'; +import { Form, Hyperlink } from '@openedx/paragon'; +import SettingsOption from '../SettingsOption'; +import { ShowAnswerTypes, ShowAnswerTypesKeys } from '../../../../../../data/constants/problem'; +import { selectors } from '../../../../../../data/redux'; +import messages from '../messages'; +import { useAnswerSettings } from '../hooks'; + +const ShowAnswerCard = ({ + showAnswer, + updateSettings, + defaultValue, + // inject + intl, + // redux + studioEndpointUrl, + learningContextId, + isLibrary, +}) => { + const { + handleShowAnswerChange, + handleAttemptsChange, + showAttempts, + } = useAnswerSettings(showAnswer, updateSettings); + + const currentShowAnswer = showAnswer.on || defaultValue; + + const showAnswerSection = ( + <> +
+ + + +
+ {!isLibrary && ( +
+ + + +
+ )} + + + {Object.values(ShowAnswerTypesKeys).map((answerType) => { + let optionDisplayName = ShowAnswerTypes[answerType]; + if (answerType === defaultValue) { + optionDisplayName = { ...optionDisplayName, defaultMessage: `${optionDisplayName.defaultMessage} (Default)` }; + } + return ( + + ); + })} + + + {showAttempts + && ( + + + + )} + + ); + + return ( + + {showAnswerSection} + + ); +}; + +ShowAnswerCard.propTypes = { + intl: intlShape.isRequired, + // eslint-disable-next-line + showAnswer: PropTypes.any.isRequired, + solutionExplanation: PropTypes.string, + updateSettings: PropTypes.func.isRequired, + studioEndpointUrl: PropTypes.string.isRequired, + learningContextId: PropTypes.string, + isLibrary: PropTypes.bool.isRequired, + defaultValue: PropTypes.string, +}; +ShowAnswerCard.defaultProps = { + solutionExplanation: '', + learningContextId: null, + defaultValue: 'finished', +}; + +export const mapStateToProps = (state) => ({ + studioEndpointUrl: selectors.app.studioEndpointUrl(state), + learningContextId: selectors.app.learningContextId(state), + isLibrary: selectors.app.isLibrary(state), +}); + +export const mapDispatchToProps = {}; + +export const ShowAnswerCardInternal = ShowAnswerCard; // For testing only +export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(ShowAnswerCard)); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/ShowAnswerCard.test.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/ShowAnswerCard.test.jsx new file mode 100644 index 0000000000..dae004510b --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/ShowAnswerCard.test.jsx @@ -0,0 +1,80 @@ +import 'CourseAuthoring/editors/setupEditorTest'; +import React from 'react'; +import { shallow } from '@edx/react-unit-test-utils'; +import { formatMessage } from '../../../../../../testUtils'; +import { selectors } from '../../../../../../data/redux'; +import { ShowAnswerCardInternal as ShowAnswerCard, mapStateToProps, mapDispatchToProps } from './ShowAnswerCard'; +import { useAnswerSettings } from '../hooks'; + +jest.mock('../hooks', () => ({ + useAnswerSettings: jest.fn(), +})); + +jest.mock('../../../../../../data/redux', () => ({ + selectors: { + app: { + studioEndpointUrl: jest.fn(state => ({ studioEndpointUrl: state })), + learningContextId: jest.fn(state => ({ learningContextId: state })), + isLibrary: jest.fn(state => ({ isLibrary: state })), + }, + }, + thunkActions: { + video: jest.fn(), + }, +})); + +describe('ShowAnswerCard', () => { + const showAnswer = { + on: 'after_attempts', + afterAttempts: 5, + updateSettings: jest.fn().mockName('args.updateSettings'), + intl: { formatMessage }, + }; + const props = { + showAnswer, + defaultValue: 'finished', + // injected + intl: { formatMessage }, + // redux + studioEndpointUrl: 'SoMEeNDpOinT', + learningContextId: 'sOMEcouRseId', + }; + + const useAnswerSettingsProps = { + handleShowAnswerChange: jest.fn().mockName('useAnswerSettings.handleShowAnswerChange'), + handleAttemptsChange: jest.fn().mockName('useAnswerSettings.handleAttemptsChange'), + }; + + useAnswerSettings.mockReturnValue(useAnswerSettingsProps); + + describe('behavior', () => { + it(' calls useAnswerSettings when initialized', () => { + shallow(); + expect(useAnswerSettings).toHaveBeenCalledWith(showAnswer, props.updateSettings); + }); + }); + + describe('snapshot', () => { + test('snapshot: show answer setting card', () => { + expect(shallow().snapshot).toMatchSnapshot(); + }); + }); + describe('mapStateToProps', () => { + const testState = { A: 'pple', B: 'anana', C: 'ucumber' }; + test('studioEndpointUrl from app.studioEndpointUrl', () => { + expect( + mapStateToProps(testState).studioEndpointUrl, + ).toEqual(selectors.app.studioEndpointUrl(testState)); + }); + test('learningContextId from app.learningContextId', () => { + expect( + mapStateToProps(testState).learningContextId, + ).toEqual(selectors.app.learningContextId(testState)); + }); + }); + describe('mapDispatchToProps', () => { + test('equal an empty object', () => { + expect(mapDispatchToProps).toEqual({}); + }); + }); +}); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/SwitchToAdvancedEditorCard.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/SwitchToAdvancedEditorCard.jsx new file mode 100644 index 0000000000..90454579ea --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/SwitchToAdvancedEditorCard.jsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { injectIntl, FormattedMessage } from '@edx/frontend-platform/i18n'; +import { Card } from '@openedx/paragon'; +import PropTypes from 'prop-types'; +import messages from '../messages'; +import { thunkActions } from '../../../../../../data/redux'; +import BaseModal from '../../../../../../sharedComponents/BaseModal'; +import Button from '../../../../../../sharedComponents/Button'; +import { confirmSwitchToAdvancedEditor } from '../hooks'; +import { ProblemTypeKeys } from '../../../../../../data/constants/problem'; + +const SwitchToAdvancedEditorCard = ({ + problemType, + switchToAdvancedEditor, +}) => { + const [isConfirmOpen, setConfirmOpen] = React.useState(false); + + if (problemType === ProblemTypeKeys.ADVANCED) { return null; } + + return ( + + { setConfirmOpen(false); }} + title={()} + confirmAction={( + + )} + size="md" + > + + + + + ); +}; + +SwitchToAdvancedEditorCard.propTypes = { + switchToAdvancedEditor: PropTypes.func.isRequired, + problemType: PropTypes.string.isRequired, +}; + +export const mapStateToProps = () => ({ +}); +export const mapDispatchToProps = { + switchToAdvancedEditor: thunkActions.problem.switchToAdvancedEditor, +}; + +export const SwitchToAdvancedEditorCardInternal = SwitchToAdvancedEditorCard; // For testing only +export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(SwitchToAdvancedEditorCard)); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/SwitchToAdvancedEditorCard.test.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/SwitchToAdvancedEditorCard.test.jsx new file mode 100644 index 0000000000..b9d59f27f1 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/SwitchToAdvancedEditorCard.test.jsx @@ -0,0 +1,25 @@ +import 'CourseAuthoring/editors/setupEditorTest'; +import React from 'react'; +import { shallow } from '@edx/react-unit-test-utils'; +import { SwitchToAdvancedEditorCardInternal as SwitchToAdvancedEditorCard, mapDispatchToProps } from './SwitchToAdvancedEditorCard'; +import { thunkActions } from '../../../../../../data/redux'; + +describe('SwitchToAdvancedEditorCard snapshot', () => { + const mockSwitchToAdvancedEditor = jest.fn().mockName('switchToAdvancedEditor'); + test('snapshot: SwitchToAdvancedEditorCard', () => { + expect( + shallow().snapshot, + ).toMatchSnapshot(); + }); + test('snapshot: SwitchToAdvancedEditorCard returns null', () => { + expect( + shallow().snapshot, + ).toMatchSnapshot(); + }); + + describe('mapDispatchToProps', () => { + test('updateField from actions.problem.updateField', () => { + expect(mapDispatchToProps.switchToAdvancedEditor).toEqual(thunkActions.problem.switchToAdvancedEditor); + }); + }); +}); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/TimerCard.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/TimerCard.jsx new file mode 100644 index 0000000000..be4a3fe771 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/TimerCard.jsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { injectIntl, FormattedMessage, intlShape } from '@edx/frontend-platform/i18n'; +import { Form } from '@openedx/paragon'; +import PropTypes from 'prop-types'; +import SettingsOption from '../SettingsOption'; +import messages from '../messages'; +import { timerCardHooks } from '../hooks'; + +const TimerCard = ({ + timeBetween, + updateSettings, + // inject + intl, +}) => { + const { handleChange } = timerCardHooks(updateSettings); + + return ( + +
+ + + +
+ + + +
+ ); +}; + +TimerCard.propTypes = { + timeBetween: PropTypes.number.isRequired, + updateSettings: PropTypes.func.isRequired, + intl: intlShape.isRequired, +}; + +export const TimerCardInternal = TimerCard; // For testing only +export default injectIntl(TimerCard); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/TimerCard.test.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/TimerCard.test.jsx new file mode 100644 index 0000000000..614a73639b --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/TimerCard.test.jsx @@ -0,0 +1,37 @@ +import 'CourseAuthoring/editors/setupEditorTest'; +import React from 'react'; +import { shallow } from '@edx/react-unit-test-utils'; +import { formatMessage } from '../../../../../../testUtils'; +import { TimerCardInternal as TimerCard } from './TimerCard'; +import { timerCardHooks } from '../hooks'; + +jest.mock('../hooks', () => ({ + timerCardHooks: jest.fn(), +})); + +describe('TimerCard', () => { + const props = { + timeBetween: 5, + updateSettings: jest.fn().mockName('args.updateSettings'), + intl: { formatMessage }, + }; + + const timerCardHooksProps = { + handleChange: jest.fn().mockName('timerCardHooks.handleChange'), + }; + + timerCardHooks.mockReturnValue(timerCardHooksProps); + + describe('behavior', () => { + it(' calls timerCardHooks when initialized', () => { + shallow(); + expect(timerCardHooks).toHaveBeenCalledWith(props.updateSettings); + }); + }); + + describe('snapshot', () => { + test('snapshot: renders reset true setting card', () => { + expect(shallow().snapshot).toMatchSnapshot(); + }); + }); +}); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/Tolerance/constants.js b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/Tolerance/constants.js new file mode 100644 index 0000000000..16d408045a --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/Tolerance/constants.js @@ -0,0 +1,18 @@ +/* eslint-disable import/prefer-default-export */ +import messages from './messages'; + +export const ToleranceTypes = { + percent: { + type: 'Percent', + message: messages.typesPercentage, + }, + number: { + type: 'Number', + message: messages.typesNumber, + + }, + none: { + type: 'None', + message: messages.typesNone, + }, +}; diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/Tolerance/index.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/Tolerance/index.jsx new file mode 100644 index 0000000000..96d7326570 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/Tolerance/index.jsx @@ -0,0 +1,129 @@ +import React, { useEffect } from 'react'; +import { injectIntl, FormattedMessage, intlShape } from '@edx/frontend-platform/i18n'; +import { Alert, Form } from '@openedx/paragon'; +import PropTypes from 'prop-types'; +import SettingsOption from '../../SettingsOption'; +import messages from './messages'; +import { ToleranceTypes } from './constants'; + +// eslint-disable-next-line no-unused-vars +export const isAnswerRangeSet = ({ answers }) => !!answers[0].isAnswerRange; + +export const handleToleranceTypeChange = ({ updateSettings, tolerance, answers }) => (event) => { + if (!isAnswerRangeSet({ answers })) { + let value; + if (event.target.value === ToleranceTypes.none.type) { + value = null; + } else { + value = tolerance.value || 0; + } + const newTolerance = { type: ToleranceTypes[Object.keys(ToleranceTypes)[event.target.selectedIndex]].type, value }; + updateSettings({ tolerance: newTolerance }); + } +}; + +export const handleToleranceValueChange = ({ updateSettings, tolerance, answers }) => (event) => { + if (!isAnswerRangeSet({ answers })) { + let value = parseFloat(event.target.value); + if (value < 0) { + value = 0; + } + const newTolerance = { value, type: tolerance.type }; + updateSettings({ tolerance: newTolerance }); + } +}; + +export const getSummary = ({ tolerance, intl }) => { + switch (tolerance?.type) { + case ToleranceTypes.percent.type: + return `± ${tolerance.value}%`; + case ToleranceTypes.number.type: + return `± ${tolerance.value}`; + case ToleranceTypes.none.type: + return intl.formatMessage(messages.noneToleranceSummary); + default: + return intl.formatMessage(messages.noneToleranceSummary); + } +}; + +const ToleranceCard = ({ + tolerance, + answers, + updateSettings, + // inject + intl, +}) => { + const isAnswerRange = isAnswerRangeSet({ answers }); + let summary = getSummary({ tolerance, intl }); + useEffect(() => { summary = getSummary({ tolerance, intl }); }, [tolerance]); + return ( + + { isAnswerRange + && ( + + + + )} +
+ + + +
+ + + {Object.keys(ToleranceTypes).map((toleranceType) => ( + + ))} + + { tolerance?.type !== ToleranceTypes.none.type && !isAnswerRange + && ( + + )} + + +
+ ); +}; + +ToleranceCard.propTypes = { + tolerance: PropTypes.shape({ + type: PropTypes.string, + value: PropTypes.oneOfType([PropTypes.number, PropTypes.any]), + }).isRequired, + answers: PropTypes.arrayOf(PropTypes.shape({ + correct: PropTypes.bool, + id: PropTypes.string, + selectedFeedback: PropTypes.string, + title: PropTypes.string, + unselectedFeedback: PropTypes.string, + })).isRequired, + updateSettings: PropTypes.func.isRequired, + intl: intlShape.isRequired, +}; + +export const ToleranceCardInternal = ToleranceCard; // For testing only +export default injectIntl(ToleranceCard); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/Tolerance/index.test.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/Tolerance/index.test.jsx new file mode 100644 index 0000000000..f781f097d6 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/Tolerance/index.test.jsx @@ -0,0 +1,157 @@ +import { + render, screen, fireEvent, +} from '@testing-library/react'; +import React from 'react'; +import messages from './messages'; +import { ToleranceTypes } from './constants'; +import { ToleranceCardInternal as ToleranceCard } from './index'; +import { formatMessage } from '../../../../../../../testUtils'; + +jest.mock('@edx/frontend-platform/i18n', () => ({ + __esmodule: true, + ...jest.requireActual('@edx/frontend-platform/i18n'), + FormattedMessage: jest.fn(({ defaultMessage }) => ( +
{ defaultMessage }
+ )), +})); + +// eslint-disable-next-line react/prop-types +jest.mock('../../SettingsOption', () => function mockSettingsOption({ children, summary }) { + return
{summary}{children}
; +}); + +jest.mock('@openedx/paragon', () => ({ + Alert: jest.fn(({ children }) => ( +
{children}
)), + Form: { + Control: jest.fn(({ + children, onChange, as, value, disabled, + }) => { + if (as === 'select') { + return (); + } + return (); + }), + Group: jest.fn(({ children }) => (
{children}
)), + }, +})); + +describe('ToleranceCard', () => { + const mockToleranceNull = { + type: ToleranceTypes.none.type, + value: null, + }; + const mockTolerancePercent = { + type: ToleranceTypes.percent.type, + value: 0, + }; + const mockToleranceNumber = { + type: ToleranceTypes.number.type, + value: 0, + }; + + const props = { + answers: [{ + id: 'A', + correct: true, + selectedFeedback: '', + title: 'An Answer', + isAnswerRange: false, + unselectedFeedback: '', + }, + ], + updateSettings: jest.fn(), + intl: { + formatMessage, + }, + }; + beforeEach(() => { + jest.clearAllMocks(); + }); + describe('summary', () => { + it('Renders None', async () => { + render(); + const NoneText = screen.getAllByText(ToleranceTypes.none.type); + expect(NoneText).toBeDefined(); + }); + it('Render Percent Value', () => { + render(); + const PercentText = screen.getByText(`± ${mockTolerancePercent.value}%`); + expect(PercentText).toBeDefined(); + }); + it('Renders Number Value', () => { + render(); + const NumberText = screen.getByText(`± ${mockToleranceNumber.value}`); + expect(NumberText).toBeDefined(); + }); + + it('If there is an answer range, show message and disable dropdown.', () => { + const rangeprops = { + answers: [{ + id: 'A', + correct: true, + selectedFeedback: '', + title: 'An Answer', + isAnswerRange: true, + unselectedFeedback: '', + }, + ], + updateSettings: jest.fn(), + intl: { + formatMessage, + }, + }; + + render(); + const NumberText = screen.getByText(messages.toleranceAnswerRangeWarning.defaultMessage); + expect(NumberText).toBeDefined(); + expect(screen.getByTestId('select').getAttributeNames().includes('disabled')).toBeTruthy(); + }); + }); + describe('Type Select', () => { + it('Renders the types for selection', async () => { + const { container } = render(); + const options = container.querySelectorAll('option'); + expect(options.length).toBe(3); + Object.keys(ToleranceTypes).forEach(type => { + expect(screen.getAllByText(ToleranceTypes[type].message.defaultMessage)).toBeDefined(); + }); + }); + it('Calls updateSettings on selection of an option', async () => { + const { container, getByTestId } = render(); + const select = getByTestId('select'); + fireEvent.change(select, { target: { value: ToleranceTypes.number.type } }); + const options = container.querySelectorAll('option'); + expect(options[0].selected).toBeFalsy(); + expect(options[1].selected).toBeTruthy(); + expect(options[2].selected).toBeFalsy(); + expect(props.updateSettings).toHaveBeenCalledWith({ tolerance: { type: ToleranceTypes.number.type, value: 0 } }); + fireEvent.change(select, { target: { value: ToleranceTypes.none.type } }); + expect(props.updateSettings).toHaveBeenCalledWith({ tolerance: { type: ToleranceTypes.none.type, value: null } }); + }); + }); + describe('Value Select', () => { + it('Doesnt render if type is null', async () => { + const { queryByTestId } = render(); + expect(queryByTestId('input')).toBeFalsy(); + }); + it('Renders with initial value of tolerance', async () => { + const { queryByTestId } = render(); + expect(queryByTestId('input')).toBeTruthy(); + expect(screen.getByDisplayValue('0')).toBeTruthy(); + }); + it('Calls change function on change.', () => { + const { queryByTestId } = render(); + fireEvent.change(queryByTestId('input'), { target: { value: 52 } }); + expect(props.updateSettings).toHaveBeenCalledWith({ tolerance: { type: ToleranceTypes.number.type, value: 52 } }); + }); + it('Resets negative value on change.', () => { + const { queryByTestId } = render(); + fireEvent.change(queryByTestId('input'), { target: { value: -52 } }); + expect(props.updateSettings).toHaveBeenCalledWith({ tolerance: { type: ToleranceTypes.number.type, value: 0 } }); + }); + }); +}); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/Tolerance/messages.js b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/Tolerance/messages.js new file mode 100644 index 0000000000..1b16c0cfb2 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/Tolerance/messages.js @@ -0,0 +1,49 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + toleranceSettingTitle: { + id: 'problemEditor.settings.tolerance.title', + defaultMessage: 'Tolerance', + description: 'Title for tolerance setting menu', + }, + noneToleranceSummary: { + id: 'problemEditor.settings.tolerance.summary.none', + defaultMessage: 'None', + description: 'message provided when no tolerance is set for a problem', + }, + toleranceSettingText: { + id: 'problemEditor.settings.tolerance.description.text', + defaultMessage: 'The margin of error on either side of an answer.', + description: 'Description of the features of setting a tolerance for a problem', + }, + toleranceValueInputLabel: { + id: 'problemEditor.settings.tolerance.valueinput', + defaultMessage: 'Tolerance', + description: 'floating label for input to set the value of the tolerance', + }, + toleranceAnswerRangeWarning: { + id: 'problemEditor.settings.tolerance.answerrangewarning', + defaultMessage: 'Tolerance cannot be applied to an answer range', + description: 'a warning to users that tolerance cannot be aplied to an answer range.', + }, + typesPercentage: { + id: 'problemEditor.settings.tolerance.type.percent', + defaultMessage: 'Percentage', + description: 'A possible value type for a tolerance', + + }, + typesNumber: { + id: 'problemEditor.settings.tolerance.type.number', + defaultMessage: 'Number', + description: 'A possible value type for a tolerance', + + }, + typesNone: { + id: 'problemEditor.settings.tolerance.type.none', + defaultMessage: 'None', + description: 'A possible value type for a tolerance', + }, + +}); + +export default messages; diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/TypeCard.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/TypeCard.jsx new file mode 100644 index 0000000000..75137b5050 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/TypeCard.jsx @@ -0,0 +1,68 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import SettingsOption from '../SettingsOption'; +import { ProblemTypeKeys, ProblemTypes } from '../../../../../../data/constants/problem'; +import messages from '../messages'; +import TypeRow from './TypeRow'; + +const TypeCard = ({ + answers, + blockTitle, + correctAnswerCount, + problemType, + setBlockTitle, + updateField, + updateAnswer, + // inject + intl, +}) => { + const problemTypeKeysArray = Object.values(ProblemTypeKeys).filter(key => key !== ProblemTypeKeys.ADVANCED); + + if (problemType === ProblemTypeKeys.ADVANCED) { return null; } + + return ( + + {problemTypeKeysArray.map((typeKey, i) => ( + + ))} + + ); +}; + +TypeCard.propTypes = { + answers: PropTypes.arrayOf(PropTypes.shape({ + correct: PropTypes.bool, + id: PropTypes.string, + selectedFeedback: PropTypes.string, + title: PropTypes.string, + unselectedFeedback: PropTypes.string, + })).isRequired, + blockTitle: PropTypes.string.isRequired, + correctAnswerCount: PropTypes.number.isRequired, + problemType: PropTypes.string.isRequired, + setBlockTitle: PropTypes.func.isRequired, + updateField: PropTypes.func.isRequired, + updateAnswer: PropTypes.func.isRequired, + // injected + intl: intlShape.isRequired, +}; + +export const TypeCardInternal = TypeCard; // For testing only +export default injectIntl(TypeCard); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/TypeCard.test.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/TypeCard.test.jsx new file mode 100644 index 0000000000..bd914dec9a --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/TypeCard.test.jsx @@ -0,0 +1,26 @@ +import 'CourseAuthoring/editors/setupEditorTest'; +import React from 'react'; +import { shallow } from '@edx/react-unit-test-utils'; +import { formatMessage } from '../../../../../../testUtils'; +import { TypeCardInternal as TypeCard } from './TypeCard'; +import { ProblemTypeKeys } from '../../../../../../data/constants/problem'; + +describe('TypeCard', () => { + const props = { + answers: [], + blockTitle: 'BLocktiTLE', + correctAnswerCount: 0, + problemType: ProblemTypeKeys.TEXTINPUT, + setBlockTitle: jest.fn().mockName('args.setBlockTitle'), + updateField: jest.fn().mockName('args.updateField'), + updateAnswer: jest.fn().mockName('args.updateAnswer'), + // injected + intl: { formatMessage }, + }; + + describe('snapshot', () => { + test('snapshot: renders type setting card', () => { + expect(shallow().snapshot).toMatchSnapshot(); + }); + }); +}); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/TypeRow.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/TypeRow.jsx new file mode 100644 index 0000000000..ec8bc78ec9 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/TypeRow.jsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { Icon } from '@openedx/paragon'; +import PropTypes from 'prop-types'; +import { Check } from '@openedx/paragon/icons'; +import { typeRowHooks } from '../hooks'; + +import Button from '../../../../../../sharedComponents/Button'; + +const TypeRow = ({ + answers, + blockTitle, + correctAnswerCount, + typeKey, + label, + selected, + problemType, + lastRow, + setBlockTitle, + updateField, + updateAnswer, +}) => { + const { onClick } = typeRowHooks({ + answers, + blockTitle, + correctAnswerCount, + problemType, + setBlockTitle, + typeKey, + updateField, + updateAnswer, + }); + + return ( + <> + +
+ + ); +}; + +TypeRow.propTypes = { + answers: PropTypes.arrayOf(PropTypes.shape({ + correct: PropTypes.bool, + id: PropTypes.string, + selectedFeedback: PropTypes.string, + title: PropTypes.string, + unselectedFeedback: PropTypes.string, + })).isRequired, + blockTitle: PropTypes.string.isRequired, + correctAnswerCount: PropTypes.number.isRequired, + typeKey: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + selected: PropTypes.bool.isRequired, + lastRow: PropTypes.bool.isRequired, + problemType: PropTypes.string.isRequired, + setBlockTitle: PropTypes.func.isRequired, + updateAnswer: PropTypes.func.isRequired, + updateField: PropTypes.func.isRequired, +}; + +export default TypeRow; diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/TypeRow.test.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/TypeRow.test.jsx new file mode 100644 index 0000000000..3902fd9659 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/TypeRow.test.jsx @@ -0,0 +1,60 @@ +import 'CourseAuthoring/editors/setupEditorTest'; +import React from 'react'; +import { shallow } from '@edx/react-unit-test-utils'; +import TypeRow from './TypeRow'; +import { typeRowHooks } from '../hooks'; + +jest.mock('../hooks', () => ({ + typeRowHooks: jest.fn(), +})); + +describe('TypeRow', () => { + const typeKey = 'TEXTINPUT'; + const props = { + answers: [], + blockTitle: 'bLoCkTiTLE', + correctAnswerCount: 0, + typeKey, + label: 'Text Input Problem', + selected: true, + lastRow: false, + problemType: 'prOBlEMtyPE', + setBlockTitle: jest.fn().mockName('args.setBlockTitle'), + updateField: jest.fn().mockName('args.updateField'), + updateAnswer: jest.fn().mockName('args.updateAnswer'), + }; + + const typeRowHooksProps = { + onClick: jest.fn().mockName('typeRowHooks.onClick'), + }; + + typeRowHooks.mockReturnValue(typeRowHooksProps); + + describe('behavior', () => { + it(' calls typeRowHooks when initialized', () => { + shallow(); + expect(typeRowHooks).toHaveBeenCalledWith({ + answers: props.answers, + blockTitle: props.blockTitle, + correctAnswerCount: props.correctAnswerCount, + problemType: props.problemType, + typeKey, + setBlockTitle: props.setBlockTitle, + updateField: props.updateField, + updateAnswer: props.updateAnswer, + }); + }); + }); + + describe('snapshot', () => { + test('snapshot: renders type row setting card', () => { + expect(shallow().snapshot).toMatchSnapshot(); + }); + test('snapshot: renders type row setting card not selected', () => { + expect(shallow().snapshot).toMatchSnapshot(); + }); + test('snapshot: renders type row setting card last row', () => { + expect(shallow().snapshot).toMatchSnapshot(); + }); + }); +}); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/__snapshots__/HintRow.test.jsx.snap b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/__snapshots__/HintRow.test.jsx.snap new file mode 100644 index 0000000000..b656155980 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/__snapshots__/HintRow.test.jsx.snap @@ -0,0 +1,31 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`HintRow snapshot snapshot: renders hints row 1`] = ` + + + + +
+ +
+
+`; diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/__snapshots__/HintsCard.test.jsx.snap b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/__snapshots__/HintsCard.test.jsx.snap new file mode 100644 index 0000000000..7e7fe0fc0d --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/__snapshots__/HintsCard.test.jsx.snap @@ -0,0 +1,97 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`HintsCard snapshot snapshot: renders hints setting card multiple hints 1`] = ` + + + + + +`; + +exports[`HintsCard snapshot snapshot: renders hints setting card no hints 1`] = ` + + + +`; + +exports[`HintsCard snapshot snapshot: renders hints setting card one hint 1`] = ` + + + + +`; diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/__snapshots__/ResetCard.test.jsx.snap b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/__snapshots__/ResetCard.test.jsx.snap new file mode 100644 index 0000000000..23d9f5c69a --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/__snapshots__/ResetCard.test.jsx.snap @@ -0,0 +1,127 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ResetCard snapshot snapshot: renders reset true setting card 1`] = ` + +
+ + + +
+
+ + + +
+ + + + +
+`; + +exports[`ResetCard snapshot snapshot: renders reset true setting card 2`] = ` + +
+ + + +
+
+ + + +
+ + + + +
+`; diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/__snapshots__/ScoringCard.test.jsx.snap b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/__snapshots__/ScoringCard.test.jsx.snap new file mode 100644 index 0000000000..9f07b02d06 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/__snapshots__/ScoringCard.test.jsx.snap @@ -0,0 +1,238 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ScoringCard snapshot snapshot: scoring setting card 1`] = ` + +
+ +
+ + + + + + + + + + + + +
+ +
+
+
+ + + +
+`; + +exports[`ScoringCard snapshot snapshot: scoring setting card max attempts 1`] = ` + +
+ +
+ + + + + + + + + + + + +
+ +
+
+
+ + + +
+`; + +exports[`ScoringCard snapshot snapshot: scoring setting card zero zero weight 1`] = ` + +
+ +
+ + + + + + + + + + + + +
+ +
+
+
+ + + +
+`; diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/__snapshots__/ShowAnswerCard.test.jsx.snap b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/__snapshots__/ShowAnswerCard.test.jsx.snap new file mode 100644 index 0000000000..fbebc864fc --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/__snapshots__/ShowAnswerCard.test.jsx.snap @@ -0,0 +1,121 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ShowAnswerCard snapshot snapshot: show answer setting card 1`] = ` + + +
+ + + +
+
+ + + +
+ + + + + + + + + + + + + + + + +
+
+`; diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/__snapshots__/SwitchToAdvancedEditorCard.test.jsx.snap b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/__snapshots__/SwitchToAdvancedEditorCard.test.jsx.snap new file mode 100644 index 0000000000..4996845242 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/__snapshots__/SwitchToAdvancedEditorCard.test.jsx.snap @@ -0,0 +1,59 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SwitchToAdvancedEditorCard snapshot snapshot: SwitchToAdvancedEditorCard 1`] = ` + + + + + } + footerAction={null} + headerComponent={null} + isFullscreenScroll={true} + isOpen={false} + size="md" + title={ + + } + > + + + + +`; + +exports[`SwitchToAdvancedEditorCard snapshot snapshot: SwitchToAdvancedEditorCard returns null 1`] = `null`; diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/__snapshots__/TimerCard.test.jsx.snap b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/__snapshots__/TimerCard.test.jsx.snap new file mode 100644 index 0000000000..8204e4a019 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/__snapshots__/TimerCard.test.jsx.snap @@ -0,0 +1,32 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`TimerCard snapshot snapshot: renders reset true setting card 1`] = ` + +
+ + + +
+ + + +
+`; diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/__snapshots__/TypeCard.test.jsx.snap b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/__snapshots__/TypeCard.test.jsx.snap new file mode 100644 index 0000000000..03f9dc771d --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/__snapshots__/TypeCard.test.jsx.snap @@ -0,0 +1,82 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`TypeCard snapshot snapshot: renders type setting card 1`] = ` + + + + + + + +`; diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/__snapshots__/TypeRow.test.jsx.snap b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/__snapshots__/TypeRow.test.jsx.snap new file mode 100644 index 0000000000..af70e8dfda --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/__snapshots__/TypeRow.test.jsx.snap @@ -0,0 +1,82 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`TypeRow snapshot snapshot: renders type row setting card 1`] = ` + + +
+
+`; + +exports[`TypeRow snapshot snapshot: renders type row setting card last row 1`] = ` + + +
+
+`; + +exports[`TypeRow snapshot snapshot: renders type row setting card not selected 1`] = ` + + +
+
+`; diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/__snapshots__/index.test.jsx.snap b/src/editors/containers/ProblemEditor/components/EditProblemView/__snapshots__/index.test.jsx.snap new file mode 100644 index 0000000000..24c6543af9 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/__snapshots__/index.test.jsx.snap @@ -0,0 +1,143 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EditorProblemView component renders raw editor 1`] = ` + + + + + + } + isOpen={false} + onClose={[Function]} + title="OLX settings discrepancy" + > + + +
+ + + + + + +
+
+`; + +exports[`EditorProblemView component renders simple view 1`] = ` + + + + + + } + isOpen={false} + onClose={[Function]} + title="No answer specified" + > + +
+ +
+
+ +
+
+
+
+ + + + + + + + +
+
+`; diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/hooks.js b/src/editors/containers/ProblemEditor/components/EditProblemView/hooks.js new file mode 100644 index 0000000000..642e149955 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/hooks.js @@ -0,0 +1,167 @@ +import { useState } from 'react'; +import 'tinymce'; +import { StrictDict } from '../../../../utils'; +import ReactStateSettingsParser from '../../data/ReactStateSettingsParser'; +import ReactStateOLXParser from '../../data/ReactStateOLXParser'; +import { setAssetToStaticUrl } from '../../../../sharedComponents/TinyMceWidget/hooks'; +import { ProblemTypeKeys } from '../../../../data/constants/problem'; + +export const state = StrictDict({ + // eslint-disable-next-line react-hooks/rules-of-hooks + isSaveWarningModalOpen: (val) => useState(val), +}); + +export const saveWarningModalToggle = () => { + const [isSaveWarningModalOpen, setIsOpen] = state.isSaveWarningModalOpen(false); + return { + isSaveWarningModalOpen, + openSaveWarningModal: () => setIsOpen(true), + closeSaveWarningModal: () => setIsOpen(false), + }; +}; + +export const fetchEditorContent = ({ format }) => { + const editorObject = { hints: [] }; + const EditorsArray = window.tinymce.editors; + Object.entries(EditorsArray).forEach(([id, editor]) => { + if (Number.isNaN(parseInt(id, 10))) { + if (id.startsWith('answer')) { + const { answers } = editorObject; + const answerId = id.substring(id.indexOf('-') + 1); + editorObject.answers = { ...answers, [answerId]: editor.getContent({ format }) }; + } else if (id.includes('Feedback')) { + const { selectedFeedback, unselectedFeedback, groupFeedback } = editorObject; + const feedbackId = id.substring(id.indexOf('-') + 1); + if (id.startsWith('selected')) { + editorObject.selectedFeedback = { ...selectedFeedback, [feedbackId]: editor.getContent() }; + } + if (id.startsWith('unselected')) { + editorObject.unselectedFeedback = { ...unselectedFeedback, [feedbackId]: editor.getContent() }; + } + if (id.startsWith('group')) { + editorObject.groupFeedback = { ...groupFeedback, [feedbackId]: editor.getContent() }; + } + } else if (id.startsWith('hint')) { + const { hints } = editorObject; + hints.push(editor.getContent()); + } else { + editorObject[id] = editor.getContent(); + } + } + }); + return editorObject; +}; + +export const parseState = ({ + problem, + isAdvanced, + ref, + lmsEndpointUrl, +}) => () => { + const rawOLX = ref?.current?.state.doc.toString(); + const editorObject = fetchEditorContent({ format: '' }); + const reactOLXParser = new ReactStateOLXParser({ problem, editorObject }); + const reactSettingsParser = new ReactStateSettingsParser({ problem, rawOLX }); + const reactBuiltOlx = setAssetToStaticUrl({ editorValue: reactOLXParser.buildOLX(), lmsEndpointUrl }); + return { + settings: isAdvanced ? reactSettingsParser.parseRawOlxSettings() : reactSettingsParser.getSettings(), + olx: isAdvanced ? rawOLX : reactBuiltOlx, + }; +}; + +export const checkForNoAnswers = ({ openSaveWarningModal, problem }) => { + const simpleTextAreaProblems = [ProblemTypeKeys.DROPDOWN, ProblemTypeKeys.NUMERIC, ProblemTypeKeys.TEXTINPUT]; + const editorObject = fetchEditorContent({ format: '' }); + const { problemType } = problem; + const { answers } = problem; + const answerTitles = simpleTextAreaProblems.includes(problemType) ? {} : editorObject.answers; + + const hasTitle = () => { + const titles = []; + answers.forEach(answer => { + const title = simpleTextAreaProblems.includes(problemType) ? answer.title : answerTitles[answer.id]; + if (title.length > 0) { + titles.push(title); + } + }); + if (titles.length > 0) { + return true; + } + return false; + }; + + const hasCorrectAnswer = () => { + let correctAnswer; + answers.forEach(answer => { + if (answer.correct) { + const title = simpleTextAreaProblems.includes(problemType) ? answer.title : answerTitles[answer.id]; + if (title.length > 0) { + correctAnswer = true; + } + } + }); + if (correctAnswer) { + return true; + } + return false; + }; + + if (problemType === ProblemTypeKeys.NUMERIC && !hasTitle()) { + openSaveWarningModal(); + return true; + } + if (!hasCorrectAnswer()) { + openSaveWarningModal(); + return true; + } + return false; +}; + +export const checkForSettingDiscrepancy = ({ problem, ref, openSaveWarningModal }) => { + const rawOLX = ref?.current?.state.doc.toString(); + const reactSettingsParser = new ReactStateSettingsParser({ problem, rawOLX }); + const problemSettings = reactSettingsParser.getSettings(); + const rawOlxSettings = reactSettingsParser.parseRawOlxSettings(); + let isMismatched = false; + + Object.entries(rawOlxSettings).forEach(([key, value]) => { + if (value !== problemSettings[key]) { + isMismatched = true; + } + }); + + if (isMismatched) { + openSaveWarningModal(); + return true; + } + return false; +}; + +export const getContent = ({ + problemState, + openSaveWarningModal, + isAdvancedProblemType, + editorRef, + lmsEndpointUrl, +}) => { + const problem = problemState; + const hasNoAnswers = isAdvancedProblemType ? false : checkForNoAnswers({ + problem, + openSaveWarningModal, + }); + const hasMismatchedSettings = isAdvancedProblemType ? checkForSettingDiscrepancy({ + ref: editorRef, + problem, + openSaveWarningModal, + }) : false; + if (!hasNoAnswers && !hasMismatchedSettings) { + const data = parseState({ + isAdvanced: isAdvancedProblemType, + ref: editorRef, + problem, + lmsEndpointUrl, + })(); + return data; + } + return null; +}; diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/hooks.test.js b/src/editors/containers/ProblemEditor/components/EditProblemView/hooks.test.js new file mode 100644 index 0000000000..11f38473b4 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/hooks.test.js @@ -0,0 +1,364 @@ +import { ProblemTypeKeys, ShowAnswerTypesKeys } from '../../../../data/constants/problem'; +import * as hooks from './hooks'; +import { MockUseState } from '../../../../testUtils'; + +const mockRawOLX = 'rawOLX'; +const mockBuiltOLX = 'builtOLX'; +const mockGetSettings = { + max_attempts: 1, + weight: 2, + showanswer: ShowAnswerTypesKeys.AFTER_SOME_NUMBER_OF_ATTEMPTS, + show_reset_button: false, + rerandomize: 'never', +}; +const mockParseRawOlxSettingsDiscrepancy = { + max_attempts: 1, + weight: 2, + showanswer: ShowAnswerTypesKeys.AFTER_SOME_NUMBER_OF_ATTEMPTS, + show_reset_button: true, + rerandomize: 'never', +}; +const mockParseRawOlxSettings = { + max_attempts: 1, + weight: 2, + showanswer: ShowAnswerTypesKeys.AFTER_SOME_NUMBER_OF_ATTEMPTS, + show_reset_button: false, + rerandomize: 'never', +}; +const problemState = { + problemType: ProblemTypeKeys.ADVANCED, + defaultSettings: {}, + settings: { + randomization: null, + scoring: { + weight: 1, + attempts: { + unlimited: true, + number: '', + }, + }, + timeBetween: 0, + showAnswer: { + on: ShowAnswerTypesKeys.AFTER_SOME_NUMBER_OF_ATTEMPTS, + afterAttempts: 0, + }, + showResetButton: false, + solutionExplanation: '', + }, +}; + +const toStringMock = () => mockRawOLX; +const refMock = { current: { state: { doc: { toString: toStringMock } } } }; + +jest.mock('../../data/ReactStateOLXParser', () => ( + jest.fn().mockImplementation(() => ({ + buildOLX: () => mockBuiltOLX, + })) +)); + +const hookState = new MockUseState(hooks); + +describe('saveWarningModalToggle', () => { + const hookKey = hookState.keys.isSaveWarningModalOpen; + beforeEach(() => { + jest.clearAllMocks(); + }); + describe('state hook', () => { + hookState.testGetter(hookKey); + }); + describe('using state', () => { + beforeEach(() => { + hookState.mock(); + }); + afterEach(() => { + hookState.restore(); + }); + + describe('saveWarningModalToggle', () => { + let hook; + beforeEach(() => { + hook = hooks.saveWarningModalToggle(); + }); + test('isSaveWarningModalOpen: state value', () => { + expect(hook.isSaveWarningModalOpen).toEqual(hookState.stateVals[hookKey]); + }); + test('openSaveWarningModal: calls setter with true', () => { + hook.openSaveWarningModal(); + expect(hookState.setState[hookKey]).toHaveBeenCalledWith(true); + }); + test('closeSaveWarningModal: calls setter with false', () => { + hook.closeSaveWarningModal(); + expect(hookState.setState[hookKey]).toHaveBeenCalledWith(false); + }); + }); + }); +}); + +describe('EditProblemView hooks parseState', () => { + describe('fetchEditorContent', () => { + const getContent = () => '

testString

'; + test('returns answers', () => { + window.tinymce.editors = { 'answer-A': { getContent } }; + const editorObject = hooks.fetchEditorContent({ format: '' }); + expect(editorObject).toEqual({ answers: { A: '

testString

' }, hints: [] }); + }); + test('returns hints', () => { + window.tinymce.editors = { 'hint-0': { getContent } }; + const editorObject = hooks.fetchEditorContent({ format: '' }); + expect(editorObject).toEqual({ hints: ['

testString

'] }); + }); + test('returns question', () => { + window.tinymce.editors = { question: { getContent } }; + const editorObject = hooks.fetchEditorContent({ format: '' }); + expect(editorObject).toEqual({ question: '

testString

', hints: [] }); + }); + test('returns selectedFeedback', () => { + window.tinymce.editors = { 'selectedFeedback-A': { getContent } }; + const editorObject = hooks.fetchEditorContent({ format: '' }); + expect(editorObject).toEqual({ selectedFeedback: { A: '

testString

' }, hints: [] }); + }); + test('returns unselectedFeedback', () => { + window.tinymce.editors = { 'unselectedFeedback-A': { getContent } }; + const editorObject = hooks.fetchEditorContent({ format: '' }); + expect(editorObject).toEqual({ unselectedFeedback: { A: '

testString

' }, hints: [] }); + }); + test('returns groupFeedback', () => { + window.tinymce.editors = { 'groupFeedback-0': { getContent } }; + const editorObject = hooks.fetchEditorContent({ format: '' }); + expect(editorObject).toEqual({ groupFeedback: { 0: '

testString

' }, hints: [] }); + }); + test('returns groupFeedback', () => { + window.tinymce.editors = {}; + const editorObject = hooks.fetchEditorContent({ format: '' }); + expect(editorObject).toEqual({ hints: [] }); + }); + }); + describe('parseState', () => { + jest.mock('../../data/ReactStateSettingsParser', () => ( + jest.fn().mockImplementationOnce(() => ({ + getSettings: () => mockGetSettings, + parseRawOlxSettings: () => mockParseRawOlxSettings, + })) + )); + it('default problem', () => { + const res = hooks.parseState({ + problem: problemState, + isAdvanced: false, + ref: refMock, + assets: {}, + })(); + expect(res.olx).toBe(mockBuiltOLX); + }); + it('advanced problem', () => { + const res = hooks.parseState({ + problem: problemState, + isAdvanced: true, + ref: refMock, + assets: {}, + })(); + expect(res.olx).toBe(mockRawOLX); + }); + }); + describe('checkNoAnswers', () => { + const openSaveWarningModal = jest.fn(); + describe('hasTitle', () => { + const problem = { + problemType: ProblemTypeKeys.NUMERIC, + }; + beforeEach(() => { + jest.clearAllMocks(); + }); + it('should call openSaveWarningModal for numerical problem with empty title', () => { + const expected = hooks.checkForNoAnswers({ + openSaveWarningModal, + problem: { + ...problem, + answers: [{ id: 'A', title: '', correct: true }], + }, + }); + expect(openSaveWarningModal).toHaveBeenCalled(); + expect(expected).toEqual(true); + }); + it('returns false for numerical problem with title', () => { + const expected = hooks.checkForNoAnswers({ + openSaveWarningModal, + problem: { + ...problem, + answers: [{ id: 'A', title: 'sOmevALUe', correct: true }], + }, + }); + expect(openSaveWarningModal).not.toHaveBeenCalled(); + expect(expected).toEqual(false); + }); + }); + describe('hasCorrectAnswer', () => { + const problem = { + problemType: ProblemTypeKeys.SINGLESELECT, + }; + beforeEach(() => { + jest.clearAllMocks(); + }); + it('should call openSaveWarningModal for single select problem with empty title', () => { + window.tinymce.editors = { 'answer-A': { getContent: () => '' }, 'answer-B': { getContent: () => 'sOmevALUe' } }; + const expected = hooks.checkForNoAnswers({ + openSaveWarningModal, + problem: { + ...problem, + answers: [{ id: 'A', title: '', correct: true }, { id: 'B', title: 'sOmevALUe', correct: false }], + }, + }); + expect(openSaveWarningModal).toHaveBeenCalled(); + expect(expected).toEqual(true); + }); + it('returns true for single select with title but no correct answer', () => { + window.tinymce.editors = { 'answer-A': { getContent: () => 'sOmevALUe' } }; + const expected = hooks.checkForNoAnswers({ + openSaveWarningModal, + problem: { + ...problem, + answers: [{ id: 'A', title: 'sOmevALUe', correct: false }, { id: 'B', title: '', correct: false }], + }, + }); + expect(openSaveWarningModal).toHaveBeenCalled(); + expect(expected).toEqual(true); + }); + it('returns true for single select with title and correct answer', () => { + window.tinymce.editors = { 'answer-A': { getContent: () => 'sOmevALUe' } }; + const expected = hooks.checkForNoAnswers({ + openSaveWarningModal, + problem: { + ...problem, + answers: [{ id: 'A', title: 'sOmevALUe', correct: true }], + }, + }); + expect(openSaveWarningModal).not.toHaveBeenCalled(); + expect(expected).toEqual(false); + }); + }); + }); + describe('checkForSettingDiscrepancy', () => { + const openSaveWarningModal = jest.fn(); + const problem = problemState; + beforeEach(() => { + jest.clearAllMocks(); + }); + it('returns true for setting discrepancies', () => { + jest.mock('../../data/ReactStateSettingsParser', () => ( + jest.fn().mockImplementationOnce(() => ({ + getSettings: () => mockGetSettings, + parseRawOlxSettings: () => mockParseRawOlxSettingsDiscrepancy, + })) + )); + const mockRawOLXWithSettings = 'rawOLX'; + const refMockWithSettings = { current: { state: { doc: { toString: () => mockRawOLXWithSettings } } } }; + const expected = hooks.checkForSettingDiscrepancy({ + openSaveWarningModal, + problem, + ref: refMockWithSettings, + }); + expect(openSaveWarningModal).toHaveBeenCalled(); + expect(expected).toEqual(true); + }); + it('returns false when there are no setting discrepancies', () => { + jest.mock('../../data/ReactStateSettingsParser', () => ( + jest.fn().mockImplementationOnce(() => ({ + getSettings: () => mockGetSettings, + parseRawOlxSettings: () => mockParseRawOlxSettings, + })) + )); + const expected = hooks.checkForSettingDiscrepancy({ + openSaveWarningModal, + problem, + ref: refMock, + }); + expect(openSaveWarningModal).not.toHaveBeenCalled(); + expect(expected).toEqual(false); + }); + }); + describe('getContent', () => { + const assets = {}; + const lmsEndpointUrl = 'someUrl'; + const editorRef = refMock; + const expectedSettings = { + max_attempts: '', + weight: 1, + showanswer: ShowAnswerTypesKeys.AFTER_SOME_NUMBER_OF_ATTEMPTS, + show_reset_button: false, + submission_wait_seconds: 0, + attempts_before_showanswer_button: 0, + }; + const openSaveWarningModal = jest.fn(); + + it('default visual save and returns parseState data', () => { + const problem = { ...problemState, problemType: ProblemTypeKeys.NUMERIC, answers: [{ id: 'A', title: 'problem', correct: true }] }; + const content = hooks.getContent({ + isAdvancedProblemType: false, + problemState: problem, + editorRef, + assets, + lmsEndpointUrl, + openSaveWarningModal, + }); + expect(content).toEqual({ + olx: 'builtOLX', + settings: expectedSettings, + }); + }); + + it('returned parseState content.settings should not include default values (not including maxAttempts)', () => { + const problem = { + ...problemState, + problemType: ProblemTypeKeys.NUMERIC, + answers: [{ id: 'A', title: 'problem', correct: true }], + defaultSettings: { + maxAttempts: '', + showanswer: ShowAnswerTypesKeys.AFTER_SOME_NUMBER_OF_ATTEMPTS, + showResetButton: false, + rerandomize: 'never', + }, + }; + const { settings } = hooks.getContent({ + isAdvancedProblemType: false, + problemState: problem, + editorRef, + assets, + lmsEndpointUrl, + openSaveWarningModal, + }); + expect(settings).toEqual({ + max_attempts: '', + attempts_before_showanswer_button: 0, + submission_wait_seconds: 0, + weight: 1, + }); + }); + + it('default advanced save and returns parseState data', () => { + const content = hooks.getContent({ + isAdvancedProblemType: true, + problemState, + editorRef, + assets, + lmsEndpointUrl, + openSaveWarningModal, + }); + expect(content).toEqual({ + olx: 'rawOLX', + settings: expectedSettings, + }); + }); + it('should return null', () => { + const problem = { ...problemState, problemType: ProblemTypeKeys.NUMERIC, answers: [{ id: 'A', title: '', correct: true }] }; + const content = hooks.getContent({ + isAdvancedProblemType: false, + problemState: problem, + editorRef, + assets, + lmsEndpointUrl, + openSaveWarningModal, + }); + expect(openSaveWarningModal).toHaveBeenCalled(); + expect(content).toEqual(null); + }); + }); +}); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/index.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/index.jsx new file mode 100644 index 0000000000..64d9229061 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/index.jsx @@ -0,0 +1,143 @@ +import React, { useRef } from 'react'; +import PropTypes from 'prop-types'; +import { connect, useDispatch } from 'react-redux'; +import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n'; + +import { + Container, + Button, + AlertModal, + ActionRow, +} from '@openedx/paragon'; +import AnswerWidget from './AnswerWidget'; +import SettingsWidget from './SettingsWidget'; +import QuestionWidget from './QuestionWidget'; +import EditorContainer from '../../../EditorContainer'; +import { selectors } from '../../../../data/redux'; +import RawEditor from '../../../../sharedComponents/RawEditor'; +import { ProblemTypeKeys } from '../../../../data/constants/problem'; + +import { parseState, saveWarningModalToggle, getContent } from './hooks'; +import './index.scss'; +import messages from './messages'; + +import ExplanationWidget from './ExplanationWidget'; +import { saveBlock } from '../../../../hooks'; + +const EditProblemView = ({ + returnFunction, + // redux + problemType, + problemState, + lmsEndpointUrl, + returnUrl, + analytics, + // injected + intl, +}) => { + const dispatch = useDispatch(); + const editorRef = useRef(null); + const isAdvancedProblemType = problemType === ProblemTypeKeys.ADVANCED; + const { isSaveWarningModalOpen, openSaveWarningModal, closeSaveWarningModal } = saveWarningModalToggle(); + + return ( + getContent({ + problemState, + openSaveWarningModal, + isAdvancedProblemType, + editorRef, + lmsEndpointUrl, + })} + returnFunction={returnFunction} + > + + + + + )} + > + {isAdvancedProblemType ? ( + + ) : ( + <> +
+ +
+
+ +
+ + )} +
+
+ {isAdvancedProblemType ? ( + + + + ) : ( + + + + + + )} + + + +
+
+ ); +}; + +EditProblemView.defaultProps = { + lmsEndpointUrl: null, + returnFunction: null, +}; + +EditProblemView.propTypes = { + problemType: PropTypes.string.isRequired, + returnFunction: PropTypes.func, + // eslint-disable-next-line + problemState: PropTypes.any.isRequired, + analytics: PropTypes.shape({}).isRequired, + lmsEndpointUrl: PropTypes.string, + returnUrl: PropTypes.string.isRequired, + // injected + intl: intlShape.isRequired, +}; + +export const mapStateToProps = (state) => ({ + analytics: selectors.app.analytics(state), + lmsEndpointUrl: selectors.app.lmsEndpointUrl(state), + returnUrl: selectors.app.returnUrl(state), + problemType: selectors.problem.problemType(state), + problemState: selectors.problem.completeState(state), +}); + +export const EditProblemViewInternal = EditProblemView; // For testing only +export default injectIntl(connect(mapStateToProps)(EditProblemView)); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/index.scss b/src/editors/containers/ProblemEditor/components/EditProblemView/index.scss new file mode 100644 index 0000000000..1c14750ebf --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/index.scss @@ -0,0 +1,55 @@ +.editProblemView { + .editProblemView-settingsColumn { + width: 320px; + flex-grow: 0; + flex-shrink: 0; + } + + .advancedEditorTopMargin { + margin-top: 40px; + } + + .answer-option { + .pgn__form-checkbox, + .pgn__form-radio { + & + .pgn__form-label { + min-width: 1.1rem; + } + } + } + + .settingsOption { + .pgn__form-checkbox .pgn__form-label { + min-width: .8rem; + } + } +} + +.tinyMceWidget { + .tox-tinymce { + border-radius: .375rem; + } + + .tox { + .tox-toolbar__primary { + background: none; + } + } + + .tox-statusbar { + border-top: none; + } + + .tox-toolbar__group:not(:last-of-type) { + // TODO: Find a way to override the border without !important + border-right: none !important; + + &::after { + content: ''; + position: relative; + left: 5px; + border: 1px solid #EAE6E5; + height: 24px; + } + } +} diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/index.test.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/index.test.jsx new file mode 100644 index 0000000000..a55d025bad --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/index.test.jsx @@ -0,0 +1,36 @@ +import 'CourseAuthoring/editors/setupEditorTest'; +import React from 'react'; +import { shallow } from '@edx/react-unit-test-utils'; +import { EditProblemViewInternal as EditProblemView } from '.'; +import { AnswerWidgetInternal as AnswerWidget } from './AnswerWidget'; +import { ProblemTypeKeys } from '../../../../data/constants/problem'; +import RawEditor from '../../../../sharedComponents/RawEditor'; +import { formatMessage } from '../../../../testUtils'; + +describe('EditorProblemView component', () => { + test('renders simple view', () => { + const wrapper = shallow(); + expect(wrapper.snapshot).toMatchSnapshot(); + + const AnswerWidgetComponent = wrapper.shallowWrapper.props.children[1].props.children[1].props.children; + expect(AnswerWidgetComponent.props.problemType).toBe(ProblemTypeKeys.SINGLESELECT); + expect(wrapper.instance.findByType(RawEditor).length).toBe(0); + }); + + test('renders raw editor', () => { + const wrapper = shallow(); + expect(wrapper.snapshot).toMatchSnapshot(); + expect(wrapper.instance.findByType(AnswerWidget).length).toBe(0); + expect(wrapper.instance.findByType(RawEditor).length).toBe(1); + }); +}); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/messages.js b/src/editors/containers/ProblemEditor/components/EditProblemView/messages.js new file mode 100644 index 0000000000..68cd4085d6 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/messages.js @@ -0,0 +1,43 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + saveWarningModalCancelButtonLabel: { + id: 'authoring.problemEditor.editProblemView.saveWarningModal.cancelButton.label', + defaultMessage: 'Cancel', + description: 'Label for cancel button in the save warning modal', + }, + saveWarningModalSaveButtonLabel: { + id: 'authoring.problemEditor.editProblemView.saveWarningModal.saveButton.label', + defaultMessage: 'Ok', + description: 'Label for save button in the save warning modal', + }, + saveWarningModalBodyQuestion: { + id: 'authoring.problemEditor.editProblemView.saveWarningModal.body.question', + defaultMessage: 'Are you sure you want to exit the editor?', + description: 'Question in body of save warning modal', + }, + noAnswerTitle: { + id: 'authoring.problemEditor.editProblemView.saveWarningModal.noAnswer.title', + defaultMessage: 'No answer specified', + description: 'Title for no answer modal', + }, + noAnswerBodyExplanation: { + id: 'authoring.problemEditor.editProblemView.saveWarningModal.noAnswer.body.explanation', + defaultMessage: 'No correct answer has been specified.', + description: 'Explanation in body of no answer modal', + }, + olxSettingDiscrepancyTitle: { + id: 'authoring.problemEditor.editProblemView.saveWarningModal.olxSettingDiscrepancy.title', + defaultMessage: 'OLX settings discrepancy', + description: 'Title for mismatched settings modal', + }, + olxSettingDiscrepancyBodyExplanation: { + id: 'authoring.problemEditor.editProblemView.saveWarningModal.olxSettingDiscrepancy.body.explanation', + defaultMessage: `A discrepancy was found between the settings defined in the OLX's problem tag and the + settings selected in the sidebar. The settings defined in the OLX's problem tag will be saved and + corresponding values in the sidebar will be discarded.`, + description: 'Explanation in body of mismatched settings modal', + }, +}); + +export default messages; diff --git a/src/editors/containers/ProblemEditor/components/SelectTypeModal/SelectTypeWrapper/SelectTypeFooter.jsx b/src/editors/containers/ProblemEditor/components/SelectTypeModal/SelectTypeWrapper/SelectTypeFooter.jsx new file mode 100644 index 0000000000..733f87a1a2 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/SelectTypeModal/SelectTypeWrapper/SelectTypeFooter.jsx @@ -0,0 +1,83 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; + +import { + ActionRow, + Button, + ModalDialog, +} from '@openedx/paragon'; +import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import messages from './messages'; +import * as hooks from '../hooks'; + +import { actions, selectors } from '../../../../../data/redux'; + +const SelectTypeFooter = ({ + onCancel, + selected, + // redux + defaultSettings, + updateField, + setBlockTitle, + // injected, + intl, +}) => ( +
+ + + + + + + +
+); + +SelectTypeFooter.defaultProps = { + selected: null, +}; + +SelectTypeFooter.propTypes = { + defaultSettings: PropTypes.shape({ + maxAttempts: PropTypes.number, + rerandomize: PropTypes.string, + showResetButton: PropTypes.bool, + showanswer: PropTypes.string, + }).isRequired, + onCancel: PropTypes.func.isRequired, + selected: PropTypes.string, + updateField: PropTypes.func.isRequired, + setBlockTitle: PropTypes.func.isRequired, + // injected + intl: intlShape.isRequired, +}; + +export const mapStateToProps = (state) => ({ + defaultSettings: selectors.problem.defaultSettings(state), +}); + +export const mapDispatchToProps = { + updateField: actions.problem.updateField, + setBlockTitle: actions.app.setBlockTitle, +}; + +export const SelectTypeFooterInternal = SelectTypeFooter; // For testing only +export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(SelectTypeFooter)); diff --git a/src/editors/containers/ProblemEditor/components/SelectTypeModal/SelectTypeWrapper/SelectTypeFooter.test.jsx b/src/editors/containers/ProblemEditor/components/SelectTypeModal/SelectTypeWrapper/SelectTypeFooter.test.jsx new file mode 100644 index 0000000000..c00cd02d48 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/SelectTypeModal/SelectTypeWrapper/SelectTypeFooter.test.jsx @@ -0,0 +1,60 @@ +import 'CourseAuthoring/editors/setupEditorTest'; +import React from 'react'; +import { shallow } from '@edx/react-unit-test-utils'; + +import { Button } from '@openedx/paragon'; +import { formatMessage } from '../../../../../testUtils'; +import * as module from './SelectTypeFooter'; +import * as hooks from '../hooks'; +import { actions } from '../../../../../data/redux'; + +const SelectTypeFooter = module.SelectTypeFooterInternal; + +jest.mock('../hooks', () => ({ + onSelect: jest.fn().mockName('onSelect'), +})); + +describe('SelectTypeFooter', () => { + const props = { + onCancel: jest.fn().mockName('onCancel'), + selected: null, + // redux + defaultSettings: {}, + updateField: jest.fn().mockName('UpdateField'), + // inject + intl: { formatMessage }, + }; + + test('snapshot', () => { + expect(shallow().snapshot).toMatchSnapshot(); + }); + + describe('behavior', () => { + let el; + beforeEach(() => { + el = shallow(); + }); + test('close behavior is linked to modal onCancel', () => { + const expected = props.onCancel; + expect(el.instance.findByType(Button)[0].props.onClick) + .toEqual(expected); + }); + test('select behavior is linked to modal onSelect', () => { + const expected = hooks.onSelect(props.selected, props.updateField); + const button = el.instance.findByType(Button); + expect(button[button.length - 1].props.onClick) + .toEqual(expected); + }); + }); + + describe('mapStateToProps', () => { + test('is empty', () => { + expect(module.mapDispatchToProps.defaultSettings).toEqual(actions.problem.defaultSettings); + }); + }); + describe('mapDispatchToProps', () => { + test('loads updateField from problem.updateField actions', () => { + expect(module.mapDispatchToProps.updateField).toEqual(actions.problem.updateField); + }); + }); +}); diff --git a/src/editors/containers/ProblemEditor/components/SelectTypeModal/SelectTypeWrapper/__snapshots__/SelectTypeFooter.test.jsx.snap b/src/editors/containers/ProblemEditor/components/SelectTypeModal/SelectTypeWrapper/__snapshots__/SelectTypeFooter.test.jsx.snap new file mode 100644 index 0000000000..5d136ff50b --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/SelectTypeModal/SelectTypeWrapper/__snapshots__/SelectTypeFooter.test.jsx.snap @@ -0,0 +1,36 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SelectTypeFooter snapshot 1`] = ` +
+ + + + + + + +
+`; diff --git a/src/editors/containers/ProblemEditor/components/SelectTypeModal/SelectTypeWrapper/__snapshots__/index.test.jsx.snap b/src/editors/containers/ProblemEditor/components/SelectTypeModal/SelectTypeWrapper/__snapshots__/index.test.jsx.snap new file mode 100644 index 0000000000..6c9bc201a1 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/SelectTypeModal/SelectTypeWrapper/__snapshots__/index.test.jsx.snap @@ -0,0 +1,37 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SelectTypeWrapper snapshot 1`] = ` +
+ + + +
+ +
+
+
+ +

+ test child +

+
+ +
+`; diff --git a/src/editors/containers/ProblemEditor/components/SelectTypeModal/SelectTypeWrapper/index.jsx b/src/editors/containers/ProblemEditor/components/SelectTypeModal/SelectTypeWrapper/index.jsx new file mode 100644 index 0000000000..f747783f5a --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/SelectTypeModal/SelectTypeWrapper/index.jsx @@ -0,0 +1,55 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { injectIntl, FormattedMessage } from '@edx/frontend-platform/i18n'; +import { Icon, ModalDialog, IconButton } from '@openedx/paragon'; +import { Close } from '@openedx/paragon/icons'; +import SelectTypeFooter from './SelectTypeFooter'; + +import * as hooks from '../../../../EditorContainer/hooks'; +import messages from './messages'; + +const SelectTypeWrapper = ({ + children, + onClose, + selected, +}) => { + const handleCancel = hooks.handleCancel({ onClose }); + + return ( +
+ + + +
+ +
+
+
+ + {children} + + +
+ ); +}; + +SelectTypeWrapper.defaultProps = { + onClose: null, +}; +SelectTypeWrapper.propTypes = { + selected: PropTypes.string.isRequired, + children: PropTypes.node.isRequired, + onClose: PropTypes.func, +}; + +export const SelectTypeWrapperInternal = SelectTypeWrapper; // For testing only +export default injectIntl(SelectTypeWrapper); diff --git a/src/editors/containers/ProblemEditor/components/SelectTypeModal/SelectTypeWrapper/index.test.jsx b/src/editors/containers/ProblemEditor/components/SelectTypeModal/SelectTypeWrapper/index.test.jsx new file mode 100644 index 0000000000..ec2e3a51a6 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/SelectTypeModal/SelectTypeWrapper/index.test.jsx @@ -0,0 +1,34 @@ +import 'CourseAuthoring/editors/setupEditorTest'; +import React from 'react'; +import { shallow } from '@edx/react-unit-test-utils'; +import { IconButton } from '@openedx/paragon'; +import { SelectTypeWrapperInternal as SelectTypeWrapper } from '.'; +import { handleCancel } from '../../../../EditorContainer/hooks'; + +jest.mock('../../../../EditorContainer/hooks', () => ({ + handleCancel: jest.fn().mockName('handleCancel'), +})); + +describe('SelectTypeWrapper', () => { + const props = { + children: (

test child

), + onClose: jest.fn(), + selected: 'iMAsElecTedValUE', + }; + + test('snapshot', () => { + expect(shallow().snapshot).toMatchSnapshot(); + }); + + describe('behavior', () => { + let el; + beforeEach(() => { + el = shallow(); + }); + test('close behavior is linked to modal onClose', () => { + const expected = handleCancel({ onClose: props.onClose }); + expect(el.instance.findByType(IconButton)[0].props.onClick) + .toEqual(expected); + }); + }); +}); diff --git a/src/editors/containers/ProblemEditor/components/SelectTypeModal/SelectTypeWrapper/messages.js b/src/editors/containers/ProblemEditor/components/SelectTypeModal/SelectTypeWrapper/messages.js new file mode 100644 index 0000000000..120968e307 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/SelectTypeModal/SelectTypeWrapper/messages.js @@ -0,0 +1,32 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + + selectTypeTitle: { + id: 'authoring.problemEditor.selectType.title', + defaultMessage: 'Select problem type', + description: 'Title for select problem type modal', + }, + cancelButtonLabel: { + id: 'authoring.problemeditor.selecttype.cancelButton.label', + defaultMessage: 'Cancel', + description: 'Label for cancel button.', + }, + cancelButtonAriaLabel: { + id: 'authoring.problemeditor.selecttype.cancelButton.ariaLabel', + defaultMessage: 'Cancel', + description: 'Screen reader label for cancel button.', + }, + selectButtonLabel: { + id: 'authoring.problemeditor.selecttype.selectButton.label', + defaultMessage: 'Select', + description: 'Label for select button.', + }, + selectButtonAriaLabel: { + id: 'authoring.problemeditor.selecttype.selectButton.ariaLabel', + defaultMessage: 'Select', + description: 'Screen reader label for select button.', + }, +}); + +export default messages; diff --git a/src/editors/containers/ProblemEditor/components/SelectTypeModal/__snapshots__/index.test.jsx.snap b/src/editors/containers/ProblemEditor/components/SelectTypeModal/__snapshots__/index.test.jsx.snap new file mode 100644 index 0000000000..51f08eada8 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/SelectTypeModal/__snapshots__/index.test.jsx.snap @@ -0,0 +1,26 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SelectTypeModal snapshot 1`] = ` + + + + + + + + +`; diff --git a/src/editors/containers/ProblemEditor/components/SelectTypeModal/content/AdvanceTypeSelect.jsx b/src/editors/containers/ProblemEditor/components/SelectTypeModal/content/AdvanceTypeSelect.jsx new file mode 100644 index 0000000000..d60061ddd1 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/SelectTypeModal/content/AdvanceTypeSelect.jsx @@ -0,0 +1,101 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { + Form, + ActionRow, + IconButton, + Icon, + OverlayTrigger, + Tooltip, + Hyperlink, + Col, +} from '@openedx/paragon'; +import { ArrowBack } from '@openedx/paragon/icons'; +import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { AdvanceProblems, ProblemTypeKeys } from '../../../../../data/constants/problem'; +import messages from './messages'; + +const AdvanceTypeSelect = ({ + selected, + setSelected, + // injected + intl, +}) => { + const handleChange = e => { setSelected(e.target.value); }; + return ( + + + + setSelected(ProblemTypeKeys.SINGLESELECT)} /> + + + + + + + + {Object.entries(AdvanceProblems).map(([type, data]) => { + if (data.status !== '') { + return ( + + + {intl.formatMessage(messages.advanceProblemTypeLabel, { problemType: data.title })} + + + +
+ {intl.formatMessage(messages.supportStatusTooltipMessage, { supportStatus: data.status.replace(' ', '_') })} +
+ + )} + > +
+ {intl.formatMessage(messages.problemSupportStatus, { supportStatus: data.status })} +
+
+
+ ); + } + return ( + + + {intl.formatMessage(messages.advanceProblemTypeLabel, { problemType: data.title })} + + + + ); + })} +
+
+ + + + + ); +}; + +AdvanceTypeSelect.defaultProps = { + selected: null, +}; + +AdvanceTypeSelect.propTypes = { + selected: PropTypes.string, + setSelected: PropTypes.func.isRequired, + // injected + intl: intlShape.isRequired, +}; + +export const AdvanceTypeSelectInternal = AdvanceTypeSelect; // For testing only +export default injectIntl(AdvanceTypeSelect); diff --git a/src/editors/containers/ProblemEditor/components/SelectTypeModal/content/AdvanceTypeSelect.test.jsx b/src/editors/containers/ProblemEditor/components/SelectTypeModal/content/AdvanceTypeSelect.test.jsx new file mode 100644 index 0000000000..978b35e7c5 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/SelectTypeModal/content/AdvanceTypeSelect.test.jsx @@ -0,0 +1,58 @@ +import 'CourseAuthoring/editors/setupEditorTest'; +import React from 'react'; +import { shallow } from '@edx/react-unit-test-utils'; + +import { formatMessage } from '../../../../../testUtils'; +import * as module from './AdvanceTypeSelect'; + +const AdvanceTypeSelect = module.AdvanceTypeSelectInternal; + +describe('AdvanceTypeSelect', () => { + const props = { + intl: { formatMessage }, + selected: 'blankadvanced', + setSelected: jest.fn().mockName('setSelect'), + }; + describe('snapshots', () => { + test('snapshots: renders as expected with default props', () => { + expect( + shallow().snapshot, + ).toMatchSnapshot(); + }); + test('snapshots: renders as expected with problemType is circuitschematic', () => { + expect( + shallow().snapshot, + ).toMatchSnapshot(); + }); + test('snapshots: renders as expected with problemType is customgrader', () => { + expect( + shallow().snapshot, + ).toMatchSnapshot(); + }); + test('snapshots: renders as expected with problemType is drag_and_drop', () => { + expect( + shallow().snapshot, + ).toMatchSnapshot(); + }); + test('snapshots: renders as expected with problemType is formularesponse', () => { + expect( + shallow().snapshot, + ).toMatchSnapshot(); + }); + test('snapshots: renders as expected with problemType is imageresponse', () => { + expect( + shallow().snapshot, + ).toMatchSnapshot(); + }); + test('snapshots: renders as expected with problemType is jsinput_response', () => { + expect( + shallow().snapshot, + ).toMatchSnapshot(); + }); + test('snapshots: renders as expected with problemType is problem_with_hint', () => { + expect( + shallow().snapshot, + ).toMatchSnapshot(); + }); + }); +}); diff --git a/src/editors/containers/ProblemEditor/components/SelectTypeModal/content/Preview.jsx b/src/editors/containers/ProblemEditor/components/SelectTypeModal/content/Preview.jsx new file mode 100644 index 0000000000..7077c18a56 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/SelectTypeModal/content/Preview.jsx @@ -0,0 +1,56 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Hyperlink, Image, Container } from '@openedx/paragon'; +import { + FormattedMessage, + injectIntl, + intlShape, +} from '@edx/frontend-platform/i18n'; +import messages from './messages'; +import { ProblemTypes } from '../../../../../data/constants/problem'; + +const Preview = ({ + problemType, + // injected + intl, +}) => { + if (problemType === null) { + return null; + } + const data = ProblemTypes[problemType]; + return ( + +
+ {intl.formatMessage(messages.previewTitle, { previewTitle: data.title })} +
+ {intl.formatMessage(messages.previewAltText, +
+ {intl.formatMessage(messages.previewDescription, { previewDescription: data.previewDescription })} +
+ + + +
+ ); +}; + +Preview.defaultProps = { + problemType: null, +}; + +Preview.propTypes = { + problemType: PropTypes.string, + // injected + intl: intlShape.isRequired, +}; + +export const PreviewInternal = Preview; // For testing only +export default injectIntl(Preview); diff --git a/src/editors/containers/ProblemEditor/components/SelectTypeModal/content/Preview.test.jsx b/src/editors/containers/ProblemEditor/components/SelectTypeModal/content/Preview.test.jsx new file mode 100644 index 0000000000..36cf961fc8 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/SelectTypeModal/content/Preview.test.jsx @@ -0,0 +1,45 @@ +import 'CourseAuthoring/editors/setupEditorTest'; +import React from 'react'; +import { shallow } from '@edx/react-unit-test-utils'; + +import { formatMessage } from '../../../../../testUtils'; +import { PreviewInternal as Preview } from './Preview'; + +describe('Preview', () => { + const props = { + intl: { formatMessage }, + problemType: null, + }; + describe('snapshots', () => { + test('snapshots: renders as expected with default props', () => { + expect( + shallow().snapshot, + ).toMatchSnapshot(); + }); + test('snapshots: renders as expected with problemType is stringresponse', () => { + expect( + shallow().snapshot, + ).toMatchSnapshot(); + }); + test('snapshots: renders as expected with problemType is numericalresponse', () => { + expect( + shallow().snapshot, + ).toMatchSnapshot(); + }); + test('snapshots: renders as expected with problemType is optionresponse', () => { + expect( + shallow().snapshot, + ).toMatchSnapshot(); + }); + test('snapshots: renders as expected with problemType is choiceresponse', () => { + expect( + shallow().snapshot, + ).toMatchSnapshot(); + }); + test('snapshots: renders as expected with problemType is multiplechoiceresponse', () => { + expect( + shallow().snapshot, + ).toMatchSnapshot(); + }); + }); +}); diff --git a/src/editors/containers/ProblemEditor/components/SelectTypeModal/content/ProblemTypeSelect.jsx b/src/editors/containers/ProblemEditor/components/SelectTypeModal/content/ProblemTypeSelect.jsx new file mode 100644 index 0000000000..e58d731af2 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/SelectTypeModal/content/ProblemTypeSelect.jsx @@ -0,0 +1,54 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Button, Container } from '@openedx/paragon'; +import { FormattedMessage, injectIntl } from '@edx/frontend-platform/i18n'; + +// SelectableBox in paragon has a bug where you can't change selection. So we override it +import SelectableBox from '../../../../../sharedComponents/SelectableBox'; +import { ProblemTypes, ProblemTypeKeys, AdvanceProblemKeys } from '../../../../../data/constants/problem'; +import messages from './messages'; + +const ProblemTypeSelect = ({ + selected, + setSelected, +}) => { + const handleChange = e => setSelected(e.target.value); + const handleClick = () => setSelected(AdvanceProblemKeys.BLANK); + const settings = { 'aria-label': 'checkbox', type: 'radio' }; + + return ( + + + {Object.values(ProblemTypeKeys).map((key) => ( + key !== 'advanced' + ? ( + + {ProblemTypes[key].title} + + ) + : null + ))} + + + + ); +}; +ProblemTypeSelect.propTypes = { + selected: PropTypes.string.isRequired, + setSelected: PropTypes.func.isRequired, +}; + +export const ProblemTypeSelectInternal = ProblemTypeSelect; // For testing only +export default injectIntl(ProblemTypeSelect); diff --git a/src/editors/containers/ProblemEditor/components/SelectTypeModal/content/ProblemTypeSelect.test.jsx b/src/editors/containers/ProblemEditor/components/SelectTypeModal/content/ProblemTypeSelect.test.jsx new file mode 100644 index 0000000000..7482f843ef --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/SelectTypeModal/content/ProblemTypeSelect.test.jsx @@ -0,0 +1,40 @@ +import 'CourseAuthoring/editors/setupEditorTest'; +import React from 'react'; +import { shallow } from '@edx/react-unit-test-utils'; +import { ProblemTypeKeys } from '../../../../../data/constants/problem'; +import { ProblemTypeSelectInternal as ProblemTypeSelect } from './ProblemTypeSelect'; + +describe('ProblemTypeSelect', () => { + const props = { + selected: null, + setSelected: jest.fn(), + }; + + describe('snapshot', () => { + test('SINGLESELECT', () => { + expect(shallow( + , + ).snapshot).toMatchSnapshot(); + }); + test('MULTISELECT', () => { + expect(shallow( + , + ).snapshot).toMatchSnapshot(); + }); + test('DROPDOWN', () => { + expect(shallow( + , + ).snapshot).toMatchSnapshot(); + }); + test('NUMERIC', () => { + expect(shallow( + , + ).snapshot).toMatchSnapshot(); + }); + test('TEXTINPUT', () => { + expect(shallow( + , + ).snapshot).toMatchSnapshot(); + }); + }); +}); diff --git a/src/editors/containers/ProblemEditor/components/SelectTypeModal/content/__snapshots__/AdvanceTypeSelect.test.jsx.snap b/src/editors/containers/ProblemEditor/components/SelectTypeModal/content/__snapshots__/AdvanceTypeSelect.test.jsx.snap new file mode 100644 index 0000000000..79a7990579 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/SelectTypeModal/content/__snapshots__/AdvanceTypeSelect.test.jsx.snap @@ -0,0 +1,2065 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AdvanceTypeSelect snapshots snapshots: renders as expected with default props 1`] = ` + + + + + + + + + + + + + + Blank problem + + + + + + Circuit schematic builder + + + +
+ {supportStatus, select, + Provisional {Provisionally supported tools might lack the robustness of functionality + that your courses require. edX does not have control over the quality of the software, + or of the content that can be provided using these tools. + + + + Test these tools thoroughly before using them in your course, especially in graded + sections. Complete documentstion might not be available for provisionally supported + tools, or documentation might be available from sources other than edX.} + Not_supported {Tools with no support are not maintained by edX, and might be deprecated + in the future. They are not recommened for use in courses due to non-compliance with one + or more of the base requirements, such as testing, accessibility, internationalization, + and documentation.} + other { } + } +
+ + } + placement="right" + > +
+ Not supported +
+
+
+ + + Custom JavaScript display and grading + + + + + + Custom Python-evaluated input + + + +
+ {supportStatus, select, + Provisional {Provisionally supported tools might lack the robustness of functionality + that your courses require. edX does not have control over the quality of the software, + or of the content that can be provided using these tools. + + + + Test these tools thoroughly before using them in your course, especially in graded + sections. Complete documentstion might not be available for provisionally supported + tools, or documentation might be available from sources other than edX.} + Not_supported {Tools with no support are not maintained by edX, and might be deprecated + in the future. They are not recommened for use in courses due to non-compliance with one + or more of the base requirements, such as testing, accessibility, internationalization, + and documentation.} + other { } + } +
+ + } + placement="right" + > +
+ Provisional +
+
+
+ + + Image mapped input + + + +
+ {supportStatus, select, + Provisional {Provisionally supported tools might lack the robustness of functionality + that your courses require. edX does not have control over the quality of the software, + or of the content that can be provided using these tools. + + + + Test these tools thoroughly before using them in your course, especially in graded + sections. Complete documentstion might not be available for provisionally supported + tools, or documentation might be available from sources other than edX.} + Not_supported {Tools with no support are not maintained by edX, and might be deprecated + in the future. They are not recommened for use in courses due to non-compliance with one + or more of the base requirements, such as testing, accessibility, internationalization, + and documentation.} + other { } + } +
+ + } + placement="right" + > +
+ Not supported +
+
+
+ + + Math expression input + + + + + + Problem with adaptive hint + + + +
+ {supportStatus, select, + Provisional {Provisionally supported tools might lack the robustness of functionality + that your courses require. edX does not have control over the quality of the software, + or of the content that can be provided using these tools. + + + + Test these tools thoroughly before using them in your course, especially in graded + sections. Complete documentstion might not be available for provisionally supported + tools, or documentation might be available from sources other than edX.} + Not_supported {Tools with no support are not maintained by edX, and might be deprecated + in the future. They are not recommened for use in courses due to non-compliance with one + or more of the base requirements, such as testing, accessibility, internationalization, + and documentation.} + other { } + } +
+ + } + placement="right" + > +
+ Not supported +
+
+
+
+
+ + + + +`; + +exports[`AdvanceTypeSelect snapshots snapshots: renders as expected with problemType is circuitschematic 1`] = ` + + + + + + + + + + + + + + Blank problem + + + + + + Circuit schematic builder + + + +
+ {supportStatus, select, + Provisional {Provisionally supported tools might lack the robustness of functionality + that your courses require. edX does not have control over the quality of the software, + or of the content that can be provided using these tools. + + + + Test these tools thoroughly before using them in your course, especially in graded + sections. Complete documentstion might not be available for provisionally supported + tools, or documentation might be available from sources other than edX.} + Not_supported {Tools with no support are not maintained by edX, and might be deprecated + in the future. They are not recommened for use in courses due to non-compliance with one + or more of the base requirements, such as testing, accessibility, internationalization, + and documentation.} + other { } + } +
+ + } + placement="right" + > +
+ Not supported +
+
+
+ + + Custom JavaScript display and grading + + + + + + Custom Python-evaluated input + + + +
+ {supportStatus, select, + Provisional {Provisionally supported tools might lack the robustness of functionality + that your courses require. edX does not have control over the quality of the software, + or of the content that can be provided using these tools. + + + + Test these tools thoroughly before using them in your course, especially in graded + sections. Complete documentstion might not be available for provisionally supported + tools, or documentation might be available from sources other than edX.} + Not_supported {Tools with no support are not maintained by edX, and might be deprecated + in the future. They are not recommened for use in courses due to non-compliance with one + or more of the base requirements, such as testing, accessibility, internationalization, + and documentation.} + other { } + } +
+ + } + placement="right" + > +
+ Provisional +
+
+
+ + + Image mapped input + + + +
+ {supportStatus, select, + Provisional {Provisionally supported tools might lack the robustness of functionality + that your courses require. edX does not have control over the quality of the software, + or of the content that can be provided using these tools. + + + + Test these tools thoroughly before using them in your course, especially in graded + sections. Complete documentstion might not be available for provisionally supported + tools, or documentation might be available from sources other than edX.} + Not_supported {Tools with no support are not maintained by edX, and might be deprecated + in the future. They are not recommened for use in courses due to non-compliance with one + or more of the base requirements, such as testing, accessibility, internationalization, + and documentation.} + other { } + } +
+ + } + placement="right" + > +
+ Not supported +
+
+
+ + + Math expression input + + + + + + Problem with adaptive hint + + + +
+ {supportStatus, select, + Provisional {Provisionally supported tools might lack the robustness of functionality + that your courses require. edX does not have control over the quality of the software, + or of the content that can be provided using these tools. + + + + Test these tools thoroughly before using them in your course, especially in graded + sections. Complete documentstion might not be available for provisionally supported + tools, or documentation might be available from sources other than edX.} + Not_supported {Tools with no support are not maintained by edX, and might be deprecated + in the future. They are not recommened for use in courses due to non-compliance with one + or more of the base requirements, such as testing, accessibility, internationalization, + and documentation.} + other { } + } +
+ + } + placement="right" + > +
+ Not supported +
+
+
+
+
+ + + + +`; + +exports[`AdvanceTypeSelect snapshots snapshots: renders as expected with problemType is customgrader 1`] = ` + + + + + + + + + + + + + + Blank problem + + + + + + Circuit schematic builder + + + +
+ {supportStatus, select, + Provisional {Provisionally supported tools might lack the robustness of functionality + that your courses require. edX does not have control over the quality of the software, + or of the content that can be provided using these tools. + + + + Test these tools thoroughly before using them in your course, especially in graded + sections. Complete documentstion might not be available for provisionally supported + tools, or documentation might be available from sources other than edX.} + Not_supported {Tools with no support are not maintained by edX, and might be deprecated + in the future. They are not recommened for use in courses due to non-compliance with one + or more of the base requirements, such as testing, accessibility, internationalization, + and documentation.} + other { } + } +
+ + } + placement="right" + > +
+ Not supported +
+
+
+ + + Custom JavaScript display and grading + + + + + + Custom Python-evaluated input + + + +
+ {supportStatus, select, + Provisional {Provisionally supported tools might lack the robustness of functionality + that your courses require. edX does not have control over the quality of the software, + or of the content that can be provided using these tools. + + + + Test these tools thoroughly before using them in your course, especially in graded + sections. Complete documentstion might not be available for provisionally supported + tools, or documentation might be available from sources other than edX.} + Not_supported {Tools with no support are not maintained by edX, and might be deprecated + in the future. They are not recommened for use in courses due to non-compliance with one + or more of the base requirements, such as testing, accessibility, internationalization, + and documentation.} + other { } + } +
+ + } + placement="right" + > +
+ Provisional +
+
+
+ + + Image mapped input + + + +
+ {supportStatus, select, + Provisional {Provisionally supported tools might lack the robustness of functionality + that your courses require. edX does not have control over the quality of the software, + or of the content that can be provided using these tools. + + + + Test these tools thoroughly before using them in your course, especially in graded + sections. Complete documentstion might not be available for provisionally supported + tools, or documentation might be available from sources other than edX.} + Not_supported {Tools with no support are not maintained by edX, and might be deprecated + in the future. They are not recommened for use in courses due to non-compliance with one + or more of the base requirements, such as testing, accessibility, internationalization, + and documentation.} + other { } + } +
+ + } + placement="right" + > +
+ Not supported +
+
+
+ + + Math expression input + + + + + + Problem with adaptive hint + + + +
+ {supportStatus, select, + Provisional {Provisionally supported tools might lack the robustness of functionality + that your courses require. edX does not have control over the quality of the software, + or of the content that can be provided using these tools. + + + + Test these tools thoroughly before using them in your course, especially in graded + sections. Complete documentstion might not be available for provisionally supported + tools, or documentation might be available from sources other than edX.} + Not_supported {Tools with no support are not maintained by edX, and might be deprecated + in the future. They are not recommened for use in courses due to non-compliance with one + or more of the base requirements, such as testing, accessibility, internationalization, + and documentation.} + other { } + } +
+ + } + placement="right" + > +
+ Not supported +
+
+
+
+
+ + + + +`; + +exports[`AdvanceTypeSelect snapshots snapshots: renders as expected with problemType is drag_and_drop 1`] = ` + + + + + + + + + + + + + + Blank problem + + + + + + Circuit schematic builder + + + +
+ {supportStatus, select, + Provisional {Provisionally supported tools might lack the robustness of functionality + that your courses require. edX does not have control over the quality of the software, + or of the content that can be provided using these tools. + + + + Test these tools thoroughly before using them in your course, especially in graded + sections. Complete documentstion might not be available for provisionally supported + tools, or documentation might be available from sources other than edX.} + Not_supported {Tools with no support are not maintained by edX, and might be deprecated + in the future. They are not recommened for use in courses due to non-compliance with one + or more of the base requirements, such as testing, accessibility, internationalization, + and documentation.} + other { } + } +
+ + } + placement="right" + > +
+ Not supported +
+
+
+ + + Custom JavaScript display and grading + + + + + + Custom Python-evaluated input + + + +
+ {supportStatus, select, + Provisional {Provisionally supported tools might lack the robustness of functionality + that your courses require. edX does not have control over the quality of the software, + or of the content that can be provided using these tools. + + + + Test these tools thoroughly before using them in your course, especially in graded + sections. Complete documentstion might not be available for provisionally supported + tools, or documentation might be available from sources other than edX.} + Not_supported {Tools with no support are not maintained by edX, and might be deprecated + in the future. They are not recommened for use in courses due to non-compliance with one + or more of the base requirements, such as testing, accessibility, internationalization, + and documentation.} + other { } + } +
+ + } + placement="right" + > +
+ Provisional +
+
+
+ + + Image mapped input + + + +
+ {supportStatus, select, + Provisional {Provisionally supported tools might lack the robustness of functionality + that your courses require. edX does not have control over the quality of the software, + or of the content that can be provided using these tools. + + + + Test these tools thoroughly before using them in your course, especially in graded + sections. Complete documentstion might not be available for provisionally supported + tools, or documentation might be available from sources other than edX.} + Not_supported {Tools with no support are not maintained by edX, and might be deprecated + in the future. They are not recommened for use in courses due to non-compliance with one + or more of the base requirements, such as testing, accessibility, internationalization, + and documentation.} + other { } + } +
+ + } + placement="right" + > +
+ Not supported +
+
+
+ + + Math expression input + + + + + + Problem with adaptive hint + + + +
+ {supportStatus, select, + Provisional {Provisionally supported tools might lack the robustness of functionality + that your courses require. edX does not have control over the quality of the software, + or of the content that can be provided using these tools. + + + + Test these tools thoroughly before using them in your course, especially in graded + sections. Complete documentstion might not be available for provisionally supported + tools, or documentation might be available from sources other than edX.} + Not_supported {Tools with no support are not maintained by edX, and might be deprecated + in the future. They are not recommened for use in courses due to non-compliance with one + or more of the base requirements, such as testing, accessibility, internationalization, + and documentation.} + other { } + } +
+ + } + placement="right" + > +
+ Not supported +
+
+
+
+
+ + + + +`; + +exports[`AdvanceTypeSelect snapshots snapshots: renders as expected with problemType is formularesponse 1`] = ` + + + + + + + + + + + + + + Blank problem + + + + + + Circuit schematic builder + + + +
+ {supportStatus, select, + Provisional {Provisionally supported tools might lack the robustness of functionality + that your courses require. edX does not have control over the quality of the software, + or of the content that can be provided using these tools. + + + + Test these tools thoroughly before using them in your course, especially in graded + sections. Complete documentstion might not be available for provisionally supported + tools, or documentation might be available from sources other than edX.} + Not_supported {Tools with no support are not maintained by edX, and might be deprecated + in the future. They are not recommened for use in courses due to non-compliance with one + or more of the base requirements, such as testing, accessibility, internationalization, + and documentation.} + other { } + } +
+ + } + placement="right" + > +
+ Not supported +
+
+
+ + + Custom JavaScript display and grading + + + + + + Custom Python-evaluated input + + + +
+ {supportStatus, select, + Provisional {Provisionally supported tools might lack the robustness of functionality + that your courses require. edX does not have control over the quality of the software, + or of the content that can be provided using these tools. + + + + Test these tools thoroughly before using them in your course, especially in graded + sections. Complete documentstion might not be available for provisionally supported + tools, or documentation might be available from sources other than edX.} + Not_supported {Tools with no support are not maintained by edX, and might be deprecated + in the future. They are not recommened for use in courses due to non-compliance with one + or more of the base requirements, such as testing, accessibility, internationalization, + and documentation.} + other { } + } +
+ + } + placement="right" + > +
+ Provisional +
+
+
+ + + Image mapped input + + + +
+ {supportStatus, select, + Provisional {Provisionally supported tools might lack the robustness of functionality + that your courses require. edX does not have control over the quality of the software, + or of the content that can be provided using these tools. + + + + Test these tools thoroughly before using them in your course, especially in graded + sections. Complete documentstion might not be available for provisionally supported + tools, or documentation might be available from sources other than edX.} + Not_supported {Tools with no support are not maintained by edX, and might be deprecated + in the future. They are not recommened for use in courses due to non-compliance with one + or more of the base requirements, such as testing, accessibility, internationalization, + and documentation.} + other { } + } +
+ + } + placement="right" + > +
+ Not supported +
+
+
+ + + Math expression input + + + + + + Problem with adaptive hint + + + +
+ {supportStatus, select, + Provisional {Provisionally supported tools might lack the robustness of functionality + that your courses require. edX does not have control over the quality of the software, + or of the content that can be provided using these tools. + + + + Test these tools thoroughly before using them in your course, especially in graded + sections. Complete documentstion might not be available for provisionally supported + tools, or documentation might be available from sources other than edX.} + Not_supported {Tools with no support are not maintained by edX, and might be deprecated + in the future. They are not recommened for use in courses due to non-compliance with one + or more of the base requirements, such as testing, accessibility, internationalization, + and documentation.} + other { } + } +
+ + } + placement="right" + > +
+ Not supported +
+
+
+
+
+ + + + +`; + +exports[`AdvanceTypeSelect snapshots snapshots: renders as expected with problemType is imageresponse 1`] = ` + + + + + + + + + + + + + + Blank problem + + + + + + Circuit schematic builder + + + +
+ {supportStatus, select, + Provisional {Provisionally supported tools might lack the robustness of functionality + that your courses require. edX does not have control over the quality of the software, + or of the content that can be provided using these tools. + + + + Test these tools thoroughly before using them in your course, especially in graded + sections. Complete documentstion might not be available for provisionally supported + tools, or documentation might be available from sources other than edX.} + Not_supported {Tools with no support are not maintained by edX, and might be deprecated + in the future. They are not recommened for use in courses due to non-compliance with one + or more of the base requirements, such as testing, accessibility, internationalization, + and documentation.} + other { } + } +
+ + } + placement="right" + > +
+ Not supported +
+
+
+ + + Custom JavaScript display and grading + + + + + + Custom Python-evaluated input + + + +
+ {supportStatus, select, + Provisional {Provisionally supported tools might lack the robustness of functionality + that your courses require. edX does not have control over the quality of the software, + or of the content that can be provided using these tools. + + + + Test these tools thoroughly before using them in your course, especially in graded + sections. Complete documentstion might not be available for provisionally supported + tools, or documentation might be available from sources other than edX.} + Not_supported {Tools with no support are not maintained by edX, and might be deprecated + in the future. They are not recommened for use in courses due to non-compliance with one + or more of the base requirements, such as testing, accessibility, internationalization, + and documentation.} + other { } + } +
+ + } + placement="right" + > +
+ Provisional +
+
+
+ + + Image mapped input + + + +
+ {supportStatus, select, + Provisional {Provisionally supported tools might lack the robustness of functionality + that your courses require. edX does not have control over the quality of the software, + or of the content that can be provided using these tools. + + + + Test these tools thoroughly before using them in your course, especially in graded + sections. Complete documentstion might not be available for provisionally supported + tools, or documentation might be available from sources other than edX.} + Not_supported {Tools with no support are not maintained by edX, and might be deprecated + in the future. They are not recommened for use in courses due to non-compliance with one + or more of the base requirements, such as testing, accessibility, internationalization, + and documentation.} + other { } + } +
+ + } + placement="right" + > +
+ Not supported +
+
+
+ + + Math expression input + + + + + + Problem with adaptive hint + + + +
+ {supportStatus, select, + Provisional {Provisionally supported tools might lack the robustness of functionality + that your courses require. edX does not have control over the quality of the software, + or of the content that can be provided using these tools. + + + + Test these tools thoroughly before using them in your course, especially in graded + sections. Complete documentstion might not be available for provisionally supported + tools, or documentation might be available from sources other than edX.} + Not_supported {Tools with no support are not maintained by edX, and might be deprecated + in the future. They are not recommened for use in courses due to non-compliance with one + or more of the base requirements, such as testing, accessibility, internationalization, + and documentation.} + other { } + } +
+ + } + placement="right" + > +
+ Not supported +
+
+
+
+
+ + + + +`; + +exports[`AdvanceTypeSelect snapshots snapshots: renders as expected with problemType is jsinput_response 1`] = ` + + + + + + + + + + + + + + Blank problem + + + + + + Circuit schematic builder + + + +
+ {supportStatus, select, + Provisional {Provisionally supported tools might lack the robustness of functionality + that your courses require. edX does not have control over the quality of the software, + or of the content that can be provided using these tools. + + + + Test these tools thoroughly before using them in your course, especially in graded + sections. Complete documentstion might not be available for provisionally supported + tools, or documentation might be available from sources other than edX.} + Not_supported {Tools with no support are not maintained by edX, and might be deprecated + in the future. They are not recommened for use in courses due to non-compliance with one + or more of the base requirements, such as testing, accessibility, internationalization, + and documentation.} + other { } + } +
+ + } + placement="right" + > +
+ Not supported +
+
+
+ + + Custom JavaScript display and grading + + + + + + Custom Python-evaluated input + + + +
+ {supportStatus, select, + Provisional {Provisionally supported tools might lack the robustness of functionality + that your courses require. edX does not have control over the quality of the software, + or of the content that can be provided using these tools. + + + + Test these tools thoroughly before using them in your course, especially in graded + sections. Complete documentstion might not be available for provisionally supported + tools, or documentation might be available from sources other than edX.} + Not_supported {Tools with no support are not maintained by edX, and might be deprecated + in the future. They are not recommened for use in courses due to non-compliance with one + or more of the base requirements, such as testing, accessibility, internationalization, + and documentation.} + other { } + } +
+ + } + placement="right" + > +
+ Provisional +
+
+
+ + + Image mapped input + + + +
+ {supportStatus, select, + Provisional {Provisionally supported tools might lack the robustness of functionality + that your courses require. edX does not have control over the quality of the software, + or of the content that can be provided using these tools. + + + + Test these tools thoroughly before using them in your course, especially in graded + sections. Complete documentstion might not be available for provisionally supported + tools, or documentation might be available from sources other than edX.} + Not_supported {Tools with no support are not maintained by edX, and might be deprecated + in the future. They are not recommened for use in courses due to non-compliance with one + or more of the base requirements, such as testing, accessibility, internationalization, + and documentation.} + other { } + } +
+ + } + placement="right" + > +
+ Not supported +
+
+
+ + + Math expression input + + + + + + Problem with adaptive hint + + + +
+ {supportStatus, select, + Provisional {Provisionally supported tools might lack the robustness of functionality + that your courses require. edX does not have control over the quality of the software, + or of the content that can be provided using these tools. + + + + Test these tools thoroughly before using them in your course, especially in graded + sections. Complete documentstion might not be available for provisionally supported + tools, or documentation might be available from sources other than edX.} + Not_supported {Tools with no support are not maintained by edX, and might be deprecated + in the future. They are not recommened for use in courses due to non-compliance with one + or more of the base requirements, such as testing, accessibility, internationalization, + and documentation.} + other { } + } +
+ + } + placement="right" + > +
+ Not supported +
+
+
+
+
+ + + + +`; + +exports[`AdvanceTypeSelect snapshots snapshots: renders as expected with problemType is problem_with_hint 1`] = ` + + + + + + + + + + + + + + Blank problem + + + + + + Circuit schematic builder + + + +
+ {supportStatus, select, + Provisional {Provisionally supported tools might lack the robustness of functionality + that your courses require. edX does not have control over the quality of the software, + or of the content that can be provided using these tools. + + + + Test these tools thoroughly before using them in your course, especially in graded + sections. Complete documentstion might not be available for provisionally supported + tools, or documentation might be available from sources other than edX.} + Not_supported {Tools with no support are not maintained by edX, and might be deprecated + in the future. They are not recommened for use in courses due to non-compliance with one + or more of the base requirements, such as testing, accessibility, internationalization, + and documentation.} + other { } + } +
+ + } + placement="right" + > +
+ Not supported +
+
+
+ + + Custom JavaScript display and grading + + + + + + Custom Python-evaluated input + + + +
+ {supportStatus, select, + Provisional {Provisionally supported tools might lack the robustness of functionality + that your courses require. edX does not have control over the quality of the software, + or of the content that can be provided using these tools. + + + + Test these tools thoroughly before using them in your course, especially in graded + sections. Complete documentstion might not be available for provisionally supported + tools, or documentation might be available from sources other than edX.} + Not_supported {Tools with no support are not maintained by edX, and might be deprecated + in the future. They are not recommened for use in courses due to non-compliance with one + or more of the base requirements, such as testing, accessibility, internationalization, + and documentation.} + other { } + } +
+ + } + placement="right" + > +
+ Provisional +
+
+
+ + + Image mapped input + + + +
+ {supportStatus, select, + Provisional {Provisionally supported tools might lack the robustness of functionality + that your courses require. edX does not have control over the quality of the software, + or of the content that can be provided using these tools. + + + + Test these tools thoroughly before using them in your course, especially in graded + sections. Complete documentstion might not be available for provisionally supported + tools, or documentation might be available from sources other than edX.} + Not_supported {Tools with no support are not maintained by edX, and might be deprecated + in the future. They are not recommened for use in courses due to non-compliance with one + or more of the base requirements, such as testing, accessibility, internationalization, + and documentation.} + other { } + } +
+ + } + placement="right" + > +
+ Not supported +
+
+
+ + + Math expression input + + + + + + Problem with adaptive hint + + + +
+ {supportStatus, select, + Provisional {Provisionally supported tools might lack the robustness of functionality + that your courses require. edX does not have control over the quality of the software, + or of the content that can be provided using these tools. + + + + Test these tools thoroughly before using them in your course, especially in graded + sections. Complete documentstion might not be available for provisionally supported + tools, or documentation might be available from sources other than edX.} + Not_supported {Tools with no support are not maintained by edX, and might be deprecated + in the future. They are not recommened for use in courses due to non-compliance with one + or more of the base requirements, such as testing, accessibility, internationalization, + and documentation.} + other { } + } +
+ + } + placement="right" + > +
+ Not supported +
+
+
+
+
+ + + + +`; diff --git a/src/editors/containers/ProblemEditor/components/SelectTypeModal/content/__snapshots__/Preview.test.jsx.snap b/src/editors/containers/ProblemEditor/components/SelectTypeModal/content/__snapshots__/Preview.test.jsx.snap new file mode 100644 index 0000000000..16dd56b0f8 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/SelectTypeModal/content/__snapshots__/Preview.test.jsx.snap @@ -0,0 +1,233 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Preview snapshots snapshots: renders as expected with default props 1`] = `null`; + +exports[`Preview snapshots snapshots: renders as expected with problemType is choiceresponse 1`] = ` + +
+ Multi-select problem +
+ A preview illustration of a {problemType, select,
+      multiplechoiceresponse {single select}
+      stringreponse {text input}
+      numericalresponse {numerical input}
+      optionresponse {dropdown}
+      choiceresponse {multiple select}
+      other {null}
+    } problem +
+ Learners must select all correct answers from a list of possible options. +
+ + + +
+`; + +exports[`Preview snapshots snapshots: renders as expected with problemType is multiplechoiceresponse 1`] = ` + +
+ Single select problem +
+ A preview illustration of a {problemType, select,
+      multiplechoiceresponse {single select}
+      stringreponse {text input}
+      numericalresponse {numerical input}
+      optionresponse {dropdown}
+      choiceresponse {multiple select}
+      other {null}
+    } problem +
+ Learners must select the correct answer from a list of possible options. +
+ + + +
+`; + +exports[`Preview snapshots snapshots: renders as expected with problemType is numericalresponse 1`] = ` + +
+ Numerical input problem +
+ A preview illustration of a {problemType, select,
+      multiplechoiceresponse {single select}
+      stringreponse {text input}
+      numericalresponse {numerical input}
+      optionresponse {dropdown}
+      choiceresponse {multiple select}
+      other {null}
+    } problem +
+ Specify one or more correct numeric answers, submitted in a response field. +
+ + + +
+`; + +exports[`Preview snapshots snapshots: renders as expected with problemType is optionresponse 1`] = ` + +
+ Dropdown problem +
+ A preview illustration of a {problemType, select,
+      multiplechoiceresponse {single select}
+      stringreponse {text input}
+      numericalresponse {numerical input}
+      optionresponse {dropdown}
+      choiceresponse {multiple select}
+      other {null}
+    } problem +
+ Learners must select the correct answer from a list of possible options +
+ + + +
+`; + +exports[`Preview snapshots snapshots: renders as expected with problemType is stringresponse 1`] = ` + +
+ Text input problem +
+ A preview illustration of a {problemType, select,
+      multiplechoiceresponse {single select}
+      stringreponse {text input}
+      numericalresponse {numerical input}
+      optionresponse {dropdown}
+      choiceresponse {multiple select}
+      other {null}
+    } problem +
+ Specify one or more correct text answers, including numbers and special characters, submitted in a response field. +
+ + + +
+`; diff --git a/src/editors/containers/ProblemEditor/components/SelectTypeModal/content/__snapshots__/ProblemTypeSelect.test.jsx.snap b/src/editors/containers/ProblemEditor/components/SelectTypeModal/content/__snapshots__/ProblemTypeSelect.test.jsx.snap new file mode 100644 index 0000000000..157708524f --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/SelectTypeModal/content/__snapshots__/ProblemTypeSelect.test.jsx.snap @@ -0,0 +1,526 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ProblemTypeSelect snapshot DROPDOWN 1`] = ` + + + + Single select + + + Multi-select + + + Dropdown + + + Numerical input + + + Text input + + + + +`; + +exports[`ProblemTypeSelect snapshot MULTISELECT 1`] = ` + + + + Single select + + + Multi-select + + + Dropdown + + + Numerical input + + + Text input + + + + +`; + +exports[`ProblemTypeSelect snapshot NUMERIC 1`] = ` + + + + Single select + + + Multi-select + + + Dropdown + + + Numerical input + + + Text input + + + + +`; + +exports[`ProblemTypeSelect snapshot SINGLESELECT 1`] = ` + + + + Single select + + + Multi-select + + + Dropdown + + + Numerical input + + + Text input + + + + +`; + +exports[`ProblemTypeSelect snapshot TEXTINPUT 1`] = ` + + + + Single select + + + Multi-select + + + Dropdown + + + Numerical input + + + Text input + + + + +`; diff --git a/src/editors/containers/ProblemEditor/components/SelectTypeModal/content/messages.js b/src/editors/containers/ProblemEditor/components/SelectTypeModal/content/messages.js new file mode 100644 index 0000000000..508d665f15 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/SelectTypeModal/content/messages.js @@ -0,0 +1,77 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + + advanceProblemButtonLabel: { + id: 'authoring.problemEditor.problemSelect.advanceButton.label', + defaultMessage: 'Advanced problem types', + description: 'Button label for advance problem types option', + }, + advanceMenuTitle: { + id: 'authoring.problemEditor.advanceProblem.menu.title', + defaultMessage: 'Advanced problems', + description: 'Title for advanced problem menu', + }, + advanceProblemTypeLabel: { + id: 'authoring.problemEditor.advanceProblem.problemType.label', + defaultMessage: '{problemType}', + description: 'Label for advance problem type radio select', + }, + problemSupportStatus: { + id: 'authoring.problemEditor.advanceProblem.supportStatus', + defaultMessage: '{supportStatus}', + description: 'Text for advance problem type\'s support status', + }, + supportStatusTooltipMessage: { + id: 'authoring.problemEditor.advanceProblem.supportStatus.tooltipMessage', + defaultMessage: `{supportStatus, select, + Provisional {Provisionally supported tools might lack the robustness of functionality + that your courses require. edX does not have control over the quality of the software, + or of the content that can be provided using these tools. + \n \n + Test these tools thoroughly before using them in your course, especially in graded + sections. Complete documentstion might not be available for provisionally supported + tools, or documentation might be available from sources other than edX.} + Not_supported {Tools with no support are not maintained by edX, and might be deprecated + in the future. They are not recommened for use in courses due to non-compliance with one + or more of the base requirements, such as testing, accessibility, internationalization, + and documentation.} + other { } + }`, + description: 'Message for support status tooltip', + }, + previewTitle: { + id: 'authoring.problemEditor.preview.title', + defaultMessage: '{previewTitle} problem', + description: 'Title for the problem preview column', + }, + previewAltText: { + id: 'authoring.problemEditor.preview.altText', + defaultMessage: `A preview illustration of a {problemType, select, + multiplechoiceresponse {single select} + stringreponse {text input} + numericalresponse {numerical input} + optionresponse {dropdown} + choiceresponse {multiple select} + other {null} + } problem`, + description: 'Alt text for the illustration of the problem preview', + }, + previewDescription: { + id: 'authoring.problemEditor.preview.description', + defaultMessage: '{previewDescription}', + description: 'Description of the selected problem type', + }, + learnMoreButtonLabel: { + id: 'authoring.problemEditor.learnMoreButtonLabel.label', + defaultMessage: 'Learn more', + description: 'Label for Learn more button', + }, + learnMoreAdvancedButtonLabel: { + id: 'authoring.problemEditor.advanceProblem.learnMoreButtonLabel.label', + defaultMessage: 'Learn more about advanced problem types', + description: 'Label for Learn more about advanced problem types button', + }, +}); + +export default messages; diff --git a/src/editors/containers/ProblemEditor/components/SelectTypeModal/hooks.js b/src/editors/containers/ProblemEditor/components/SelectTypeModal/hooks.js new file mode 100644 index 0000000000..d25c028aaa --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/SelectTypeModal/hooks.js @@ -0,0 +1,78 @@ +import { useEffect, useState } from 'react'; +import { + AdvanceProblemKeys, AdvanceProblems, ProblemTypeKeys, ProblemTypes, +} from '../../../../data/constants/problem'; +import { StrictDict, snakeCaseKeys } from '../../../../utils'; +// This 'module' self-import hack enables mocking during tests. +// See src/editors/decisions/0005-internal-editor-testability-decisions.md. The whole approach to how hooks are tested +// should be re-thought and cleaned up to avoid this pattern. +// eslint-disable-next-line import/no-self-import +import * as module from './hooks'; +import { getDataFromOlx } from '../../../../data/redux/thunkActions/problem'; + +export const state = StrictDict({ + // eslint-disable-next-line react-hooks/rules-of-hooks + selected: (val) => useState(val), +}); + +export const selectHooks = () => { + const [selected, setSelected] = module.state.selected(ProblemTypeKeys.SINGLESELECT); + return { + selected, + setSelected, + }; +}; + +export const onSelect = ({ + selected, + updateField, + setBlockTitle, + defaultSettings, +}) => () => { + if (Object.values(AdvanceProblemKeys).includes(selected)) { + updateField({ problemType: ProblemTypeKeys.ADVANCED, rawOLX: AdvanceProblems[selected].template }); + setBlockTitle(AdvanceProblems[selected].title); + } else { + const newOLX = ProblemTypes[selected].template; + const newState = getDataFromOlx({ + rawOLX: newOLX, + rawSettings: { + weight: 1, + attempts_before_showanswer_button: 0, + show_reset_button: null, + showanswer: null, + defaultToAdvanced: false, + }, + defaultSettings: snakeCaseKeys(defaultSettings), + }); + updateField(newState); + setBlockTitle(ProblemTypes[selected].title); + } +}; + +export const useArrowNav = (selected, setSelected) => { + const detectKeyDown = (e) => { + const problemTypeValues = Object.values(ProblemTypeKeys); + switch (e.key) { + case 'ArrowUp': + if (problemTypeValues.includes(selected) && ProblemTypes[selected].prev) { + setSelected(ProblemTypes[selected].prev); + document.getElementById(ProblemTypes[selected].prev).focus(); + } + break; + case 'ArrowDown': + if (problemTypeValues.includes(selected) && ProblemTypes[selected].next) { + setSelected(ProblemTypes[selected].next); + document.getElementById(ProblemTypes[selected].next).focus(); + } + break; + default: + } + }; + useEffect(() => { + document.addEventListener('keydown', detectKeyDown, true); + return () => { + document.removeEventListener('keydown', detectKeyDown, true); + }; + }, [selected, setSelected]); +}; diff --git a/src/editors/containers/ProblemEditor/components/SelectTypeModal/hooks.test.js b/src/editors/containers/ProblemEditor/components/SelectTypeModal/hooks.test.js new file mode 100644 index 0000000000..e0582b5fb2 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/SelectTypeModal/hooks.test.js @@ -0,0 +1,203 @@ +/* eslint-disable prefer-destructuring */ +import React from 'react'; +import { MockUseState } from '../../../../testUtils'; +// This 'module' self-import hack enables mocking during tests. +// See src/editors/decisions/0005-internal-editor-testability-decisions.md. The whole approach to how hooks are tested +// should be re-thought and cleaned up to avoid this pattern. +// eslint-disable-next-line import/no-self-import +import * as module from './hooks'; +import { AdvanceProblems, ProblemTypeKeys, ProblemTypes } from '../../../../data/constants/problem'; +import { getDataFromOlx } from '../../../../data/redux/thunkActions/problem'; + +jest.mock('react', () => ({ + ...jest.requireActual('react'), + useState: (val) => ({ useState: val }), + useEffect: jest.fn(), +})); + +const state = new MockUseState(module); +const mockUpdateField = jest.fn().mockName('updateField'); +const mockSelected = 'multiplechoiceresponse'; +const mockAdvancedSelected = 'circuitschematic'; +const mockSetSelected = jest.fn().mockName('setSelected'); +const mocksetBlockTitle = jest.fn().mockName('setBlockTitle'); +const mockDefaultSettings = { + max_attempts: null, + rerandomize: 'never', + showR_reset_button: false, + showanswer: 'always', +}; + +let hook; + +describe('SelectTypeModal hooks', () => { + beforeEach(() => { + state.mock(); + }); + afterEach(() => { + state.restore(); + jest.clearAllMocks(); + }); + + describe('selectHooks', () => { + beforeEach(() => { + hook = module.selectHooks(); + }); + test('selected defaults to SINGLESELECT', () => { + expect(hook.selected).toEqual(ProblemTypeKeys.SINGLESELECT); + }); + test('setSelected sets state as expected', () => { + const expectedArg = 'neWvAl'; + state.mockVal(state.keys.selected, 'mOcKvAl'); + hook.setSelected(expectedArg); + expect(state.setState.selected).toHaveBeenCalledWith(expectedArg); + }); + }); + + describe('onSelect', () => { + test('updateField is called with selected templated if selected is an Advanced Problem', () => { + module.onSelect({ + selected: mockAdvancedSelected, + updateField: mockUpdateField, + setBlockTitle: mocksetBlockTitle, + })(); + expect(mockUpdateField).toHaveBeenCalledWith({ + problemType: ProblemTypeKeys.ADVANCED, + rawOLX: AdvanceProblems[mockAdvancedSelected].template, + }); + expect(mocksetBlockTitle).toHaveBeenCalledWith(AdvanceProblems[mockAdvancedSelected].title); + }); + test('updateField is called with selected on visual propblems', () => { + module.onSelect({ + selected: mockSelected, + updateField: mockUpdateField, + setBlockTitle: mocksetBlockTitle, + defaultSettings: mockDefaultSettings, + })(); + // const testOlXParser = new OLXParser(ProblemTypes[mockSelected].template); + const testState = getDataFromOlx({ + rawOLX: ProblemTypes[mockSelected].template, + rawSettings: { + weight: 1, + attempts_before_showanswer_button: 0, + show_reset_button: null, + showanswer: null, + defaultToAdvanced: false, + }, + defaultSettings: mockDefaultSettings, + }); + expect(mockUpdateField).toHaveBeenCalledWith(testState); + expect(mocksetBlockTitle).toHaveBeenCalledWith(ProblemTypes[mockSelected].title); + }); + }); + + describe('useArrowNav', () => { + document.body.innerHTML = ` +
+
+
+
+
+ `; + const mockKeyUp = new KeyboardEvent('keydown', { key: 'ArrowUp' }); + const mockKeyDown = new KeyboardEvent('keydown', { key: 'ArrowDown' }); + let cb; + let prereqs; + + describe('SINGLESELECT', () => { + beforeEach(() => { + module.useArrowNav(ProblemTypeKeys.SINGLESELECT, mockSetSelected); + [cb, prereqs] = React.useEffect.mock.calls[0]; + cb(); + }); + test('pressing up arrow sets MULTISELECT', () => { + expect(React.useEffect.mock.calls.length).toEqual(1); + expect(prereqs).toStrictEqual([ProblemTypeKeys.SINGLESELECT, mockSetSelected]); + document.dispatchEvent(mockKeyUp); + expect(mockSetSelected).toHaveBeenCalledWith(ProblemTypeKeys.TEXTINPUT); + }); + test('pressing down arrow sets MULTISELECT', () => { + expect(React.useEffect.mock.calls.length).toEqual(1); + expect(prereqs).toStrictEqual([ProblemTypeKeys.SINGLESELECT, mockSetSelected]); + document.dispatchEvent(mockKeyDown); + expect(mockSetSelected).toHaveBeenCalledWith(ProblemTypeKeys.MULTISELECT); + }); + }); + describe('MULTISELECT', () => { + beforeEach(() => { + module.useArrowNav(ProblemTypeKeys.MULTISELECT, mockSetSelected); + [cb, prereqs] = React.useEffect.mock.calls[0]; + cb(); + }); + test('pressing up arrow sets SINGLESELECT', () => { + expect(React.useEffect.mock.calls.length).toEqual(1); + expect(prereqs).toStrictEqual([ProblemTypeKeys.MULTISELECT, mockSetSelected]); + document.dispatchEvent(mockKeyUp); + expect(mockSetSelected).toHaveBeenCalledWith(ProblemTypeKeys.SINGLESELECT); + }); + test('pressing down arrow sets DROPDOWN', () => { + expect(React.useEffect.mock.calls.length).toEqual(1); + expect(prereqs).toStrictEqual([ProblemTypeKeys.MULTISELECT, mockSetSelected]); + document.dispatchEvent(mockKeyDown); + expect(mockSetSelected).toHaveBeenCalledWith(ProblemTypeKeys.DROPDOWN); + }); + }); + describe('DROPDOWN', () => { + beforeEach(() => { + module.useArrowNav(ProblemTypeKeys.DROPDOWN, mockSetSelected); + [cb, prereqs] = React.useEffect.mock.calls[0]; + cb(); + }); + test('pressing up arrow sets MULTISELECT', () => { + expect(React.useEffect.mock.calls.length).toEqual(1); + expect(prereqs).toStrictEqual([ProblemTypeKeys.DROPDOWN, mockSetSelected]); + document.dispatchEvent(mockKeyUp); + expect(mockSetSelected).toHaveBeenCalledWith(ProblemTypeKeys.MULTISELECT); + }); + test('pressing down arrow sets NUMERIC', () => { + expect(React.useEffect.mock.calls.length).toEqual(1); + expect(prereqs).toStrictEqual([ProblemTypeKeys.DROPDOWN, mockSetSelected]); + document.dispatchEvent(mockKeyDown); + expect(mockSetSelected).toHaveBeenCalledWith(ProblemTypeKeys.NUMERIC); + }); + }); + describe('NUMERIC', () => { + beforeEach(() => { + module.useArrowNav(ProblemTypeKeys.NUMERIC, mockSetSelected); + [cb, prereqs] = React.useEffect.mock.calls[0]; + cb(); + }); + test('pressing up arrow sets DROPDOWN', () => { + expect(React.useEffect.mock.calls.length).toEqual(1); + expect(prereqs).toStrictEqual([ProblemTypeKeys.NUMERIC, mockSetSelected]); + document.dispatchEvent(mockKeyUp); + expect(mockSetSelected).toHaveBeenCalledWith(ProblemTypeKeys.DROPDOWN); + }); + test('pressing down arrow sets TEXTINPUT', () => { + expect(React.useEffect.mock.calls.length).toEqual(1); + expect(prereqs).toStrictEqual([ProblemTypeKeys.NUMERIC, mockSetSelected]); + document.dispatchEvent(mockKeyDown); + expect(mockSetSelected).toHaveBeenCalledWith(ProblemTypeKeys.TEXTINPUT); + }); + }); + describe('TEXTINPUT', () => { + beforeEach(() => { + module.useArrowNav(ProblemTypeKeys.TEXTINPUT, mockSetSelected); + [cb, prereqs] = React.useEffect.mock.calls[0]; + cb(); + }); + test('pressing up arrow sets NUMERIC', () => { + expect(React.useEffect.mock.calls.length).toEqual(1); + expect(prereqs).toStrictEqual([ProblemTypeKeys.TEXTINPUT, mockSetSelected]); + document.dispatchEvent(mockKeyUp); + expect(mockSetSelected).toHaveBeenCalledWith(ProblemTypeKeys.NUMERIC); + }); + test('pressing down arrow sets SINGLESELECT', () => { + expect(React.useEffect.mock.calls.length).toEqual(1); + expect(prereqs).toStrictEqual([ProblemTypeKeys.TEXTINPUT, mockSetSelected]); + document.dispatchEvent(mockKeyDown); + expect(mockSetSelected).toHaveBeenCalledWith(ProblemTypeKeys.SINGLESELECT); + }); + }); + }); +}); diff --git a/src/editors/containers/ProblemEditor/components/SelectTypeModal/index.jsx b/src/editors/containers/ProblemEditor/components/SelectTypeModal/index.jsx new file mode 100644 index 0000000000..828dc4be05 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/SelectTypeModal/index.jsx @@ -0,0 +1,36 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { Row, Stack } from '@openedx/paragon'; +import ProblemTypeSelect from './content/ProblemTypeSelect'; +import Preview from './content/Preview'; +import AdvanceTypeSelect from './content/AdvanceTypeSelect'; +import SelectTypeWrapper from './SelectTypeWrapper'; +import * as hooks from './hooks'; +import { AdvanceProblemKeys } from '../../../../data/constants/problem'; + +const SelectTypeModal = ({ + onClose, +}) => { + const { selected, setSelected } = hooks.selectHooks(); + hooks.useArrowNav(selected, setSelected); + + return ( + + + {(!Object.values(AdvanceProblemKeys).includes(selected)) ? ( + + + + + ) : } + + + ); +}; + +SelectTypeModal.propTypes = { + onClose: PropTypes.func.isRequired, +}; + +export default SelectTypeModal; diff --git a/src/editors/containers/ProblemEditor/components/SelectTypeModal/index.test.jsx b/src/editors/containers/ProblemEditor/components/SelectTypeModal/index.test.jsx new file mode 100644 index 0000000000..4574c1b799 --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/SelectTypeModal/index.test.jsx @@ -0,0 +1,22 @@ +import 'CourseAuthoring/editors/setupEditorTest'; +import React from 'react'; +import { shallow } from '@edx/react-unit-test-utils'; +import SelectTypeModal from '.'; + +jest.mock('./hooks', () => ({ + selectHooks: jest.fn(() => ({ + selected: 'mOcKsELEcted', + setSelected: jest.fn().mockName('setSelected'), + })), + useArrowNav: jest.fn().mockName('useArrowNav'), +})); + +describe('SelectTypeModal', () => { + const props = { + onClose: jest.fn(), + }; + + test('snapshot', () => { + expect(shallow().snapshot).toMatchSnapshot(); + }); +}); diff --git a/src/editors/containers/ProblemEditor/data/OLXParser.js b/src/editors/containers/ProblemEditor/data/OLXParser.js new file mode 100644 index 0000000000..8a5e290507 --- /dev/null +++ b/src/editors/containers/ProblemEditor/data/OLXParser.js @@ -0,0 +1,750 @@ +// Parse OLX to JavaScript objects. +/* eslint no-eval: 0 */ + +import { XMLParser, XMLBuilder } from 'fast-xml-parser'; +import _ from 'lodash'; +import { ProblemTypeKeys, RichTextProblems, settingsOlxAttributes } from '../../../data/constants/problem'; + +export const indexToLetterMap = [...Array(26)].map((val, i) => String.fromCharCode(i + 65)); + +export const nonQuestionKeys = [ + '@_answer', + '@_type', + 'additional_answer', + 'checkboxgroup', + 'choicegroup', + 'choiceresponse', + 'correcthint', + 'demandhint', + 'formulaequationinput', + 'multiplechoiceresponse', + 'numericalresponse', + 'optioninput', + 'optionresponse', + 'responseparam', + 'solution', + 'stringequalhint', + 'stringresponse', + 'textline', +]; + +export const responseKeys = [ + 'multiplechoiceresponse', + 'numericalresponse', + 'optionresponse', + 'stringresponse', + 'choiceresponse', + 'multiplechoiceresponse', + 'truefalseresponse', + 'optionresponse', + 'numericalresponse', + 'stringresponse', + 'customresponse', + 'symbolicresponse', + 'coderesponse', + 'externalresponse', + 'formularesponse', + 'schematicresponse', + 'imageresponse', + 'annotationresponse', + 'choicetextresponse', +]; + +export const stripNonTextTags = ({ input, tag }) => { + const stripedTags = {}; + Object.entries(input).forEach(([key, value]) => { + if (key !== tag) { + stripedTags[key] = value; + } + }); + return stripedTags; +}; + +export class OLXParser { + constructor(olxString) { + // There are two versions of the parsed XLM because the fields using tinymce require the order + // of the parsed data and spacing values to be preserved. However, all the other widgets need + // the data grouped by the wrapping tag. Examples of the parsed format can be found here: + // https://github.com/NaturalIntelligence/fast-xml-parser/blob/master/docs/v4/2.XMLparseOptions.md + const baseParserOptions = { + ignoreAttributes: false, + numberParseOptions: { + leadingZeros: false, + hex: false, + }, + processEntities: false, + }; + + // Base Parser + this.problem = {}; + const parserOptions = { + ...baseParserOptions, + alwaysCreateTextNode: true, + }; + const builderOptions = { + ...baseParserOptions, + }; + const parser = new XMLParser(parserOptions); + this.builder = new XMLBuilder(builderOptions); + this.parsedOLX = parser.parse(olxString); + if (_.has(this.parsedOLX, 'problem')) { + this.problem = this.parsedOLX.problem; + } + + // Parser with `preservedOrder: true` and `trimValues: false` + this.richTextProblem = []; + const richTextOptions = { + ...baseParserOptions, + alwaysCreateTextNode: true, + preserveOrder: true, + trimValues: false, + }; + const richTextBuilderOptions = { + ...baseParserOptions, + preserveOrder: true, + trimValues: false, + }; + const richTextParser = new XMLParser(richTextOptions); + this.richTextBuilder = new XMLBuilder(richTextBuilderOptions); + this.richTextOLX = richTextParser.parse(olxString); + if (_.has(this.parsedOLX, 'problem')) { + this.richTextProblem = this.richTextOLX[0].problem; + } + } + + /** getPreservedAnswersAndFeedback(problemType, widgetName, option) + * getPreservedAnswersAndFeedback takes a problemType, widgetName, and a valid option. The + * olx for the given problem type and widget is parsed. Do to the structure of xml that is + * parsed with the prsereved attribute, the function has to loop through arrays of objects. + * The first for-loop checks for feedback tags and answer choices and appended to the + * preservedAnswers. The nested for loop checks for feedback and answer values inside the + * option (answer) tags. + * @param {string} problemType - string of the olx problem type + * @param {string} widgetName - string of the wrapping tag name + * (optioninput, choicegroup, checkboxgroup, additional_answer) + * @param {string} option - string of the type of answers (choice, option, correcthint, stringequalhint) + * @return {array} array containing answer objects and possibly an array of grouped feedback + */ + getPreservedAnswersAndFeedback(problemType, widgetName, option) { + const [problemBody] = this.richTextProblem.filter(section => Object.keys(section).includes(problemType)); + const isChoiceProblem = !([ProblemTypeKeys.NUMERIC, ProblemTypeKeys.TEXTINPUT].includes(problemType)); + const preservedAnswers = []; + let correctAnswerFeedbackTag = option; + let incorrectAnswerFeedbackTag; + if (problemType === ProblemTypeKeys.TEXTINPUT) { + [correctAnswerFeedbackTag, incorrectAnswerFeedbackTag] = option; + } + const problemBodyArr = problemBody[problemType]; + problemBodyArr.forEach(subtag => { + const tagNames = Object.keys(subtag); + if (!isChoiceProblem && tagNames.includes(correctAnswerFeedbackTag)) { + preservedAnswers.unshift(subtag[correctAnswerFeedbackTag]); + } + if (problemType === ProblemTypeKeys.TEXTINPUT && tagNames.includes(incorrectAnswerFeedbackTag)) { + preservedAnswers.push(subtag); + } + if (tagNames.includes(widgetName)) { + const currentAnswerArr = subtag[widgetName]; + currentAnswerArr.forEach(answer => { + if (Object.keys(answer).includes(correctAnswerFeedbackTag)) { + preservedAnswers.push(answer[correctAnswerFeedbackTag]); + } + }); + } + }); + return preservedAnswers; + } + + /** parseMultipleChoiceAnswers(problemType, widgetName, option) + * parseMultipleChoiceAnswers takes a problemType, widgetName, and a valid option. The + * olx for the given problem type and widget is parsed. Depending on the problem + * type, the title for an answer will be parsed differently because of single select and multiselect + * problems are rich text while dropdown answers are plain text. The rich text is parsed into an object + * and is converted back into a string before being added to the answer object. The parsing returns a + * data object with an array of answer objects. If the olx has grouped feedback, this will also be + * included in the data object. + * @param {string} problemType - string of the olx problem type + * @param {string} widgetName - string of the wrapping tag name (optioninput, choicegroup, checkboxgroup) + * @param {string} option - string of the type of answers (choice or option) + * @return {object} object containing an array of answer objects and possibly an array of grouped feedback + */ + parseMultipleChoiceAnswers(problemType, widgetName, option) { + const preservedAnswers = this.getPreservedAnswersAndFeedback( + problemType, + widgetName, + option, + ); + const answers = []; + let data = {}; + const widget = _.get(this.problem, `${problemType}.${widgetName}`); + const permissableTags = ['choice', '@_type', 'compoundhint', 'option', '#text']; + if (_.keys(widget).some((tag) => !permissableTags.includes(tag))) { + throw new Error('Misc Tags, reverting to Advanced Editor'); + } + if (_.get(this.problem, `${problemType}.@_partial_credit`)) { + throw new Error('Partial credit not supported by GUI, reverting to Advanced Editor'); + } + const choice = _.get(widget, option); + const isComplexAnswer = RichTextProblems.includes(problemType); + if (_.isEmpty(choice)) { + answers.push( + { + id: indexToLetterMap[answers.length], + title: '', + correct: true, + }, + ); + } else if (_.isArray(choice)) { + choice.forEach((element, index) => { + const preservedAnswer = preservedAnswers[index].filter(answer => !Object.keys(answer).includes(`${option}hint`)); + const preservedFeedback = preservedAnswers[index].filter(answer => Object.keys(answer).includes(`${option}hint`)); + let title = element['#text']; + + if (isComplexAnswer && preservedAnswer) { + title = this.richTextBuilder.build(preservedAnswer); + } + const correct = eval(element['@_correct'].toLowerCase()); + const id = indexToLetterMap[index]; + const feedback = this.getAnswerFeedback(preservedFeedback, `${option}hint`); + answers.push( + { + id, + correct, + title, + ...feedback, + }, + ); + }); + } else { + const preservedAnswer = preservedAnswers[0].filter(answer => !Object.keys(answer).includes(`${option}hint`)); + const preservedFeedback = preservedAnswers[0].filter(answer => Object.keys(answer).includes(`${option}hint`)); + let title = choice['#text']; + + if (isComplexAnswer && preservedAnswer) { + title = this.richTextBuilder.build(preservedAnswer); + } + const feedback = this.getAnswerFeedback(preservedFeedback, `${option}hint`); + answers.push({ + correct: eval(choice['@_correct'].toLowerCase()), + id: indexToLetterMap[answers.length], + title, + ...feedback, + }); + } + data = { answers }; + const groupFeedbackList = this.getGroupedFeedback(widget); + if (groupFeedbackList.length) { + data = { + ...data, + groupFeedbackList, + }; + } + return data; + } + + /** getAnswerFeedback(preservedFeedback, hintKey) + * getAnswerFeedback takes preservedFeedback and a valid option. The preservedFeedback object + * is checked for selected and unselected feedback. The respective values are added to the + * feedback object. The feedback object is returned. + * @param {array} preservedFeedback - array of feedback objects + * @param {string} hintKey - string of the wrapping tag name (optionhint or choicehint) + * @return {object} object containing selected and unselected feedback + */ + getAnswerFeedback(preservedFeedback, hintKey) { + const feedback = {}; + let feedbackKeys = 'selectedFeedback'; + if (_.isEmpty(preservedFeedback)) { return feedback; } + + preservedFeedback.forEach((feedbackArr) => { + if (_.has(feedbackArr, hintKey)) { + if (_.has(feedbackArr, ':@') && _.has(feedbackArr[':@'], '@_selected')) { + const isSelectedFeedback = feedbackArr[':@']['@_selected'] === 'true'; + feedbackKeys = isSelectedFeedback ? 'selectedFeedback' : 'unselectedFeedback'; + } + feedback[feedbackKeys] = this.richTextBuilder.build(feedbackArr[hintKey]); + } + }); + return feedback; + } + + /** getGroupedFeedback(choices) + * getGroupedFeedback takes choices. The choices with the attribute compoundhint are parsed for + * the text value and the answers associated with the feedback. The groupFeedback array is returned. + * @param {object} choices - object of problem's subtags + * @return {array} array containing objects of feedback and associated answer ids + */ + getGroupedFeedback(choices) { + const groupFeedback = []; + if (_.has(choices, 'compoundhint')) { + const groupFeedbackArray = choices.compoundhint; + if (_.isArray(groupFeedbackArray)) { + groupFeedbackArray.forEach((element) => { + const parsedFeedback = stripNonTextTags({ input: element, tag: '@_value' }); + groupFeedback.push({ + id: groupFeedback.length, + answers: element['@_value'].split(' '), + feedback: this.builder.build(parsedFeedback), + }); + }); + } else { + const parsedFeedback = stripNonTextTags({ input: groupFeedbackArray, tag: '@_value' }); + groupFeedback.push({ + id: groupFeedback.length, + answers: groupFeedbackArray['@_value'].split(' '), + feedback: this.builder.build(parsedFeedback), + }); + } + } + return groupFeedback; + } + + /** parseStringResponse() + * The OLX saved to the class constuctor is parsed for text input answers. There are two + * types of tags with the answer attribute, stringresponse (the problem wrapper) and + * additional_answer. Looping through each tag, the associated title and feedback are added + * to the answers object and appended to the answers array. The array returned in an object + * with the key "answers". The object also conatins additional attributes that belong to the + * string response tag. + * @return {object} object containing an array of answer objects and object of additionalStringAttributes + */ + parseStringResponse() { + const [firstCorrectFeedback, ...preservedFeedback] = this.getPreservedAnswersAndFeedback( + ProblemTypeKeys.TEXTINPUT, + 'additional_answer', + ['correcthint', 'stringequalhint'], + ); + const { stringresponse } = this.problem; + const answers = []; + let answerFeedback = ''; + let additionalStringAttributes = {}; + let data = {}; + const firstFeedback = this.getFeedback(firstCorrectFeedback); + answers.push({ + id: indexToLetterMap[answers.length], + title: stringresponse['@_answer'], + correct: true, + selectedFeedback: firstFeedback, + }); + + const additionalAnswerFeedback = preservedFeedback.filter(feedback => _.isArray(feedback)); + const stringEqualHintFeedback = preservedFeedback.filter(feedback => !_.isArray(feedback)); + + // Parsing additional_answer for string response. + const additionalAnswer = _.get(stringresponse, 'additional_answer', []); + if (_.isArray(additionalAnswer)) { + additionalAnswer.forEach((newAnswer, indx) => { + answerFeedback = this.getFeedback(additionalAnswerFeedback[indx]); + answers.push({ + id: indexToLetterMap[answers.length], + title: newAnswer['@_answer'], + correct: true, + selectedFeedback: answerFeedback, + }); + }); + } else { + answerFeedback = this.getFeedback(additionalAnswerFeedback[0]); + answers.push({ + id: indexToLetterMap[answers.length], + title: additionalAnswer['@_answer'], + correct: true, + selectedFeedback: answerFeedback, + }); + } + + // Parsing stringequalhint for string response. + const stringEqualHint = _.get(stringresponse, 'stringequalhint', []); + if (_.isArray(stringEqualHint)) { + stringEqualHint.forEach((newAnswer, indx) => { + answerFeedback = this.richTextBuilder.build(stringEqualHintFeedback[indx].stringequalhint); + answers.push({ + id: indexToLetterMap[answers.length], + title: newAnswer['@_answer'], + correct: false, + selectedFeedback: answerFeedback, + }); + }); + } else { + answerFeedback = this.richTextBuilder.build(stringEqualHintFeedback[0].stringequalhint); + answers.push({ + id: indexToLetterMap[answers.length], + title: stringEqualHint['@_answer'], + correct: false, + selectedFeedback: answerFeedback, + }); + } + + // TODO: Support multiple types. + additionalStringAttributes = { + type: _.get(stringresponse, '@_type'), + textline: { + size: _.get(stringresponse, 'textline.@_size'), + }, + }; + + data = { + answers, + additionalStringAttributes, + }; + + return data; + } + + /** parseNumericResponse() + * The OLX saved to the class constuctor is parsed for numeric answers. There are two + * types of tags for numeric answers, responseparam and additional_answer. Looping through + * each tag, the associated title and feedback and if the answer is an answer range are + * added to the answers object and appended to the answers array. The array returned in + * an object with the key "answers". + * @return {object} object containing an array of answer objects + */ + parseNumericResponse() { + const [firstCorrectFeedback, ...preservedFeedback] = this.getPreservedAnswersAndFeedback( + ProblemTypeKeys.NUMERIC, + 'additional_answer', + 'correcthint', + ); + const { numericalresponse } = this.problem; + if (_.get(numericalresponse, '@_partial_credit')) { + throw new Error('Partial credit not supported by GUI, reverting to Advanced Editor'); + } + let answerFeedback = ''; + const answers = []; + let responseParam = {}; + const feedback = this.getFeedback(firstCorrectFeedback); + if (_.has(numericalresponse, 'responseparam')) { + const type = _.get(numericalresponse, 'responseparam.@_type'); + const defaultValue = _.get(numericalresponse, 'responseparam.@_default'); + responseParam = { + [type]: defaultValue, + }; + } + const isAnswerRange = /[([]\s*\d*,\s*\d*\s*[)\]]/gm.test(numericalresponse['@_answer']); + answers.push({ + id: indexToLetterMap[answers.length], + title: numericalresponse['@_answer'], + correct: true, + selectedFeedback: feedback, + isAnswerRange, + ...responseParam, + }); + + // Parsing additional_answer for numerical response. + const additionalAnswer = _.get(numericalresponse, 'additional_answer', []); + if (_.isArray(additionalAnswer)) { + additionalAnswer.forEach((newAnswer, indx) => { + answerFeedback = this.getFeedback(preservedFeedback[indx]); + answers.push({ + id: indexToLetterMap[answers.length], + title: newAnswer['@_answer'], + correct: true, + selectedFeedback: answerFeedback, + }); + }); + } else { + answerFeedback = this.getFeedback(preservedFeedback[0]); + answers.push({ + id: indexToLetterMap[answers.length], + title: additionalAnswer['@_answer'], + correct: true, + selectedFeedback: answerFeedback, + isAnswerRange: false, + }); + } + return { answers }; + } + + /** parseQuestions(problemType) + * parseQuestions takes a problemType. The problem type is used to determine where the + * text for the question lies (sibling or child to warpping problem type tags). + * Using the XMLBuilder, the question is built with its proper children (including label + * and description). The string version of the OLX is return, replacing the description + * tags with italicized tags for styling purposes. + * @param {string} problemType - string of the olx problem type + * @return {string} string of OLX + */ + parseQuestions(problemType) { + const problemArray = _.get(this.richTextProblem[0], problemType) || this.richTextProblem; + + const questionArray = []; + problemArray.forEach(tag => { + const tagName = Object.keys(tag)[0]; + if (!nonQuestionKeys.includes(tagName)) { + if (tagName === 'script') { + throw new Error('Script Tag, reverting to Advanced Editor'); + } + questionArray.push(tag); + } else if (responseKeys.includes(tagName)) { + /* Tags that are not used for other parts of the question such as or + should be included in the question. These include but are not limited to tags like