diff --git a/.env b/.env index c99c6c43d..27a61c5b9 100644 --- a/.env +++ b/.env @@ -32,6 +32,7 @@ ENABLE_PROGRESS_GRAPH_SETTINGS=false ENABLE_TEAM_TYPE_SETTING=false ENABLE_NEW_EDITOR_PAGES=true ENABLE_UNIT_PAGE=false +ENABLE_ASSETS_PAGE=false ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=false ENABLE_TAGGING_TAXONOMY_PAGES=false BBB_LEARN_MORE_URL='' @@ -41,3 +42,4 @@ HOTJAR_DEBUG=false INVITE_STUDENTS_EMAIL_TO='' AI_TRANSLATIONS_BASE_URL='' ENABLE_PAGINATION_COURSES_STUDIO_HOME='true' +ENABLE_CHECKLIST_QUALITY='' diff --git a/.env.development b/.env.development index e62534fe7..a060c171c 100644 --- a/.env.development +++ b/.env.development @@ -34,6 +34,7 @@ ENABLE_PROGRESS_GRAPH_SETTINGS=false ENABLE_TEAM_TYPE_SETTING=false ENABLE_NEW_EDITOR_PAGES=true ENABLE_UNIT_PAGE=false +ENABLE_ASSETS_PAGE=false ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=false ENABLE_TAGGING_TAXONOMY_PAGES=true BBB_LEARN_MORE_URL='' @@ -43,3 +44,4 @@ HOTJAR_DEBUG=true INVITE_STUDENTS_EMAIL_TO="someone@domain.com" AI_TRANSLATIONS_BASE_URL='http://localhost:18760' ENABLE_PAGINATION_COURSES_STUDIO_HOME='true' +ENABLE_CHECKLIST_QUALITY=true diff --git a/.env.test b/.env.test index efec9adae..6075264b0 100644 --- a/.env.test +++ b/.env.test @@ -30,8 +30,10 @@ ENABLE_PROGRESS_GRAPH_SETTINGS=false ENABLE_TEAM_TYPE_SETTING=false ENABLE_NEW_EDITOR_PAGES=true ENABLE_UNIT_PAGE=true +ENABLE_ASSETS_PAGE=false ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=true ENABLE_TAGGING_TAXONOMY_PAGES=true BBB_LEARN_MORE_URL='' INVITE_STUDENTS_EMAIL_TO="someone@domain.com" ENABLE_PAGINATION_COURSES_STUDIO_HOME='true' +ENABLE_CHECKLIST_QUALITY=true diff --git a/.eslintrc.js b/.eslintrc.js index f5cfb442c..8b03261fc 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,3 +1,4 @@ +const path = require('path'); // eslint-disable-next-line import/no-extraneous-dependencies const { createConfig } = require('@openedx/frontend-build'); @@ -13,5 +14,21 @@ module.exports = createConfig( indent: ['error', 2], 'no-restricted-exports': 'off', }, + settings: { + // Import URLs should be resolved using aliases + 'import/resolver': { + webpack: { + config: path.resolve(__dirname, 'webpack.dev.config.js'), + }, + }, + }, + overrides: [ + { + files: ['plugins/**/*.test.jsx'], + rules: { + 'import/no-extraneous-dependencies': 'off', + }, + }, + ], }, ); diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..d71c07ce6 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,27 @@ +## Description + +Describe what this pull request changes, and why. Include implications for people using this change. +Design decisions and their rationales should be documented in the repo (docstring / ADR), per + +Useful information to include: +- Which edX user roles will this change impact? Common user roles are "Learner", "Course Author", +"Developer", and "Operator". +- Include screenshots for changes to the UI (ideally, both "before" and "after" screenshots, if applicable). +- Provide links to the description of corresponding configuration changes. Remember to correctly annotate these +changes. + +## Supporting information + +Link to other information about the change, such as Jira issues, GitHub issues, or Discourse discussions. +Be sure to check they are publicly readable, or if not, repeat the information here. + +## Testing instructions + +Please provide detailed step-by-step instructions for testing this change. + + +## Other information + +Include anything else that will help reviewers and consumers understand the change. +- Does this change depend on other changes elsewhere? +- Any special concerns or limitations? For example: deprecations, migrations, security, or accessibility. \ No newline at end of file diff --git a/.gitignore b/.gitignore index 9770f7309..02542e96c 100755 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,6 @@ temp/babel-plugin-react-intl # Local environment overrides .env.private + +# Messages .json files fetched by atlas +src/i18n/messages/ diff --git a/.tx/config b/.tx/config deleted file mode 100644 index a9bee1f9f..000000000 --- a/.tx/config +++ /dev/null @@ -1,9 +0,0 @@ -[main] -host = https://www.transifex.com - -[o:open-edx:p:edx-platform:r:frontend-app-course-authoring] -file_filter = src/i18n/messages/.json -source_file = src/i18n/transifex_input.json -source_lang = en -type = KEYVALUEJSON - diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 000000000..9904a5264 --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1,2 @@ +# The following users are the maintainers of all frontend-app-course-authoring files +* @openedx/2u-tnl diff --git a/Makefile b/Makefile index c1be0e2cb..aea3dd304 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,3 @@ -transifex_resource = frontend-app-course-authoring -export TRANSIFEX_RESOURCE = ${transifex_resource} -transifex_langs = "ar,de,de_DE,es_419,fa_IR,fr,fr_CA,hi,it,it_IT,pt,pt_PT,ru,uk,zh_CN" - intl_imports = ./node_modules/.bin/intl-imports.js transifex_utils = ./node_modules/.bin/transifex-utils.js i18n = ./src/i18n @@ -33,23 +29,6 @@ detect_changed_source_translations: # Checking for changed translations... git diff --exit-code $(i18n) -# Pushes translations to Transifex. You must run make extract_translations first. -push_translations: - # Pushing strings to Transifex... - tx push -s - # Fetching hashes from Transifex... - ./node_modules/@edx/reactifex/bash_scripts/get_hashed_strings_v3.sh - # Writing out comments to file... - $(transifex_utils) $(transifex_temp) --comments --v3-scripts-path - # Pushing comments to Transifex... - ./node_modules/@edx/reactifex/bash_scripts/put_comments_v3.sh - -ifeq ($(OPENEDX_ATLAS_PULL),) -# Pulls translations from Transifex. -pull_translations: - tx pull -t -f --mode reviewed --languages=$(transifex_langs) -else -# Pulls translations using atlas. pull_translations: rm -rf src/i18n/messages mkdir src/i18n/messages @@ -63,7 +42,6 @@ pull_translations: 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 -endif # This target is used by Travis. validate-no-uncommitted-package-lock-changes: diff --git a/catalog-info.yaml b/catalog-info.yaml new file mode 100644 index 000000000..7867f553f --- /dev/null +++ b/catalog-info.yaml @@ -0,0 +1,18 @@ +# This file records information about this repo. Its use is described in OEP-55: +# https://open-edx-proposals.readthedocs.io/en/latest/processes/oep-0055-proc-project-maintainers.html + +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: 'frontend-app-course-authoring' + description: "The frontend (MFE) for Open edX Course Authoring (aka Studio)" + links: + - url: "https://github.com/openedx/frontend-app-course-authoring" + title: "Frontend app course authoring" + icon: "Web" + annotations: + openedx.org/arch-interest-groups: "" +spec: + owner: group:2u-tnl + type: 'website' + lifecycle: 'production' diff --git a/jest.config.js b/jest.config.js index db3f44b20..0af1e62af 100644 --- a/jest.config.js +++ b/jest.config.js @@ -11,6 +11,7 @@ module.exports = createConfig('jest', { ], moduleNameMapper: { '^lodash-es$': 'lodash', + '^CourseAuthoring/(.*)$': '/src/$1', }, modulePathIgnorePatterns: [ '/src/pages-and-resources/utils.test.jsx', diff --git a/package-lock.json b/package-lock.json index 918f88973..85899f669 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,13 +9,16 @@ "version": "0.1.0", "license": "AGPL-3.0", "dependencies": { + "@dnd-kit/core": "^6.1.0", + "@dnd-kit/modifiers": "^7.0.0", "@dnd-kit/sortable": "^8.0.0", + "@dnd-kit/utilities": "^3.2.2", "@edx/brand": "npm:@openedx/brand-openedx@^1.2.2", "@edx/frontend-component-ai-translations": "^2.0.0", "@edx/frontend-component-footer": "^13.0.2", "@edx/frontend-component-header": "^5.0.2", "@edx/frontend-enterprise-hotjar": "^2.0.0", - "@edx/frontend-lib-content-components": "^2.0.0", + "@edx/frontend-lib-content-components": "^2.1.4", "@edx/frontend-platform": "7.0.1", "@edx/openedx-atlas": "^0.6.0", "@fortawesome/fontawesome-svg-core": "1.2.36", @@ -23,6 +26,16 @@ "@fortawesome/free-regular-svg-icons": "5.15.4", "@fortawesome/free-solid-svg-icons": "5.15.4", "@fortawesome/react-fontawesome": "0.2.0", + "@openedx-plugins/course-app-calculator": "file:plugins/course-apps/calculator", + "@openedx-plugins/course-app-edxnotes": "file:plugins/course-apps/edxnotes", + "@openedx-plugins/course-app-learning_assistant": "file:plugins/course-apps/learning_assistant", + "@openedx-plugins/course-app-live": "file:plugins/course-apps/live", + "@openedx-plugins/course-app-ora_settings": "file:plugins/course-apps/ora_settings", + "@openedx-plugins/course-app-proctoring": "file:plugins/course-apps/proctoring", + "@openedx-plugins/course-app-progress": "file:plugins/course-apps/progress", + "@openedx-plugins/course-app-teams": "file:plugins/course-apps/teams", + "@openedx-plugins/course-app-wiki": "file:plugins/course-apps/wiki", + "@openedx-plugins/course-app-xpert_unit_summary": "file:plugins/course-apps/xpert_unit_summary", "@openedx/paragon": "^21.5.7", "@reduxjs/toolkit": "1.9.7", "@tanstack/react-query": "4.36.1", @@ -44,6 +57,7 @@ "react-responsive": "9.0.2", "react-router": "6.16.0", "react-router-dom": "6.16.0", + "react-select": "5.8.0", "react-textarea-autosize": "^8.4.1", "react-transition-group": "4.4.5", "redux": "4.0.5", @@ -65,6 +79,7 @@ "@testing-library/user-event": "^13.2.1", "axios": "^0.27.2", "axios-mock-adapter": "1.22.0", + "eslint-import-resolver-webpack": "^0.13.8", "glob": "7.2.3", "husky": "7.0.4", "jest-canvas-mock": "^2.5.2", @@ -2341,6 +2356,19 @@ "react-dom": ">=16.8.0" } }, + "node_modules/@dnd-kit/modifiers": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-7.0.0.tgz", + "integrity": "sha512-BG/ETy3eBjFap7+zIti53f0PCLGDzNXyTmn6fSdrudORf+OH04MxrW4p5+mPu4mgMk9kM41iYONjc3DOUWTcfg==", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.1.0", + "react": ">=16.8.0" + } + }, "node_modules/@dnd-kit/sortable": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-8.0.0.tgz", @@ -2437,9 +2465,9 @@ } }, "node_modules/@edx/frontend-component-footer": { - "version": "13.0.2", - "resolved": "https://registry.npmjs.org/@edx/frontend-component-footer/-/frontend-component-footer-13.0.2.tgz", - "integrity": "sha512-ANemEcIX6ZK8+dvFncAP+8Z4TLpw4cl9L/non9xhVgvo3kzsiq6Siu++cNqdafZIgmGm11TXp37D5WVlk0dFSQ==", + "version": "13.0.4", + "resolved": "https://registry.npmjs.org/@edx/frontend-component-footer/-/frontend-component-footer-13.0.4.tgz", + "integrity": "sha512-E7KvzCXBmeH7bI6CHrZs/V4ARTAKsvX4tcFROtx9S9F35OvwvkZLxfyuuYgPfgNTTZtYqpoUcef1B7LQUlxLbw==", "dependencies": { "@fortawesome/fontawesome-svg-core": "6.5.1", "@fortawesome/free-brands-svg-icons": "6.5.1", @@ -2621,9 +2649,9 @@ } }, "node_modules/@edx/frontend-lib-content-components": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@edx/frontend-lib-content-components/-/frontend-lib-content-components-2.0.0.tgz", - "integrity": "sha512-D8noIqWa4rgiFMWChKot3NsHYFmbrYqWDn2Q0asZblyIs5BrZ596ZybdAtncXMG8zKv/FUzYUwE6T5PAhvUG1w==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@edx/frontend-lib-content-components/-/frontend-lib-content-components-2.1.4.tgz", + "integrity": "sha512-5uZH0NxjLJQIpZhovC0EphdMK1sXt00bFElKs4DLWmV90ojDNNqtgQ73BIX4H9tz44QYrwCovYWUQ3NYCg5epA==", "dependencies": { "@codemirror/lang-html": "^6.0.0", "@codemirror/lang-xml": "^6.0.0", @@ -2637,6 +2665,7 @@ "@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", @@ -2681,6 +2710,11 @@ "react": ">=16.8.0" } }, + "node_modules/@edx/frontend-lib-content-components/node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==" + }, "node_modules/@edx/frontend-lib-content-components/node_modules/react-responsive": { "version": "8.2.0", "resolved": "https://registry.npmjs.org/react-responsive/-/react-responsive-8.2.0.tgz", @@ -2995,6 +3029,117 @@ "tslib": "^2.4.0" } }, + "node_modules/@emotion/babel-plugin": { + "version": "11.11.0", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.11.0.tgz", + "integrity": "sha512-m4HEDZleaaCH+XgDDsPF15Ht6wTLsgDTeR3WYj9Q/k76JtWhrJjcP4+/XlG8LGT/Rol9qUfOIztXeA84ATpqPQ==", + "dependencies": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.1", + "@emotion/memoize": "^0.8.1", + "@emotion/serialize": "^1.1.2", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/babel-plugin/node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@emotion/cache": { + "version": "11.11.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.11.0.tgz", + "integrity": "sha512-P34z9ssTCBi3e9EI1ZsWpNHcfY1r09ZO0rZbRO2ob3ZQMnFI35jB536qoXbkdesr5EUhYi22anuEJuyxifaqAQ==", + "dependencies": { + "@emotion/memoize": "^0.8.1", + "@emotion/sheet": "^1.2.2", + "@emotion/utils": "^1.2.1", + "@emotion/weak-memoize": "^0.3.1", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.1.tgz", + "integrity": "sha512-gJB6HLm5rYwSLI6PQa+X1t5CFGrv1J1TWG+sOyMCeKz2ojaj6Fnl/rZEspogG+cvqbt4AE/2eIyD2QfLKTBNlQ==" + }, + "node_modules/@emotion/memoize": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.8.1.tgz", + "integrity": "sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA==" + }, + "node_modules/@emotion/react": { + "version": "11.11.3", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.11.3.tgz", + "integrity": "sha512-Cnn0kuq4DoONOMcnoVsTOR8E+AdnKFf//6kUWc4LCdnxj31pZWn7rIULd6Y7/Js1PiPHzn7SKCM9vB/jBni8eA==", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.11.0", + "@emotion/cache": "^11.11.0", + "@emotion/serialize": "^1.1.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.0.1", + "@emotion/utils": "^1.2.1", + "@emotion/weak-memoize": "^0.3.1", + "hoist-non-react-statics": "^3.3.1" + }, + "peerDependencies": { + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/serialize": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.1.3.tgz", + "integrity": "sha512-iD4D6QVZFDhcbH0RAG1uVu1CwVLMWUkCvAqqlewO/rxf8+87yIBAlt4+AxMiiKPLs5hFc0owNk/sLLAOROw3cA==", + "dependencies": { + "@emotion/hash": "^0.9.1", + "@emotion/memoize": "^0.8.1", + "@emotion/unitless": "^0.8.1", + "@emotion/utils": "^1.2.1", + "csstype": "^3.0.2" + } + }, + "node_modules/@emotion/sheet": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.2.2.tgz", + "integrity": "sha512-0QBtGvaqtWi+nx6doRwDdBIzhNdZrXUppvTM4dtZZWEGTXL/XE/yJxLMGlDT1Gt+UHH5IX1n+jkXyytE/av7OA==" + }, + "node_modules/@emotion/unitless": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.8.1.tgz", + "integrity": "sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==" + }, + "node_modules/@emotion/use-insertion-effect-with-fallbacks": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.0.1.tgz", + "integrity": "sha512-jT/qyKZ9rzLErtrjGgdkMBn2OP8wl0G3sQlBb3YPryvKHsjvINUhVaPFfP+fpBcOkmrVOVEEHQFJ7nbj2TH2gw==", + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@emotion/utils": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.2.1.tgz", + "integrity": "sha512-Y2tGf3I+XVnajdItskUCn6LX+VUDmP6lTL4fcqsXAv43dnlbZiuW4MWQW38rW/BVWSE7Q/7+XQocmpnRYILUmg==" + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.3.1.tgz", + "integrity": "sha512-EsBwpc7hBUJWAsNPBmJy4hxWx12v6bshQsldrVmjxJoc3isbxhOrF2IcCpaXxfvq03NwkI7sbsOLXbYuqF/8Ww==" + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", @@ -3099,6 +3244,28 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.0.tgz", + "integrity": "sha512-PcF++MykgmTj3CIyOQbKA/hDzOAiqI3mhuoN44WRCopIs1sgoDoU4oty4Jtqaj/y3oDU6fnVSm4QG0a3t5i0+g==", + "dependencies": { + "@floating-ui/utils": "^0.2.1" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.3.tgz", + "integrity": "sha512-RnDthu3mzPlQ31Ss/BTwQ1zjzIhr3lk1gZB1OC56h/1vEtaXkESrOqL5fQVMfXpwGtRwX+YsZBdyHtJMQnkArw==", + "dependencies": { + "@floating-ui/core": "^1.0.0", + "@floating-ui/utils": "^0.2.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.1.tgz", + "integrity": "sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q==" + }, "node_modules/@formatjs/cli": { "version": "6.2.7", "resolved": "https://registry.npmjs.org/@formatjs/cli/-/cli-6.2.7.tgz", @@ -4721,6 +4888,46 @@ "node": ">= 8" } }, + "node_modules/@openedx-plugins/course-app-calculator": { + "resolved": "plugins/course-apps/calculator", + "link": true + }, + "node_modules/@openedx-plugins/course-app-edxnotes": { + "resolved": "plugins/course-apps/edxnotes", + "link": true + }, + "node_modules/@openedx-plugins/course-app-learning_assistant": { + "resolved": "plugins/course-apps/learning_assistant", + "link": true + }, + "node_modules/@openedx-plugins/course-app-live": { + "resolved": "plugins/course-apps/live", + "link": true + }, + "node_modules/@openedx-plugins/course-app-ora_settings": { + "resolved": "plugins/course-apps/ora_settings", + "link": true + }, + "node_modules/@openedx-plugins/course-app-proctoring": { + "resolved": "plugins/course-apps/proctoring", + "link": true + }, + "node_modules/@openedx-plugins/course-app-progress": { + "resolved": "plugins/course-apps/progress", + "link": true + }, + "node_modules/@openedx-plugins/course-app-teams": { + "resolved": "plugins/course-apps/teams", + "link": true + }, + "node_modules/@openedx-plugins/course-app-wiki": { + "resolved": "plugins/course-apps/wiki", + "link": true + }, + "node_modules/@openedx-plugins/course-app-xpert_unit_summary": { + "resolved": "plugins/course-apps/xpert_unit_summary", + "link": true + }, "node_modules/@openedx/frontend-build": { "version": "13.0.27", "resolved": "https://registry.npmjs.org/@openedx/frontend-build/-/frontend-build-13.0.27.tgz", @@ -6622,6 +6829,21 @@ "node": ">=0.10.0" } }, + "node_modules/array.prototype.find": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/array.prototype.find/-/array.prototype.find-2.2.2.tgz", + "integrity": "sha512-DRumkfW97iZGOfn+lIXbkVrXL04sfYKX+EfOodo8XboR5sxPDVvOjZTF/rysusa9lmhmSOeD6Vp6RKQP+eP4Tg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "es-shim-unscopables": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/array.prototype.flat": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.2.tgz", @@ -7034,6 +7256,35 @@ "node": ">= 10.14.2" } }, + "node_modules/babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "dependencies": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, + "node_modules/babel-plugin-macros/node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/babel-plugin-polyfill-corejs2": { "version": "0.4.8", "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.8.tgz", @@ -9436,6 +9687,99 @@ "ms": "^2.1.1" } }, + "node_modules/eslint-import-resolver-webpack": { + "version": "0.13.8", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-webpack/-/eslint-import-resolver-webpack-0.13.8.tgz", + "integrity": "sha512-Y7WIaXWV+Q21Rz/PJgUxiW/FTBOWmU8NTLdz+nz9mMoiz5vAev/fOaQxwD7qRzTfE3HSm1qsxZ5uRd7eX+VEtA==", + "dev": true, + "dependencies": { + "array.prototype.find": "^2.2.2", + "debug": "^3.2.7", + "enhanced-resolve": "^0.9.1", + "find-root": "^1.1.0", + "hasown": "^2.0.0", + "interpret": "^1.4.0", + "is-core-module": "^2.13.1", + "is-regex": "^1.1.4", + "lodash": "^4.17.21", + "resolve": "^2.0.0-next.5", + "semver": "^5.7.2" + }, + "engines": { + "node": ">= 6" + }, + "peerDependencies": { + "eslint-plugin-import": ">=1.4.0", + "webpack": ">=1.11.0" + } + }, + "node_modules/eslint-import-resolver-webpack/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-import-resolver-webpack/node_modules/enhanced-resolve": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-0.9.1.tgz", + "integrity": "sha512-kxpoMgrdtkXZ5h0SeraBS1iRntpTpQ3R8ussdb38+UAFnMGX5DDyJXePm+OCHOcoXvHDw7mc2erbJBpDnl7TPw==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.1.2", + "memory-fs": "^0.2.0", + "tapable": "^0.1.8" + }, + "engines": { + "node": ">=0.6" + } + }, + "node_modules/eslint-import-resolver-webpack/node_modules/interpret": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-1.4.0.tgz", + "integrity": "sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==", + "dev": true, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/eslint-import-resolver-webpack/node_modules/resolve": { + "version": "2.0.0-next.5", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", + "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", + "dev": true, + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/eslint-import-resolver-webpack/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/eslint-import-resolver-webpack/node_modules/tapable": { + "version": "0.1.10", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-0.1.10.tgz", + "integrity": "sha512-jX8Et4hHg57mug1/079yitEKWGB3LCwoxByLsNim89LABq8NqgiX+6iYVOsq0vX8uJHkU+DZ5fnq95f800bEsQ==", + "dev": true, + "engines": { + "node": ">=0.6" + } + }, "node_modules/eslint-module-utils": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.8.0.tgz", @@ -10360,6 +10704,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==" + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -14515,6 +14864,17 @@ "node": ">= 4.0.0" } }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==" + }, + "node_modules/memory-fs": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.2.0.tgz", + "integrity": "sha512-+y4mDxU4rvXXu5UDSGCGNiesFmwCHuefGMoPCO1WYucNYj7DsLqrFaa2fXVI0H+NNiPTwwzKwspn9yTZqUGqng==", + "dev": true + }, "node_modules/meow": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/meow/-/meow-9.0.0.tgz", @@ -17341,6 +17701,26 @@ "react-dom": ">=16.8" } }, + "node_modules/react-select": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/react-select/-/react-select-5.8.0.tgz", + "integrity": "sha512-TfjLDo58XrhP6VG5M/Mi56Us0Yt8X7xD6cDybC7yoRMUNm7BGO7qk8J0TLQOua/prb8vUOtsfnXZwfm30HGsAA==", + "dependencies": { + "@babel/runtime": "^7.12.0", + "@emotion/cache": "^11.4.0", + "@emotion/react": "^11.8.1", + "@floating-ui/dom": "^1.0.1", + "@types/react-transition-group": "^4.4.0", + "memoize-one": "^6.0.0", + "prop-types": "^15.6.0", + "react-transition-group": "^4.3.0", + "use-isomorphic-layout-effect": "^1.1.2" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/react-shallow-renderer": { "version": "16.15.0", "resolved": "https://registry.npmjs.org/react-shallow-renderer/-/react-shallow-renderer-16.15.0.tgz", @@ -19730,6 +20110,11 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==" + }, "node_modules/superagent": { "version": "3.8.3", "resolved": "https://registry.npmjs.org/superagent/-/superagent-3.8.3.tgz", @@ -21682,6 +22067,186 @@ "engines": { "node": ">=10" } + }, + "plugins/course-apps/calculator": { + "name": "@openedx-plugins/course-app-calculator", + "version": "0.1.0", + "peerDependencies": { + "@edx/frontend-app-course-authoring": "*", + "@edx/frontend-platform": "*", + "@openedx/paragon": "*", + "prop-types": "*", + "react": "*" + }, + "peerDependenciesMeta": { + "@edx/frontend-app-course-authoring": { + "optional": true + } + } + }, + "plugins/course-apps/edxnotes": { + "name": "@openedx-plugins/course-app-edxnotes", + "version": "0.1.0", + "peerDependencies": { + "@edx/frontend-app-course-authoring": "*", + "@edx/frontend-platform": "*", + "@openedx/paragon": "*", + "prop-types": "*", + "react": "*" + }, + "peerDependenciesMeta": { + "@edx/frontend-app-course-authoring": { + "optional": true + } + } + }, + "plugins/course-apps/learning_assistant": { + "name": "@openedx-plugins/course-app-learning_assistant", + "version": "0.1.0", + "peerDependencies": { + "@edx/frontend-app-course-authoring": "*", + "@edx/frontend-platform": "*", + "@openedx/paragon": "*", + "prop-types": "*", + "react": "*", + "yup": "*" + }, + "peerDependenciesMeta": { + "@edx/frontend-app-course-authoring": { + "optional": true + } + } + }, + "plugins/course-apps/live": { + "name": "@openedx-plugins/course-app-live", + "version": "0.1.0", + "peerDependencies": { + "@edx/frontend-app-course-authoring": "*", + "@edx/frontend-lib-content-components": "*", + "@edx/frontend-platform": "*", + "@openedx/paragon": "*", + "@reduxjs/toolkit": "*", + "lodash": "*", + "prop-types": "*", + "react": "*", + "react-redux": "*", + "react-router-dom": "*", + "yup": "*" + }, + "peerDependenciesMeta": { + "@edx/frontend-app-course-authoring": { + "optional": true + } + } + }, + "plugins/course-apps/ora_settings": { + "name": "@openedx-plugins/course-app-ora_settings", + "version": "0.1.0", + "peerDependencies": { + "@edx/frontend-app-course-authoring": "*", + "@edx/frontend-platform": "*", + "@openedx/paragon": "*", + "prop-types": "*", + "react": "*", + "yup": "*" + }, + "peerDependenciesMeta": { + "@edx/frontend-app-course-authoring": { + "optional": true + } + } + }, + "plugins/course-apps/proctoring": { + "name": "@openedx-plugins/course-app-proctoring", + "version": "0.1.0", + "peerDependencies": { + "@edx/frontend-app-course-authoring": "*", + "@edx/frontend-platform": "*", + "@openedx/paragon": "*", + "classnames": "*", + "email-validator": "*", + "moment": "*", + "prop-types": "*", + "react": "*" + }, + "peerDependenciesMeta": { + "@edx/frontend-app-course-authoring": { + "optional": true + } + } + }, + "plugins/course-apps/progress": { + "name": "@openedx-plugins/course-app-progress", + "version": "0.1.0", + "peerDependencies": { + "@edx/frontend-app-course-authoring": "*", + "@edx/frontend-platform": "*", + "@openedx/paragon": "*", + "prop-types": "*", + "react": "*", + "yup": "*" + }, + "peerDependenciesMeta": { + "@edx/frontend-app-course-authoring": { + "optional": true + } + } + }, + "plugins/course-apps/teams": { + "name": "@openedx-plugins/course-app-teams", + "version": "0.1.0", + "peerDependencies": { + "@edx/frontend-app-course-authoring": "*", + "@edx/frontend-platform": "*", + "@openedx/paragon": "*", + "formik": "*", + "prop-types": "*", + "react": "*", + "uuid": "*", + "yup": "*" + }, + "peerDependenciesMeta": { + "@edx/frontend-app-course-authoring": { + "optional": true + } + } + }, + "plugins/course-apps/wiki": { + "name": "@openedx-plugins/course-app-wiki", + "version": "0.1.0", + "peerDependencies": { + "@edx/frontend-app-course-authoring": "*", + "@edx/frontend-platform": "*", + "@openedx/paragon": "*", + "prop-types": "*", + "react": "*", + "yup": "*" + }, + "peerDependenciesMeta": { + "@edx/frontend-app-course-authoring": { + "optional": true + } + } + }, + "plugins/course-apps/xpert_unit_summary": { + "name": "@openedx-plugins/course-app-xpert_unit_summary", + "version": "0.1.0", + "peerDependencies": { + "@edx/frontend-app-course-authoring": "*", + "@edx/frontend-platform": "*", + "@openedx/paragon": "*", + "formik": "*", + "prop-types": "*", + "react": "*", + "react-redux": "*", + "react-router-dom": "*", + "yup": "*" + }, + "peerDependenciesMeta": { + "@edx/frontend-app-course-authoring": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index 60bd1168e..8b4080ece 100644 --- a/package.json +++ b/package.json @@ -36,21 +36,34 @@ "url": "https://github.com/openedx/frontend-app-course-authoring/issues" }, "dependencies": { + "@dnd-kit/core": "^6.1.0", + "@dnd-kit/modifiers": "^7.0.0", "@dnd-kit/sortable": "^8.0.0", + "@dnd-kit/utilities": "^3.2.2", "@edx/brand": "npm:@openedx/brand-openedx@^1.2.2", "@edx/frontend-component-ai-translations": "^2.0.0", "@edx/frontend-component-footer": "^13.0.2", "@edx/frontend-component-header": "^5.0.2", "@edx/frontend-enterprise-hotjar": "^2.0.0", - "@edx/frontend-lib-content-components": "^2.0.0", + "@edx/frontend-lib-content-components": "^2.1.4", "@edx/frontend-platform": "7.0.1", "@edx/openedx-atlas": "^0.6.0", - "@openedx/paragon": "^21.5.7", "@fortawesome/fontawesome-svg-core": "1.2.36", "@fortawesome/free-brands-svg-icons": "5.15.4", "@fortawesome/free-regular-svg-icons": "5.15.4", "@fortawesome/free-solid-svg-icons": "5.15.4", "@fortawesome/react-fontawesome": "0.2.0", + "@openedx-plugins/course-app-calculator": "file:plugins/course-apps/calculator", + "@openedx-plugins/course-app-edxnotes": "file:plugins/course-apps/edxnotes", + "@openedx-plugins/course-app-learning_assistant": "file:plugins/course-apps/learning_assistant", + "@openedx-plugins/course-app-live": "file:plugins/course-apps/live", + "@openedx-plugins/course-app-ora_settings": "file:plugins/course-apps/ora_settings", + "@openedx-plugins/course-app-proctoring": "file:plugins/course-apps/proctoring", + "@openedx-plugins/course-app-progress": "file:plugins/course-apps/progress", + "@openedx-plugins/course-app-teams": "file:plugins/course-apps/teams", + "@openedx-plugins/course-app-wiki": "file:plugins/course-apps/wiki", + "@openedx-plugins/course-app-xpert_unit_summary": "file:plugins/course-apps/xpert_unit_summary", + "@openedx/paragon": "^21.5.7", "@reduxjs/toolkit": "1.9.7", "@tanstack/react-query": "4.36.1", "broadcast-channel": "^7.0.0", @@ -71,6 +84,7 @@ "react-responsive": "9.0.2", "react-router": "6.16.0", "react-router-dom": "6.16.0", + "react-select": "5.8.0", "react-textarea-autosize": "^8.4.1", "react-transition-group": "4.4.5", "redux": "4.0.5", @@ -81,17 +95,18 @@ }, "devDependencies": { "@edx/browserslist-config": "1.2.0", - "@openedx/frontend-build": "13.0.27", "@edx/react-unit-test-utils": "^2.0.0", "@edx/reactifex": "^1.0.3", "@edx/stylelint-config-edx": "2.3.0", "@edx/typescript-config": "^1.0.1", + "@openedx/frontend-build": "13.0.27", "@testing-library/jest-dom": "5.17.0", "@testing-library/react": "12.1.5", "@testing-library/react-hooks": "^8.0.1", "@testing-library/user-event": "^13.2.1", "axios": "^0.27.2", "axios-mock-adapter": "1.22.0", + "eslint-import-resolver-webpack": "^0.13.8", "glob": "7.2.3", "husky": "7.0.4", "jest-canvas-mock": "^2.5.2", diff --git a/plugins/course-apps/calculator/Settings.jsx b/plugins/course-apps/calculator/Settings.jsx new file mode 100644 index 000000000..7bfed1f65 --- /dev/null +++ b/plugins/course-apps/calculator/Settings.jsx @@ -0,0 +1,31 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { useIntl } from '@edx/frontend-platform/i18n'; + +import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal'; +import messages from './messages'; + +/** + * Settings widget for the "calculator" Course App. + * @param {{onClose: () => void}} props + */ +const CalculatorSettings = ({ onClose }) => { + const intl = useIntl(); + return ( + + ); +}; + +CalculatorSettings.propTypes = { + onClose: PropTypes.func.isRequired, +}; + +export default CalculatorSettings; diff --git a/src/pages-and-resources/calculator/messages.js b/plugins/course-apps/calculator/messages.js similarity index 100% rename from src/pages-and-resources/calculator/messages.js rename to plugins/course-apps/calculator/messages.js diff --git a/plugins/course-apps/calculator/package.json b/plugins/course-apps/calculator/package.json new file mode 100644 index 000000000..6f4a98670 --- /dev/null +++ b/plugins/course-apps/calculator/package.json @@ -0,0 +1,17 @@ +{ + "name": "@openedx-plugins/course-app-calculator", + "version": "0.1.0", + "description": "Calculator configuration for courses using it", + "peerDependencies": { + "@edx/frontend-app-course-authoring": "*", + "@edx/frontend-platform": "*", + "@openedx/paragon": "*", + "prop-types": "*", + "react": "*" + }, + "peerDependenciesMeta": { + "@edx/frontend-app-course-authoring": { + "optional": true + } + } +} diff --git a/plugins/course-apps/edxnotes/Settings.jsx b/plugins/course-apps/edxnotes/Settings.jsx new file mode 100644 index 000000000..b6672320c --- /dev/null +++ b/plugins/course-apps/edxnotes/Settings.jsx @@ -0,0 +1,31 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { useIntl } from '@edx/frontend-platform/i18n'; + +import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal'; +import messages from './messages'; + +/** + * Settings widget for the "edxnotes" Course App. + * @param {{onClose: () => void}} props + */ +const NotesSettings = ({ onClose }) => { + const intl = useIntl(); + return ( + + ); +}; + +NotesSettings.propTypes = { + onClose: PropTypes.func.isRequired, +}; + +export default NotesSettings; diff --git a/src/pages-and-resources/edxnotes/messages.js b/plugins/course-apps/edxnotes/messages.js similarity index 100% rename from src/pages-and-resources/edxnotes/messages.js rename to plugins/course-apps/edxnotes/messages.js diff --git a/plugins/course-apps/edxnotes/package.json b/plugins/course-apps/edxnotes/package.json new file mode 100644 index 000000000..ed2287db2 --- /dev/null +++ b/plugins/course-apps/edxnotes/package.json @@ -0,0 +1,17 @@ +{ + "name": "@openedx-plugins/course-app-edxnotes", + "version": "0.1.0", + "description": "edxnotes configuration for courses using it", + "peerDependencies": { + "@edx/frontend-app-course-authoring": "*", + "@edx/frontend-platform": "*", + "@openedx/paragon": "*", + "prop-types": "*", + "react": "*" + }, + "peerDependenciesMeta": { + "@edx/frontend-app-course-authoring": { + "optional": true + } + } +} diff --git a/src/pages-and-resources/learning_assistant/Settings.jsx b/plugins/course-apps/learning_assistant/Settings.jsx similarity index 83% rename from src/pages-and-resources/learning_assistant/Settings.jsx rename to plugins/course-apps/learning_assistant/Settings.jsx index b2abd8224..7b9bb916b 100644 --- a/src/pages-and-resources/learning_assistant/Settings.jsx +++ b/plugins/course-apps/learning_assistant/Settings.jsx @@ -1,16 +1,18 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { useIntl } from '@edx/frontend-platform/i18n'; import { Hyperlink } from '@openedx/paragon'; -import AppSettingsModal from '../app-settings-modal/AppSettingsModal'; -import messages from './messages'; -import { useModel } from '../../generic/model-store'; +import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal'; +import { useModel } from 'CourseAuthoring/generic/model-store'; -const LearningAssistantSettings = ({ intl, onClose }) => { +import messages from './messages'; + +const LearningAssistantSettings = ({ onClose }) => { const appId = 'learning_assistant'; const appInfo = useModel('courseApps', appId); + const intl = useIntl(); // We need to render more than one link, so we use the bodyChildren prop. const bodyChildren = ( @@ -55,8 +57,7 @@ const LearningAssistantSettings = ({ intl, onClose }) => { }; LearningAssistantSettings.propTypes = { - intl: intlShape.isRequired, onClose: PropTypes.func.isRequired, }; -export default injectIntl(LearningAssistantSettings); +export default LearningAssistantSettings; diff --git a/src/pages-and-resources/learning_assistant/Settings.test.jsx b/plugins/course-apps/learning_assistant/Settings.test.jsx similarity index 93% rename from src/pages-and-resources/learning_assistant/Settings.test.jsx rename to plugins/course-apps/learning_assistant/Settings.test.jsx index 087434857..a9bfa5820 100644 --- a/src/pages-and-resources/learning_assistant/Settings.test.jsx +++ b/plugins/course-apps/learning_assistant/Settings.test.jsx @@ -1,9 +1,9 @@ import React from 'react'; import { screen, waitFor } from '@testing-library/react'; +import { RequestStatus } from 'CourseAuthoring/data/constants'; +import { render } from 'CourseAuthoring/pages-and-resources/utils.test'; import LearningAssistantSettings from './Settings'; -import { render } from '../utils.test'; -import { RequestStatus } from '../../data/constants'; const onClose = () => { }; diff --git a/src/pages-and-resources/learning_assistant/messages.js b/plugins/course-apps/learning_assistant/messages.js similarity index 100% rename from src/pages-and-resources/learning_assistant/messages.js rename to plugins/course-apps/learning_assistant/messages.js diff --git a/plugins/course-apps/learning_assistant/package.json b/plugins/course-apps/learning_assistant/package.json new file mode 100644 index 000000000..0c96b6fc5 --- /dev/null +++ b/plugins/course-apps/learning_assistant/package.json @@ -0,0 +1,19 @@ +{ + "name": "@openedx-plugins/course-app-learning_assistant", + "version": "0.1.0", + "description": "Learning Assistant configuration for courses using it", + "peerDependencies": { + "@edx/frontend-app-course-authoring": "*", + "@edx/frontend-platform": "*", + "@openedx/paragon": "*", + "prop-types": "*", + "react": "*", + "yup": "*" + }, + "peerDependenciesMeta": { + "@edx/frontend-app-course-authoring": { + "optional": true + } + } +} + \ No newline at end of file diff --git a/src/pages-and-resources/live/BBBSettings.jsx b/plugins/course-apps/live/BBBSettings.jsx similarity index 95% rename from src/pages-and-resources/live/BBBSettings.jsx rename to plugins/course-apps/live/BBBSettings.jsx index 461a6140a..02b09a82b 100644 --- a/src/pages-and-resources/live/BBBSettings.jsx +++ b/plugins/course-apps/live/BBBSettings.jsx @@ -3,11 +3,12 @@ import { getConfig } from '@edx/frontend-platform'; import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { Form, Hyperlink } from '@openedx/paragon'; import PropTypes from 'prop-types'; -import messages from './messages'; +import AppConfigFormDivider from 'CourseAuthoring/pages-and-resources/discussions/app-config-form/apps/shared/AppConfigFormDivider'; +import { useModel } from 'CourseAuthoring/generic/model-store'; + import { providerNames, bbbPlanTypes } from './constants'; -import AppConfigFormDivider from '../discussions/app-config-form/apps/shared/AppConfigFormDivider'; import LiveCommonFields from './LiveCommonFields'; -import { useModel } from '../../generic/model-store'; +import messages from './messages'; const BbbSettings = ({ intl, diff --git a/src/pages-and-resources/live/BbbSettings.test.jsx b/plugins/course-apps/live/BbbSettings.test.jsx similarity index 96% rename from src/pages-and-resources/live/BbbSettings.test.jsx rename to plugins/course-apps/live/BbbSettings.test.jsx index 86c2193e2..143d4eafd 100644 --- a/src/pages-and-resources/live/BbbSettings.test.jsx +++ b/plugins/course-apps/live/BbbSettings.test.jsx @@ -15,8 +15,10 @@ import { AppProvider, PageWrap } from '@edx/frontend-platform/react'; import { IntlProvider } from '@edx/frontend-platform/i18n'; import userEvent from '@testing-library/user-event'; -import initializeStore from '../../store'; -import { executeThunk } from '../../utils'; +import initializeStore from 'CourseAuthoring/store'; +import { executeThunk } from 'CourseAuthoring/utils'; +import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider'; + import LiveSettings from './Settings'; import { generateLiveConfigurationApiResponse, @@ -24,11 +26,9 @@ import { initialState, configurationProviders, } from './factories/mockApiResponses'; - import { fetchLiveConfiguration, fetchLiveProviders } from './data/thunks'; import { providerConfigurationApiUrl, providersApiUrl } from './data/api'; import messages from './messages'; -import PagesAndResourcesProvider from '../PagesAndResourcesProvider'; let axiosMock; let container; diff --git a/src/pages-and-resources/live/LiveCommonFields.jsx b/plugins/course-apps/live/LiveCommonFields.jsx similarity index 94% rename from src/pages-and-resources/live/LiveCommonFields.jsx rename to plugins/course-apps/live/LiveCommonFields.jsx index c357a36bf..6e49e62df 100644 --- a/src/pages-and-resources/live/LiveCommonFields.jsx +++ b/plugins/course-apps/live/LiveCommonFields.jsx @@ -1,8 +1,9 @@ import React from 'react'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import PropTypes from 'prop-types'; +import FormikControl from 'CourseAuthoring/generic/FormikControl'; + import messages from './messages'; -import FormikControl from '../../generic/FormikControl'; const LiveCommonFields = ({ intl, diff --git a/src/pages-and-resources/live/Settings.jsx b/plugins/course-apps/live/Settings.jsx similarity index 92% rename from src/pages-and-resources/live/Settings.jsx rename to plugins/course-apps/live/Settings.jsx index 0e5c8679d..a8f09257b 100644 --- a/src/pages-and-resources/live/Settings.jsx +++ b/plugins/course-apps/live/Settings.jsx @@ -1,18 +1,20 @@ import React, { useEffect } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { camelCase } from 'lodash'; -import { SelectableBox, Icon } from '@openedx/paragon'; +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 AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal'; +import { useModel } from 'CourseAuthoring/generic/model-store'; +import Loading from 'CourseAuthoring/generic/Loading'; +import { RequestStatus } from 'CourseAuthoring/data/constants'; + import { fetchLiveData, saveLiveConfiguration, saveLiveConfigurationAsDraft } from './data/thunks'; import { selectApp } from './data/slice'; -import AppSettingsModal from '../app-settings-modal/AppSettingsModal'; -import { useModel } from '../../generic/model-store'; -import Loading from '../../generic/Loading'; import { iconsSrc, bbbPlanTypes } from './constants'; -import { RequestStatus } from '../../data/constants'; import messages from './messages'; import ZoomSettings from './ZoomSettings'; import BBBSettings from './BBBSettings'; diff --git a/src/pages-and-resources/live/Settings.test.jsx b/plugins/course-apps/live/Settings.test.jsx similarity index 96% rename from src/pages-and-resources/live/Settings.test.jsx rename to plugins/course-apps/live/Settings.test.jsx index 586265d06..71bfc509f 100644 --- a/src/pages-and-resources/live/Settings.test.jsx +++ b/plugins/course-apps/live/Settings.test.jsx @@ -18,8 +18,10 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { AppProvider, PageWrap } from '@edx/frontend-platform/react'; import { IntlProvider } from '@edx/frontend-platform/i18n'; -import initializeStore from '../../store'; -import { executeThunk } from '../../utils'; +import initializeStore from 'CourseAuthoring/store'; +import { executeThunk } from 'CourseAuthoring/utils'; +import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider'; + import LiveSettings from './Settings'; import { generateLiveConfigurationApiResponse, @@ -31,7 +33,6 @@ import { import { fetchLiveConfiguration, fetchLiveProviders } from './data/thunks'; import { providerConfigurationApiUrl, providersApiUrl } from './data/api'; import messages from './messages'; -import PagesAndResourcesProvider from '../PagesAndResourcesProvider'; let axiosMock; let container; diff --git a/src/pages-and-resources/live/ZoomSettings.jsx b/plugins/course-apps/live/ZoomSettings.jsx similarity index 95% rename from src/pages-and-resources/live/ZoomSettings.jsx rename to plugins/course-apps/live/ZoomSettings.jsx index 0d25dfa95..edb17f830 100644 --- a/src/pages-and-resources/live/ZoomSettings.jsx +++ b/plugins/course-apps/live/ZoomSettings.jsx @@ -1,10 +1,11 @@ import React from 'react'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import PropTypes from 'prop-types'; +import FormikControl from 'CourseAuthoring/generic/FormikControl'; + import messages from './messages'; import { providerNames } from './constants'; import LiveCommonFields from './LiveCommonFields'; -import FormikControl from '../../generic/FormikControl'; const ZoomSettings = ({ intl, diff --git a/src/pages-and-resources/live/ZoomSettings.test.jsx b/plugins/course-apps/live/ZoomSettings.test.jsx similarity index 96% rename from src/pages-and-resources/live/ZoomSettings.test.jsx rename to plugins/course-apps/live/ZoomSettings.test.jsx index 72706c9c8..a0e083613 100644 --- a/src/pages-and-resources/live/ZoomSettings.test.jsx +++ b/plugins/course-apps/live/ZoomSettings.test.jsx @@ -13,8 +13,9 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { AppProvider, PageWrap } from '@edx/frontend-platform/react'; import { IntlProvider } from '@edx/frontend-platform/i18n'; -import initializeStore from '../../store'; -import { executeThunk } from '../../utils'; +import initializeStore from 'CourseAuthoring/store'; +import { executeThunk } from 'CourseAuthoring/utils'; +import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider'; import LiveSettings from './Settings'; import { generateLiveConfigurationApiResponse, @@ -26,7 +27,6 @@ import { import { fetchLiveConfiguration, fetchLiveProviders } from './data/thunks'; import { providerConfigurationApiUrl, providersApiUrl } from './data/api'; import messages from './messages'; -import PagesAndResourcesProvider from '../PagesAndResourcesProvider'; let axiosMock; let container; diff --git a/src/pages-and-resources/live/constants.js b/plugins/course-apps/live/constants.js similarity index 100% rename from src/pages-and-resources/live/constants.js rename to plugins/course-apps/live/constants.js diff --git a/src/pages-and-resources/live/data/api.js b/plugins/course-apps/live/data/api.js similarity index 100% rename from src/pages-and-resources/live/data/api.js rename to plugins/course-apps/live/data/api.js diff --git a/src/pages-and-resources/live/data/slice.js b/plugins/course-apps/live/data/slice.js similarity index 95% rename from src/pages-and-resources/live/data/slice.js rename to plugins/course-apps/live/data/slice.js index 69ccf6b66..5f4c2a72d 100644 --- a/src/pages-and-resources/live/data/slice.js +++ b/plugins/course-apps/live/data/slice.js @@ -1,6 +1,6 @@ /* eslint-disable no-param-reassign */ import { createSlice } from '@reduxjs/toolkit'; -import { RequestStatus } from '../../../data/constants'; +import { RequestStatus } from 'CourseAuthoring/data/constants'; const slice = createSlice({ name: 'live', diff --git a/src/pages-and-resources/live/data/thunks.js b/plugins/course-apps/live/data/thunks.js similarity index 94% rename from src/pages-and-resources/live/data/thunks.js rename to plugins/course-apps/live/data/thunks.js index cf2900195..6c3108289 100644 --- a/src/pages-and-resources/live/data/thunks.js +++ b/plugins/course-apps/live/data/thunks.js @@ -1,4 +1,6 @@ -import { addModel, addModels, updateModel } from '../../../generic/model-store'; +import { addModel, addModels, updateModel } from 'CourseAuthoring/generic/model-store'; +import { RequestStatus } from 'CourseAuthoring/data/constants'; + import { getLiveConfiguration, getLiveProviders, @@ -7,7 +9,6 @@ import { deNormalizeSettings, } from './api'; import { loadApps, updateStatus, updateSaveStatus } from './slice'; -import { RequestStatus } from '../../../data/constants'; function updateLiveSettingsState({ appConfig, diff --git a/src/pages-and-resources/live/factories/mockApiResponses.jsx b/plugins/course-apps/live/factories/mockApiResponses.jsx similarity index 100% rename from src/pages-and-resources/live/factories/mockApiResponses.jsx rename to plugins/course-apps/live/factories/mockApiResponses.jsx diff --git a/src/pages-and-resources/live/messages.js b/plugins/course-apps/live/messages.js similarity index 100% rename from src/pages-and-resources/live/messages.js rename to plugins/course-apps/live/messages.js diff --git a/plugins/course-apps/live/package.json b/plugins/course-apps/live/package.json new file mode 100644 index 000000000..50f38b725 --- /dev/null +++ b/plugins/course-apps/live/package.json @@ -0,0 +1,23 @@ +{ + "name": "@openedx-plugins/course-app-live", + "version": "0.1.0", + "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": "*", + "lodash": "*", + "prop-types": "*", + "react": "*", + "react-redux": "*", + "react-router-dom": "*", + "yup": "*" + }, + "peerDependenciesMeta": { + "@edx/frontend-app-course-authoring": { + "optional": true + } + } +} diff --git a/src/pages-and-resources/ora_settings/Settings.jsx b/plugins/course-apps/ora_settings/Settings.jsx similarity index 86% rename from src/pages-and-resources/ora_settings/Settings.jsx rename to plugins/course-apps/ora_settings/Settings.jsx index 205c24ed0..b3e3c0d28 100644 --- a/src/pages-and-resources/ora_settings/Settings.jsx +++ b/plugins/course-apps/ora_settings/Settings.jsx @@ -5,11 +5,11 @@ import * as Yup from 'yup'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { Hyperlink } from '@openedx/paragon'; -import { useModel } from '../../generic/model-store'; +import { useModel } from 'CourseAuthoring/generic/model-store'; -import FormSwitchGroup from '../../generic/FormSwitchGroup'; -import { useAppSetting } from '../../utils'; -import AppSettingsModal from '../app-settings-modal/AppSettingsModal'; +import FormSwitchGroup from 'CourseAuthoring/generic/FormSwitchGroup'; +import { useAppSetting } from 'CourseAuthoring/utils'; +import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal'; import messages from './messages'; const ORASettings = ({ intl, onClose }) => { diff --git a/src/pages-and-resources/ora_settings/Settings.test.jsx b/plugins/course-apps/ora_settings/Settings.test.jsx similarity index 75% rename from src/pages-and-resources/ora_settings/Settings.test.jsx rename to plugins/course-apps/ora_settings/Settings.test.jsx index 01f1200e8..d74cab9e6 100644 --- a/src/pages-and-resources/ora_settings/Settings.test.jsx +++ b/plugins/course-apps/ora_settings/Settings.test.jsx @@ -9,14 +9,14 @@ jest.mock('@edx/frontend-platform/i18n', () => ({ jest.mock('yup', () => ({ boolean: jest.fn().mockReturnValue('Yub.boolean'), })); -jest.mock('../../generic/model-store', () => ({ +jest.mock('CourseAuthoring/generic/model-store', () => ({ useModel: jest.fn().mockReturnValue({ documentationLinks: { learnMoreConfiguration: 'https://learnmore.test' } }), })); -jest.mock('../../generic/FormSwitchGroup', () => 'FormSwitchGroup'); -jest.mock('../../utils', () => ({ +jest.mock('CourseAuthoring/generic/FormSwitchGroup', () => 'FormSwitchGroup'); +jest.mock('CourseAuthoring/utils', () => ({ useAppSetting: jest.fn().mockReturnValue(['abitrary value', jest.fn().mockName('saveSetting')]), })); -jest.mock('../app-settings-modal/AppSettingsModal', () => 'AppSettingsModal'); +jest.mock('CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal', () => 'AppSettingsModal'); const props = { onClose: jest.fn().mockName('onClose'), diff --git a/src/pages-and-resources/ora_settings/__snapshots__/Settings.test.jsx.snap b/plugins/course-apps/ora_settings/__snapshots__/Settings.test.jsx.snap similarity index 100% rename from src/pages-and-resources/ora_settings/__snapshots__/Settings.test.jsx.snap rename to plugins/course-apps/ora_settings/__snapshots__/Settings.test.jsx.snap diff --git a/src/pages-and-resources/ora_settings/messages.js b/plugins/course-apps/ora_settings/messages.js similarity index 100% rename from src/pages-and-resources/ora_settings/messages.js rename to plugins/course-apps/ora_settings/messages.js diff --git a/plugins/course-apps/ora_settings/package.json b/plugins/course-apps/ora_settings/package.json new file mode 100644 index 000000000..d6de33882 --- /dev/null +++ b/plugins/course-apps/ora_settings/package.json @@ -0,0 +1,19 @@ +{ + "name": "@openedx-plugins/course-app-ora_settings", + "version": "0.1.0", + "description": "Open Response Assessment configuration for courses using it", + "peerDependencies": { + "@edx/frontend-app-course-authoring": "*", + "@edx/frontend-platform": "*", + "@openedx/paragon": "*", + "prop-types": "*", + "react": "*", + "yup": "*" + }, + "peerDependenciesMeta": { + "@edx/frontend-app-course-authoring": { + "optional": true + } + } +} + \ No newline at end of file diff --git a/src/pages-and-resources/proctoring/Settings.jsx b/plugins/course-apps/proctoring/Settings.jsx similarity index 97% rename from src/pages-and-resources/proctoring/Settings.jsx rename to plugins/course-apps/proctoring/Settings.jsx index 9ff4af443..255152aa7 100644 --- a/src/pages-and-resources/proctoring/Settings.jsx +++ b/plugins/course-apps/proctoring/Settings.jsx @@ -13,15 +13,16 @@ import { ActionRow, Alert, Badge, Form, Hyperlink, ModalDialog, StatefulButton, } from '@openedx/paragon'; -import ExamsApiService from '../../data/services/ExamsApiService'; -import StudioApiService from '../../data/services/StudioApiService'; -import Loading from '../../generic/Loading'; -import ConnectionErrorAlert from '../../generic/ConnectionErrorAlert'; -import FormSwitchGroup from '../../generic/FormSwitchGroup'; -import { useModel } from '../../generic/model-store'; -import PermissionDeniedAlert from '../../generic/PermissionDeniedAlert'; -import { useIsMobile } from '../../utils'; -import { PagesAndResourcesContext } from '../PagesAndResourcesProvider'; +import ExamsApiService from 'CourseAuthoring/data/services/ExamsApiService'; +import StudioApiService from 'CourseAuthoring/data/services/StudioApiService'; +import Loading from 'CourseAuthoring/generic/Loading'; +import ConnectionErrorAlert from 'CourseAuthoring/generic/ConnectionErrorAlert'; +import FormSwitchGroup from 'CourseAuthoring/generic/FormSwitchGroup'; +import { useModel } from 'CourseAuthoring/generic/model-store'; +import PermissionDeniedAlert from 'CourseAuthoring/generic/PermissionDeniedAlert'; +import { useIsMobile } from 'CourseAuthoring/utils'; +import { PagesAndResourcesContext } from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider'; + import messages from './messages'; const ProctoringSettings = ({ intl, onClose }) => { diff --git a/src/pages-and-resources/proctoring/Settings.test.jsx b/plugins/course-apps/proctoring/Settings.test.jsx similarity index 99% rename from src/pages-and-resources/proctoring/Settings.test.jsx rename to plugins/course-apps/proctoring/Settings.test.jsx index 1e8d79caa..bd544ebb3 100644 --- a/src/pages-and-resources/proctoring/Settings.test.jsx +++ b/plugins/course-apps/proctoring/Settings.test.jsx @@ -9,10 +9,10 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n'; import { AppProvider } from '@edx/frontend-platform/react'; -import StudioApiService from '../../data/services/StudioApiService'; -import ExamsApiService from '../../data/services/ExamsApiService'; -import initializeStore from '../../store'; -import PagesAndResourcesProvider from '../PagesAndResourcesProvider'; +import StudioApiService from 'CourseAuthoring/data/services/StudioApiService'; +import ExamsApiService from 'CourseAuthoring/data/services/ExamsApiService'; +import initializeStore from 'CourseAuthoring/store'; +import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider'; import ProctoredExamSettings from './Settings'; const defaultProps = { diff --git a/src/pages-and-resources/proctoring/messages.js b/plugins/course-apps/proctoring/messages.js similarity index 100% rename from src/pages-and-resources/proctoring/messages.js rename to plugins/course-apps/proctoring/messages.js diff --git a/plugins/course-apps/proctoring/package.json b/plugins/course-apps/proctoring/package.json new file mode 100644 index 000000000..82801ab69 --- /dev/null +++ b/plugins/course-apps/proctoring/package.json @@ -0,0 +1,20 @@ +{ + "name": "@openedx-plugins/course-app-proctoring", + "version": "0.1.0", + "description": "Proctoring configuration for courses using it", + "peerDependencies": { + "@edx/frontend-app-course-authoring": "*", + "@edx/frontend-platform": "*", + "@openedx/paragon": "*", + "classnames": "*", + "email-validator": "*", + "react": "*", + "prop-types": "*", + "moment": "*" + }, + "peerDependenciesMeta": { + "@edx/frontend-app-course-authoring": { + "optional": true + } + } +} diff --git a/src/pages-and-resources/progress/Settings.jsx b/plugins/course-apps/progress/Settings.jsx similarity index 89% rename from src/pages-and-resources/progress/Settings.jsx rename to plugins/course-apps/progress/Settings.jsx index 965f110b1..248ae4abb 100644 --- a/src/pages-and-resources/progress/Settings.jsx +++ b/plugins/course-apps/progress/Settings.jsx @@ -3,9 +3,9 @@ import PropTypes from 'prop-types'; import React from 'react'; import * as Yup from 'yup'; import { getConfig } from '@edx/frontend-platform'; -import FormSwitchGroup from '../../generic/FormSwitchGroup'; -import { useAppSetting } from '../../utils'; -import AppSettingsModal from '../app-settings-modal/AppSettingsModal'; +import FormSwitchGroup from 'CourseAuthoring/generic/FormSwitchGroup'; +import { useAppSetting } from 'CourseAuthoring/utils'; +import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal'; import messages from './messages'; const ProgressSettings = ({ intl, onClose }) => { diff --git a/src/pages-and-resources/progress/messages.js b/plugins/course-apps/progress/messages.js similarity index 100% rename from src/pages-and-resources/progress/messages.js rename to plugins/course-apps/progress/messages.js diff --git a/plugins/course-apps/progress/package.json b/plugins/course-apps/progress/package.json new file mode 100644 index 000000000..1541af390 --- /dev/null +++ b/plugins/course-apps/progress/package.json @@ -0,0 +1,18 @@ +{ + "name": "@openedx-plugins/course-app-progress", + "version": "0.1.0", + "description": "Progress configuration for courses using it", + "peerDependencies": { + "@edx/frontend-app-course-authoring": "*", + "@edx/frontend-platform": "*", + "@openedx/paragon": "*", + "prop-types": "*", + "react": "*", + "yup": "*" + }, + "peerDependenciesMeta": { + "@edx/frontend-app-course-authoring": { + "optional": true + } + } +} diff --git a/src/pages-and-resources/teams/GroupEditor.jsx b/plugins/course-apps/teams/GroupEditor.jsx similarity index 92% rename from src/pages-and-resources/teams/GroupEditor.jsx rename to plugins/course-apps/teams/GroupEditor.jsx index 3785e4432..0e96d6ad6 100644 --- a/src/pages-and-resources/teams/GroupEditor.jsx +++ b/plugins/course-apps/teams/GroupEditor.jsx @@ -2,11 +2,12 @@ import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { Button, Form, TransitionReplace } from '@openedx/paragon'; import PropTypes from 'prop-types'; import React, { useState } from 'react'; -import { GroupTypes, TeamSizes } from '../../data/constants'; +import { GroupTypes, TeamSizes } from 'CourseAuthoring/data/constants'; -import CollapsableEditor from '../../generic/CollapsableEditor'; -import FormikControl from '../../generic/FormikControl'; +import CollapsableEditor from 'CourseAuthoring/generic/CollapsableEditor'; +import FormikControl from 'CourseAuthoring/generic/FormikControl'; import messages from './messages'; +import { isGroupTypeEnabled } from './utils'; // Maps a team type to its corresponding intl message const TeamTypeNameMessage = { @@ -14,6 +15,10 @@ const TeamTypeNameMessage = { label: messages.groupTypeOpen, description: messages.groupTypeOpenDescription, }, + [GroupTypes.OPEN_MANAGED]: { + label: messages.groupTypeOpenManaged, + description: messages.groupTypeOpenManagedDescription, + }, [GroupTypes.PUBLIC_MANAGED]: { label: messages.groupTypePublicManaged, description: messages.groupTypePublicManagedDescription, @@ -105,7 +110,7 @@ const GroupEditor = ({ onChange={onChange} onBlur={onBlur} > - {Object.values(GroupTypes).map(groupType => ( + {Object.values(GroupTypes).map(groupType => isGroupTypeEnabled(groupType) && ( ({ + ...jest.requireActual('formik'), + useFormikContext: jest.fn(), +})); + +describe('GroupEditor', () => { + const mockIntl = { formatMessage: jest.fn() }; + + const mockGroup = { + id: '1', + name: 'Test Group', + description: 'Test Group Description', + type: 'open', + maxTeamSize: 5, + }; + + const mockProps = { + intl: mockIntl, + fieldNameCommonBase: 'test', + group: mockGroup, + onDelete: jest.fn(), + onChange: jest.fn(), + onBlur: jest.fn(), + errors: {}, + }; + + const renderComponent = (overrideProps = {}) => render( + + + , + ); + + beforeEach(() => { + useFormikContext.mockReturnValue({ + touched: {}, + errors: {}, + handleChange: jest.fn(), + handleBlur: jest.fn(), + setFieldError: jest.fn(), + }); + + jest.clearAllMocks(); + }); + + test('renders without errors', () => { + renderComponent(); + }); + + test('renders the group name and description', () => { + const { getByText } = renderComponent(); + expect(getByText('Test Group')).toBeInTheDocument(); + expect(getByText('Test Group Description')).toBeInTheDocument(); + }); + + describe('group types messages', () => { + test('group type open message', () => { + const { getByLabelText, getByText } = renderComponent(); + const expandButton = getByLabelText('Expand group editor'); + expect(expandButton).toBeInTheDocument(); + fireEvent.click(expandButton); + expect(getByText(messages.groupTypeOpenDescription.defaultMessage)).toBeInTheDocument(); + }); + + test('group type public_managed message', () => { + const publicManagedGroupMock = { + id: '2', + name: 'Test Group', + description: 'Test Group Description', + type: 'public_managed', + maxTeamSize: 5, + }; + const { getByLabelText, getByText } = renderComponent({ group: publicManagedGroupMock }); + const expandButton = getByLabelText('Expand group editor'); + expect(expandButton).toBeInTheDocument(); + fireEvent.click(expandButton); + expect(getByText(messages.groupTypePublicManagedDescription.defaultMessage)).toBeInTheDocument(); + }); + + test('group type private_managed message', () => { + const privateManagedGroupMock = { + id: '3', + name: 'Test Group', + description: 'Test Group Description', + type: 'private_managed', + maxTeamSize: 5, + }; + const { getByLabelText, getByText } = renderComponent({ group: privateManagedGroupMock }); + const expandButton = getByLabelText('Expand group editor'); + expect(expandButton).toBeInTheDocument(); + fireEvent.click(expandButton); + expect(getByText(messages.groupTypePrivateManagedDescription.defaultMessage)).toBeInTheDocument(); + }); + }); +}); diff --git a/src/pages-and-resources/teams/Settings.jsx b/plugins/course-apps/teams/Settings.jsx similarity index 94% rename from src/pages-and-resources/teams/Settings.jsx rename to plugins/course-apps/teams/Settings.jsx index d6984e5a2..6f2514091 100644 --- a/src/pages-and-resources/teams/Settings.jsx +++ b/plugins/course-apps/teams/Settings.jsx @@ -7,10 +7,10 @@ import PropTypes from 'prop-types'; import React from 'react'; import { v4 as uuid } from 'uuid'; import * as Yup from 'yup'; -import { GroupTypes, TeamSizes } from '../../data/constants'; -import FormikControl from '../../generic/FormikControl'; -import { setupYupExtensions, useAppSetting } from '../../utils'; -import AppSettingsModal from '../app-settings-modal/AppSettingsModal'; +import { GroupTypes, TeamSizes } from 'CourseAuthoring/data/constants'; +import FormikControl from 'CourseAuthoring/generic/FormikControl'; +import { setupYupExtensions, useAppSetting } from 'CourseAuthoring/utils'; +import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal'; import GroupEditor from './GroupEditor'; import messages from './messages'; diff --git a/src/pages-and-resources/teams/messages.js b/plugins/course-apps/teams/messages.js similarity index 94% rename from src/pages-and-resources/teams/messages.js rename to plugins/course-apps/teams/messages.js index eeb809c96..1caf514e5 100644 --- a/src/pages-and-resources/teams/messages.js +++ b/plugins/course-apps/teams/messages.js @@ -93,6 +93,14 @@ const messages = defineMessages({ id: 'authoring.pagesAndResources.teams.group.types.open', defaultMessage: 'Open', }, + groupTypeOpenManaged: { + id: 'authoring.pagesAndResources.teams.group.types.open_managed', + defaultMessage: 'Open managed', + }, + groupTypeOpenManagedDescription: { + id: 'authoring.pagesAndResources.teams.group.types.open_managed.description', + defaultMessage: 'Only course staff can create teams. Learners can see, join and leave teams.', + }, groupTypeOpenDescription: { id: 'authoring.pagesAndResources.teams.group.types.open.description', defaultMessage: 'Learners can create, join, leave, and see other teams', diff --git a/plugins/course-apps/teams/package.json b/plugins/course-apps/teams/package.json new file mode 100644 index 000000000..64471e694 --- /dev/null +++ b/plugins/course-apps/teams/package.json @@ -0,0 +1,20 @@ +{ + "name": "@openedx-plugins/course-app-teams", + "version": "0.1.0", + "description": "Teams configuration for courses using it", + "peerDependencies": { + "@edx/frontend-app-course-authoring": "*", + "@edx/frontend-platform": "*", + "@openedx/paragon": "*", + "formik": "*", + "prop-types": "*", + "react": "*", + "uuid": "*", + "yup": "*" + }, + "peerDependenciesMeta": { + "@edx/frontend-app-course-authoring": { + "optional": true + } + } +} diff --git a/plugins/course-apps/teams/utils.js b/plugins/course-apps/teams/utils.js new file mode 100644 index 000000000..3bc8b39d3 --- /dev/null +++ b/plugins/course-apps/teams/utils.js @@ -0,0 +1,23 @@ +/* eslint-disable import/prefer-default-export */ +import { getConfig } from '@edx/frontend-platform'; + +import { GroupTypes } from 'CourseAuthoring/data/constants'; + +/** + * Check if a group type is enabled by the current configuration. + * This is a temporary workaround to disable the OPEN MANAGED team type until it is fully adopted. + * For more information, see: https://openedx.atlassian.net/wiki/spaces/COMM/pages/3885760525/Open+Managed+Group+Type + * @param {string} groupType - the group type to check + * @returns {boolean} - true if the group type is enabled + */ +export const isGroupTypeEnabled = (groupType) => { + const enabledTypesByDefault = [ + GroupTypes.OPEN, + GroupTypes.PUBLIC_MANAGED, + GroupTypes.PRIVATE_MANAGED, + ]; + const enabledTypesByConfig = { + [GroupTypes.OPEN_MANAGED]: getConfig().ENABLE_OPEN_MANAGED_TEAM_TYPE, + }; + return enabledTypesByDefault.includes(groupType) || enabledTypesByConfig[groupType] || false; +}; diff --git a/plugins/course-apps/teams/utils.test.js b/plugins/course-apps/teams/utils.test.js new file mode 100644 index 000000000..3b7324cc9 --- /dev/null +++ b/plugins/course-apps/teams/utils.test.js @@ -0,0 +1,39 @@ +import { getConfig } from '@edx/frontend-platform'; +import { GroupTypes } from 'CourseAuthoring/data/constants'; +import { isGroupTypeEnabled } from './utils'; + +jest.mock('@edx/frontend-platform', () => ({ getConfig: jest.fn() })); + +describe('teams utils', () => { + describe('isGroupTypeEnabled', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('returns true if the group type is enabled', () => { + getConfig.mockReturnValue({ ENABLE_OPEN_MANAGED_TEAM_TYPE: false }); + expect(isGroupTypeEnabled(GroupTypes.OPEN)).toBe(true); + expect(isGroupTypeEnabled(GroupTypes.PUBLIC_MANAGED)).toBe(true); + expect(isGroupTypeEnabled(GroupTypes.PRIVATE_MANAGED)).toBe(true); + }); + test('returns false if the OPEN_MANAGED group is not enabled', () => { + getConfig.mockReturnValue({ ENABLE_OPEN_MANAGED_TEAM_TYPE: false }); + expect(isGroupTypeEnabled(GroupTypes.OPEN_MANAGED)).toBe(false); + }); + + test('returns true if the OPEN_MANAGED group is enabled', () => { + getConfig.mockReturnValue({ ENABLE_OPEN_MANAGED_TEAM_TYPE: true }); + expect(isGroupTypeEnabled(GroupTypes.OPEN_MANAGED)).toBe(true); + }); + + test('returns false if the group is invalid', () => { + getConfig.mockReturnValue({ ENABLE_OPEN_MANAGED_TEAM_TYPE: true }); + expect(isGroupTypeEnabled('FOO')).toBe(false); + }); + + test('returns false if the group is null', () => { + getConfig.mockReturnValue({ ENABLE_OPEN_MANAGED_TEAM_TYPE: true }); + expect(isGroupTypeEnabled(null)).toBe(false); + }); + }); +}); diff --git a/src/pages-and-resources/wiki/Settings.jsx b/plugins/course-apps/wiki/Settings.jsx similarity index 86% rename from src/pages-and-resources/wiki/Settings.jsx rename to plugins/course-apps/wiki/Settings.jsx index a2711629c..e71b27f81 100644 --- a/src/pages-and-resources/wiki/Settings.jsx +++ b/plugins/course-apps/wiki/Settings.jsx @@ -3,9 +3,9 @@ import PropTypes from 'prop-types'; import React from 'react'; import * as Yup from 'yup'; -import FormSwitchGroup from '../../generic/FormSwitchGroup'; -import { useAppSetting } from '../../utils'; -import AppSettingsModal from '../app-settings-modal/AppSettingsModal'; +import FormSwitchGroup from 'CourseAuthoring/generic/FormSwitchGroup'; +import { useAppSetting } from 'CourseAuthoring/utils'; +import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal'; import messages from './messages'; const WikiSettings = ({ intl, onClose }) => { diff --git a/src/pages-and-resources/wiki/messages.js b/plugins/course-apps/wiki/messages.js similarity index 100% rename from src/pages-and-resources/wiki/messages.js rename to plugins/course-apps/wiki/messages.js diff --git a/plugins/course-apps/wiki/package.json b/plugins/course-apps/wiki/package.json new file mode 100644 index 000000000..e14e897db --- /dev/null +++ b/plugins/course-apps/wiki/package.json @@ -0,0 +1,18 @@ +{ + "name": "@openedx-plugins/course-app-wiki", + "version": "0.1.0", + "description": "Wiki configuration for courses using it", + "peerDependencies": { + "@edx/frontend-app-course-authoring": "*", + "@edx/frontend-platform": "*", + "@openedx/paragon": "*", + "prop-types": "*", + "react": "*", + "yup": "*" + }, + "peerDependenciesMeta": { + "@edx/frontend-app-course-authoring": { + "optional": true + } + } +} diff --git a/plugins/course-apps/xpert_unit_summary/README.rst b/plugins/course-apps/xpert_unit_summary/README.rst new file mode 100644 index 000000000..6c3c16278 --- /dev/null +++ b/plugins/course-apps/xpert_unit_summary/README.rst @@ -0,0 +1,4 @@ +Xpert Unit Summaries Configuration Plugin +========================================= + +Install this using ``npm install plugins/course-apps/xpert_unit_summary/ --no-save``. diff --git a/src/pages-and-resources/xpert-unit-summary/XpertUnitSummarySettings.jsx b/plugins/course-apps/xpert_unit_summary/Settings.jsx similarity index 93% rename from src/pages-and-resources/xpert-unit-summary/XpertUnitSummarySettings.jsx rename to plugins/course-apps/xpert_unit_summary/Settings.jsx index 664cad5c4..99825b9b7 100644 --- a/src/pages-and-resources/xpert-unit-summary/XpertUnitSummarySettings.jsx +++ b/plugins/course-apps/xpert_unit_summary/Settings.jsx @@ -2,8 +2,8 @@ import React, { useCallback, useContext, useEffect } from 'react'; import { useDispatch } from 'react-redux'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { PagesAndResourcesContext } from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider'; import { useNavigate } from 'react-router-dom'; -import { PagesAndResourcesContext } from '../PagesAndResourcesProvider'; import SettingsModal from './settings-modal/SettingsModal'; import messages from './messages'; diff --git a/src/pages-and-resources/xpert-unit-summary/XpertUnitSummarySettings.test.jsx b/plugins/course-apps/xpert_unit_summary/Settings.test.jsx similarity index 97% rename from src/pages-and-resources/xpert-unit-summary/XpertUnitSummarySettings.test.jsx rename to plugins/course-apps/xpert_unit_summary/Settings.test.jsx index 000b09999..69f9ba5e9 100644 --- a/src/pages-and-resources/xpert-unit-summary/XpertUnitSummarySettings.test.jsx +++ b/plugins/course-apps/xpert_unit_summary/Settings.test.jsx @@ -10,12 +10,13 @@ import { queryByTestId, render, waitFor, getByText, fireEvent, } from '@testing-library/react'; import MockAdapter from 'axios-mock-adapter'; -import PagesAndResourcesProvider from '../PagesAndResourcesProvider'; -import { XpertUnitSummarySettings } from './index'; -import initializeStore from '../../store'; +import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider'; +import initializeStore from 'CourseAuthoring/store'; +import { executeThunk } from 'CourseAuthoring/utils'; + +import XpertUnitSummarySettings from './Settings'; import * as API from './data/api'; import * as Thunks from './data/thunks'; -import { executeThunk } from '../../utils'; const courseId = 'course-v1:edX+TestX+Test_Course'; let axiosMock; diff --git a/src/pages-and-resources/xpert-unit-summary/appInfo.js b/plugins/course-apps/xpert_unit_summary/appInfo.js similarity index 100% rename from src/pages-and-resources/xpert-unit-summary/appInfo.js rename to plugins/course-apps/xpert_unit_summary/appInfo.js diff --git a/src/pages-and-resources/xpert-unit-summary/data/api.js b/plugins/course-apps/xpert_unit_summary/data/api.js similarity index 100% rename from src/pages-and-resources/xpert-unit-summary/data/api.js rename to plugins/course-apps/xpert_unit_summary/data/api.js diff --git a/src/pages-and-resources/xpert-unit-summary/data/thunks.js b/plugins/course-apps/xpert_unit_summary/data/thunks.js similarity index 94% rename from src/pages-and-resources/xpert-unit-summary/data/thunks.js rename to plugins/course-apps/xpert_unit_summary/data/thunks.js index b79a36ccb..270fdce5f 100644 --- a/src/pages-and-resources/xpert-unit-summary/data/thunks.js +++ b/plugins/course-apps/xpert_unit_summary/data/thunks.js @@ -1,12 +1,11 @@ +import { updateSavingStatus, updateLoadingStatus, updateResetStatus } from 'CourseAuthoring/pages-and-resources/data/slice'; +import { RequestStatus } from 'CourseAuthoring/data/constants'; +import { addModel, updateModel } from 'CourseAuthoring/generic/model-store'; + import { getXpertSettings, postXpertSettings, getXpertPluginConfigurable, deleteXpertSettings, } from './api'; -import { updateSavingStatus, updateLoadingStatus, updateResetStatus } from '../../data/slice'; -import { RequestStatus } from '../../../data/constants'; - -import { addModel, updateModel } from '../../../generic/model-store'; - export function updateXpertSettings(courseId, state) { return async (dispatch) => { dispatch(updateSavingStatus({ status: RequestStatus.IN_PROGRESS })); diff --git a/src/pages-and-resources/xpert-unit-summary/messages.js b/plugins/course-apps/xpert_unit_summary/messages.js similarity index 100% rename from src/pages-and-resources/xpert-unit-summary/messages.js rename to plugins/course-apps/xpert_unit_summary/messages.js diff --git a/plugins/course-apps/xpert_unit_summary/package.json b/plugins/course-apps/xpert_unit_summary/package.json new file mode 100644 index 000000000..e8850ecdf --- /dev/null +++ b/plugins/course-apps/xpert_unit_summary/package.json @@ -0,0 +1,21 @@ +{ + "name": "@openedx-plugins/course-app-xpert_unit_summary", + "version": "0.1.0", + "description": "Xpert Unit Summaries configuration for courses using it", + "peerDependencies": { + "@edx/frontend-app-course-authoring": "*", + "@edx/frontend-platform": "*", + "@openedx/paragon": "*", + "formik": "*", + "prop-types": "*", + "yup": "*", + "react": "*", + "react-redux": "*", + "react-router-dom": "*" + }, + "peerDependenciesMeta": { + "@edx/frontend-app-course-authoring": { + "optional": true + } + } +} diff --git a/src/pages-and-resources/xpert-unit-summary/settings-modal/ResetIcon.jsx b/plugins/course-apps/xpert_unit_summary/settings-modal/ResetIcon.jsx similarity index 100% rename from src/pages-and-resources/xpert-unit-summary/settings-modal/ResetIcon.jsx rename to plugins/course-apps/xpert_unit_summary/settings-modal/ResetIcon.jsx diff --git a/src/pages-and-resources/xpert-unit-summary/settings-modal/SettingsModal.jsx b/plugins/course-apps/xpert_unit_summary/settings-modal/SettingsModal.jsx similarity index 93% rename from src/pages-and-resources/xpert-unit-summary/settings-modal/SettingsModal.jsx rename to plugins/course-apps/xpert_unit_summary/settings-modal/SettingsModal.jsx index 8c31ddad0..2d98304d1 100644 --- a/src/pages-and-resources/xpert-unit-summary/settings-modal/SettingsModal.jsx +++ b/plugins/course-apps/xpert_unit_summary/settings-modal/SettingsModal.jsx @@ -24,22 +24,25 @@ import React, { import { useDispatch, useSelector } from 'react-redux'; import * as Yup from 'yup'; -import { RequestStatus } from '../../../data/constants'; -import ConnectionErrorAlert from '../../../generic/ConnectionErrorAlert'; -import FormSwitchGroup from '../../../generic/FormSwitchGroup'; -import Loading from '../../../generic/Loading'; -import { useModel } from '../../../generic/model-store'; -import PermissionDeniedAlert from '../../../generic/PermissionDeniedAlert'; -import { useIsMobile } from '../../../utils'; -import { getLoadingStatus, getSavingStatus, getResetStatus } from '../../data/selectors'; -import { updateSavingStatus, updateResetStatus } from '../../data/slice'; +import { RequestStatus } from 'CourseAuthoring/data/constants'; +import ConnectionErrorAlert from 'CourseAuthoring/generic/ConnectionErrorAlert'; +import FormSwitchGroup from 'CourseAuthoring/generic/FormSwitchGroup'; +import Loading from 'CourseAuthoring/generic/Loading'; +import { useModel } from 'CourseAuthoring/generic/model-store'; +import PermissionDeniedAlert from 'CourseAuthoring/generic/PermissionDeniedAlert'; +import { useIsMobile } from 'CourseAuthoring/utils'; +import { getLoadingStatus, getSavingStatus, getResetStatus } from 'CourseAuthoring/pages-and-resources/data/selectors'; +import { updateSavingStatus, updateResetStatus } from 'CourseAuthoring/pages-and-resources/data/slice'; +import AppConfigFormDivider from 'CourseAuthoring/pages-and-resources/discussions/app-config-form/apps/shared/AppConfigFormDivider'; +import { PagesAndResourcesContext } from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider'; + import { updateXpertSettings, resetXpertSettings, removeXpertSettings } from '../data/thunks'; -import AppConfigFormDivider from '../../discussions/app-config-form/apps/shared/AppConfigFormDivider'; -import { PagesAndResourcesContext } from '../../PagesAndResourcesProvider'; import messages from './messages'; import appInfo from '../appInfo'; import ResetIcon from './ResetIcon'; +import './SettingsModal.scss'; + const AppSettingsForm = ({ formikProps, children, showForm, }) => children && ( diff --git a/src/pages-and-resources/xpert-unit-summary/settings-modal/SettingsModal.scss b/plugins/course-apps/xpert_unit_summary/settings-modal/SettingsModal.scss similarity index 88% rename from src/pages-and-resources/xpert-unit-summary/settings-modal/SettingsModal.scss rename to plugins/course-apps/xpert_unit_summary/settings-modal/SettingsModal.scss index 82942e210..264e0c7b1 100644 --- a/src/pages-and-resources/xpert-unit-summary/settings-modal/SettingsModal.scss +++ b/plugins/course-apps/xpert_unit_summary/settings-modal/SettingsModal.scss @@ -1,3 +1,6 @@ +@import "~@edx/brand/paragon/variables"; +@import "~@openedx/paragon/scss/core/utilities-only"; + .summary-radio { display: flex; align-items: center; diff --git a/src/pages-and-resources/xpert-unit-summary/settings-modal/messages.js b/plugins/course-apps/xpert_unit_summary/settings-modal/messages.js similarity index 100% rename from src/pages-and-resources/xpert-unit-summary/settings-modal/messages.js rename to plugins/course-apps/xpert_unit_summary/settings-modal/messages.js diff --git a/src/CourseAuthoringRoutes.jsx b/src/CourseAuthoringRoutes.jsx index bdeffa110..910269974 100644 --- a/src/CourseAuthoringRoutes.jsx +++ b/src/CourseAuthoringRoutes.jsx @@ -20,6 +20,7 @@ import { CourseUnit } from './course-unit'; import CourseExportPage from './export-page/CourseExportPage'; import CourseImportPage from './import-page/CourseImportPage'; import { DECODED_ROUTES } from './constants'; +import CourseChecklist from './course-checklist'; /** * As of this writing, these routes are mounted at a path prefixed with the following: @@ -73,6 +74,7 @@ const CourseAuthoringRoutes = () => { /> {DECODED_ROUTES.COURSE_UNIT.map((path) => ( } /> @@ -109,6 +111,10 @@ const CourseAuthoringRoutes = () => { path="export" element={} /> + } + /> ); diff --git a/src/accessibility-page/AccessibilityBody/AccessibilityBody.jsx b/src/accessibility-page/AccessibilityBody/AccessibilityBody.jsx new file mode 100644 index 000000000..40da33c91 --- /dev/null +++ b/src/accessibility-page/AccessibilityBody/AccessibilityBody.jsx @@ -0,0 +1,98 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { injectIntl, FormattedMessage } from '@edx/frontend-platform/i18n'; +import { Hyperlink, MailtoLink, Stack } from '@openedx/paragon'; + +import messages from './messages'; + +const AccessibilityBody = ({ + communityAccessibilityLink, + email, +}) => ( +
+
+

+ +

+
+ +
+ + Website Accessibility Policy + + ), + }} + /> +
+
+ +
+
    +
  1. + + {email} + + ), + }} + /> +
      +
    • + +
    • +
    • + +
    • +
    • + +
    • +
    • + +
    • +
    +
  2. +
  3. + +
  4. +
  5. + +
  6. +
+
+ + {email} + + ), + }} + /> +
+
+
+); + +AccessibilityBody.propTypes = { + communityAccessibilityLink: PropTypes.string.isRequired, + email: PropTypes.string.isRequired, +}; + +export default injectIntl(AccessibilityBody); diff --git a/src/accessibility-page/AccessibilityBody/AccessibilityBody.test.jsx b/src/accessibility-page/AccessibilityBody/AccessibilityBody.test.jsx new file mode 100644 index 000000000..bcb5f3c49 --- /dev/null +++ b/src/accessibility-page/AccessibilityBody/AccessibilityBody.test.jsx @@ -0,0 +1,46 @@ +import { + render, + screen, +} from '@testing-library/react'; +import { AppProvider } from '@edx/frontend-platform/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { initializeMockApp } from '@edx/frontend-platform'; +import initializeStore from '../../store'; + +import AccessibilityBody from './index'; + +let store; + +const renderComponent = () => { + render( + + + + + , + ); +}; + +describe('', () => { + describe('renders', () => { + beforeEach(async () => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: false, + roles: [], + }, + }); + store = initializeStore({}); + }); + it('contains links', () => { + renderComponent(); + expect(screen.getAllByTestId('email-element')).toHaveLength(2); + expect(screen.getAllByTestId('accessibility-page-link')).toHaveLength(1); + }); + }); +}); diff --git a/src/accessibility-page/AccessibilityBody/index.js b/src/accessibility-page/AccessibilityBody/index.js new file mode 100644 index 000000000..a46720ac6 --- /dev/null +++ b/src/accessibility-page/AccessibilityBody/index.js @@ -0,0 +1,3 @@ +import AccessibilityBody from './AccessibilityBody'; + +export default AccessibilityBody; diff --git a/src/accessibility-page/AccessibilityBody/messages.js b/src/accessibility-page/AccessibilityBody/messages.js new file mode 100644 index 000000000..78384b80e --- /dev/null +++ b/src/accessibility-page/AccessibilityBody/messages.js @@ -0,0 +1,111 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + a11yBodyPolicyLink: { + id: 'a11yBodyPolicyLink', + defaultMessage: 'Website Accessibility Policy', + description: 'Title for link to full accessibility policy.', + }, + a11yBodyPageHeader: { + id: 'a11yBodyPageHeader', + defaultMessage: 'Individualized Accessibility Process for Course Creators', + description: 'Heading for studio\'s accessibility policy page.', + }, + a11yBodyIntroGraph: { + id: 'a11yBodyIntroGraph', + defaultMessage: `At edX, we seek to understand and respect the unique needs and perspectives of the edX global community. + We value every course team and are committed to expanding access to all, including course team creators and authors with + disabilities. To that end, we have adopted a {communityAccessibilityLink} and this process to allow course team creators + and authors to request assistance if they are unable to develop and post content on our platform via Studio because of their + disabilities.`, + description: 'Introductory paragraph outlining why we care about accessibility, and what we\'re doing about it.', + }, + a11yBodyStepsHeader: { + id: 'a11yBodyStepsHeader', + defaultMessage: 'Course team creators and authors needing such assistance should take the following steps:', + description: 'Heading for list of steps authors can take for accessibility requests.', + }, + a11yBodyEdxResponse: { + id: 'a11yBodyEdxResponse', + defaultMessage: `'We will communicate with you about your preferences and needs in determining the appropriate solution, although + the ultimate decision will be ours, provided that the solution is effective and timely. The factors we will consider in choosing + an accessibility solution are: effectiveness; timeliness (relative to your deadlines); ease of implementation; and ease of use for + you. We will notify you of the decision and explain the basis for our decision within 10 business days of discussing with you.`, + description: 'Paragraph outlining how we will select an accessibility solution.', + }, + a11yBodyEdxFollowUp: { + id: 'a11yBodyEdxFollowUp', + defaultMessage: `Thereafter, we will communicate with you on a weekly basis regarding our evaluation, decision, and progress in + implementing the accessibility solution. We will notify you when implementation of your accessibility solution is complete and + will follow-up with you as may be necessary to see if the solution was effective.`, + description: 'Paragraph outlining how we will follow-up with you during and after implementing an accessibility solution.', + }, + a11yBodyOngoingSupport: { + id: 'a11yBodyOngoingSupport', + defaultMessage: 'EdX will provide ongoing technical support as needed and will address any additional issues that arise after the initial course creation.', + description: 'A statement of ongoing support.', + }, + a11yBodyA11yFeedback: { + id: 'a11yBodyA11yFeedback', + defaultMessage: 'Please direct any questions or suggestions on how to improve the accessibility of Studio to {emailElement} or use the form below. We welcome your feedback.', + description: 'Contact information heading for those with accessibility issues or suggestions.', + }, + a11yBodyEmailHeading: { + id: 'a11yBodyEmailHeading', + defaultMessage: 'Send an email to {emailElement} with the following information:', + description: 'Heading for list of information required when you email us.', + }, + a11yBodyNameEmail: { + id: 'a11yBodyNameEmail', + defaultMessage: 'your name and email address;', + description: 'Your contact information.', + }, + a11yBodyInstitution: { + id: 'a11yBodyInstitution', + defaultMessage: 'the edX member institution that you are affiliated with;', + description: 'edX affiliate information.', + }, + a11yBodyBarrier: { + id: 'a11yBodyBarrier', + defaultMessage: 'a brief description of the challenge or barrier to access that you are experiencing; and', + description: 'Accessibility problem information.', + }, + a11yBodyTimeConstraints: { + id: 'a11yBodyTimeConstraints', + defaultMessage: 'how soon you need access and for how long (e.g., a planned course start date or in connection with a course-related deadline such as a final essay).', + description: 'Time contstraint information.', + }, + a11yBodyReceipt: { + id: 'a11yBodyReceipt', + defaultMessage: 'The edX Support Team will respond to confirm receipt and forward your request to the edX Partner Manager for your institution and the edX Website Accessibility Specialist.', + description: 'Paragraph outlining what steps edX will take immediately.', + }, + a11yBodyExtraInfo: { + id: 'a11yBodyExtraInfo', + defaultMessage: `With guidance from the Website Accessibility Specialist, edX will contact you to discuss your request and gather + additional information from you about your preferences and needs, to determine if there's a workable solution that edX is able to support.`, + description: 'Paragraph outlining how and when edX will reach out to you.', + }, + a11yBodyFixesListHeader: { + id: 'a11yBodyFixesListHeader', + defaultMessage: 'EdX will assist you promptly and thoroughly so that you are able to create content on the CMS within your time constraints. Such efforts may include, but are not limited to:', + description: 'Heading for list of ways we might be able to assist.', + }, + a11yBodyThirdParty: { + id: 'a11yBodyThirdParty', + defaultMessage: 'Purchasing a third-party tool or software for use on an individual basis to assist your use of Studio;', + description: 'Buy third-party software.', + }, + a11yBodyContractor: { + id: 'a11yBodyContractor', + defaultMessage: 'Engaging a trained independent contractor to provide real-time visual, verbal and physical assistance; or', + description: 'Hire a contractor.', + }, + a11yBodyCodeFix: { + id: 'a11yBodyCodeFix', + defaultMessage: 'Developing new code to implement a technical fix.', + description: 'Make a technical fix.', + }, +}); + +export default messages; diff --git a/src/accessibility-page/AccessibilityForm/AccessibilityForm.jsx b/src/accessibility-page/AccessibilityForm/AccessibilityForm.jsx new file mode 100644 index 000000000..19587e2a7 --- /dev/null +++ b/src/accessibility-page/AccessibilityForm/AccessibilityForm.jsx @@ -0,0 +1,146 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { + injectIntl, FormattedMessage, intlShape, FormattedDate, FormattedTime, +} from '@edx/frontend-platform/i18n'; +import { + ActionRow, Alert, Form, Stack, StatefulButton, +} from '@openedx/paragon'; + +import { RequestStatus } from '../../data/constants'; +import { STATEFUL_BUTTON_STATES } from '../../constants'; +import submitAccessibilityForm from '../data/thunks'; +import useAccessibility from './hooks'; +import messages from './messages'; + +const AccessibilityForm = ({ + accessibilityEmail, + // injected + intl, +}) => { + const { + errors, + values, + isFormFilled, + dispatch, + handleBlur, + handleChange, + hasErrorField, + savingStatus, + } = useAccessibility({ name: '', email: '', message: '' }, intl); + + const formFields = [ + { + label: intl.formatMessage(messages.accessibilityPolicyFormEmailLabel), + name: 'email', + value: values.email, + }, + { + label: intl.formatMessage(messages.accessibilityPolicyFormNameLabel), + name: 'name', + value: values.name, + }, + { + label: intl.formatMessage(messages.accessibilityPolicyFormMessageLabel), + name: 'message', + value: values.message, + }, + ]; + + const createButtonState = { + labels: { + default: intl.formatMessage(messages.accessibilityPolicyFormSubmitLabel), + pending: intl.formatMessage(messages.accessibilityPolicyFormSubmittingFeedbackLabel), + }, + disabledStates: [STATEFUL_BUTTON_STATES.pending], + }; + + const handleSubmit = () => { + dispatch(submitAccessibilityForm(values)); + }; + + const start = new Date('Mon Jan 29 2018 13:00:00 GMT (UTC)'); + const end = new Date('Fri Feb 2 2018 21:00:00 GMT (UTC)'); + + return ( + <> +

+ +

+ {savingStatus === RequestStatus.SUCCESSFUL && ( + + +
+ +
+
+ ), + time_start: (), + day_end: (), + time_end: (), + }} + /> +
+
+
+ )} + {savingStatus === RequestStatus.FAILED && ( + +
+ {accessibilityEmail}, + }} + /> +
+
+ )} +
+ {formFields.map((field) => ( + + + {hasErrorField(field.name) && ( + + {errors[field.name]} + + )} + + ))} +
+ + + + + ); +}; + +AccessibilityForm.propTypes = { + accessibilityEmail: PropTypes.string.isRequired, + // injected + intl: intlShape.isRequired, +}; + +export default injectIntl(AccessibilityForm); diff --git a/src/accessibility-page/AccessibilityForm/AccessibilityForm.test.jsx b/src/accessibility-page/AccessibilityForm/AccessibilityForm.test.jsx new file mode 100644 index 000000000..cc15f8126 --- /dev/null +++ b/src/accessibility-page/AccessibilityForm/AccessibilityForm.test.jsx @@ -0,0 +1,164 @@ +import { + render, + act, + screen, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { initializeMockApp } from '@edx/frontend-platform'; +import MockAdapter from 'axios-mock-adapter'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { AppProvider } from '@edx/frontend-platform/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; + +import initializeStore from '../../store'; +import { RequestStatus } from '../../data/constants'; + +import AccessibilityForm from './index'; +import { getZendeskrUrl } from '../data/api'; +import messages from './messages'; + +let axiosMock; +let store; + +const defaultProps = { + accessibilityEmail: 'accessibilityTest@test.com', +}; + +const initialState = { + accessibilityPage: { + savingStatus: '', + }, +}; + +const renderComponent = () => { + render( + + + + + , + ); +}; + +describe('', () => { + beforeEach(async () => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: false, + roles: [], + }, + }); + store = initializeStore(initialState); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + }); + + describe('renders', () => { + beforeEach(() => { + renderComponent(); + }); + + it('correct number of form fields', () => { + const formSections = screen.getAllByRole('textbox'); + const formButton = screen.getByText(messages.accessibilityPolicyFormSubmitLabel.defaultMessage); + expect(formSections).toHaveLength(3); + expect(formButton).toBeVisible(); + }); + + it('hides StatusAlert on initial load', () => { + expect(screen.queryAllByRole('alert')).toHaveLength(0); + }); + }); + + describe('statusAlert', () => { + let formSections; + let submitButton; + beforeEach(async () => { + renderComponent(); + formSections = screen.getAllByRole('textbox'); + await act(async () => { + userEvent.type(formSections[0], 'email@email.com'); + userEvent.type(formSections[1], 'test name'); + userEvent.type(formSections[2], 'feedback message'); + }); + submitButton = screen.getByText(messages.accessibilityPolicyFormSubmitLabel.defaultMessage); + }); + + it('shows correct success message', async () => { + axiosMock.onPost(getZendeskrUrl()).reply(200); + await act(async () => { + userEvent.click(submitButton); + }); + const { savingStatus } = store.getState().accessibilityPage; + expect(savingStatus).toEqual(RequestStatus.SUCCESSFUL); + + expect(screen.getAllByRole('alert')).toHaveLength(1); + + expect(screen.getByText(messages.accessibilityPolicyFormSuccess.defaultMessage)).toBeVisible(); + + formSections.forEach(input => { + expect(input.value).toBe(''); + }); + }); + + it('shows correct rate limiting message', async () => { + axiosMock.onPost(getZendeskrUrl()).reply(429); + await act(async () => { + userEvent.click(submitButton); + }); + const { savingStatus } = store.getState().accessibilityPage; + expect(savingStatus).toEqual(RequestStatus.FAILED); + + expect(screen.getAllByRole('alert')).toHaveLength(1); + + expect(screen.getByTestId('rate-limit-alert')).toBeVisible(); + + formSections.forEach(input => { + expect(input.value).not.toBe(''); + }); + }); + }); + + describe('input validation', () => { + let formSections; + let submitButton; + beforeEach(async () => { + renderComponent(); + formSections = screen.getAllByRole('textbox'); + await act(async () => { + userEvent.type(formSections[0], 'email@email.com'); + userEvent.type(formSections[1], 'test name'); + userEvent.type(formSections[2], 'feedback message'); + }); + submitButton = screen.getByText(messages.accessibilityPolicyFormSubmitLabel.defaultMessage); + }); + + it('adds validation checking on each input field', async () => { + await act(async () => { + userEvent.clear(formSections[0]); + userEvent.clear(formSections[1]); + userEvent.clear(formSections[2]); + }); + const emailError = screen.getByTestId('error-feedback-email'); + expect(emailError).toBeVisible(); + + const fullNameError = screen.getByTestId('error-feedback-email'); + expect(fullNameError).toBeVisible(); + + const messageError = screen.getByTestId('error-feedback-message'); + expect(messageError).toBeVisible(); + }); + + it('sumbit button is disabled when trying to submit with all empty fields', async () => { + await act(async () => { + userEvent.clear(formSections[0]); + userEvent.clear(formSections[1]); + userEvent.clear(formSections[2]); + userEvent.click(submitButton); + }); + + expect(submitButton.closest('button')).toBeDisabled(); + }); + }); +}); diff --git a/src/accessibility-page/AccessibilityForm/hooks.js b/src/accessibility-page/AccessibilityForm/hooks.js new file mode 100644 index 000000000..c96e21181 --- /dev/null +++ b/src/accessibility-page/AccessibilityForm/hooks.js @@ -0,0 +1,58 @@ +import { useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useFormik } from 'formik'; +import * as Yup from 'yup'; + +import { RequestStatus } from '../../data/constants'; +import messages from './messages'; + +const useAccessibility = (initialValues, intl) => { + const dispatch = useDispatch(); + const savingStatus = useSelector(state => state.accessibilityPage.savingStatus); + const [isFormFilled, setFormFilled] = useState(false); + const validationSchema = Yup.object().shape({ + name: Yup.string().required( + intl.formatMessage(messages.accessibilityPolicyFormValidName), + ), + email: Yup.string() + .email(intl.formatMessage(messages.accessibilityPolicyFormValidEmail)) + .required(intl.formatMessage(messages.accessibilityPolicyFormValidEmail)), + message: Yup.string().required( + intl.formatMessage(messages.accessibilityPolicyFormValidMessage), + ), + }); + + const { + values, errors, touched, handleChange, handleBlur, handleReset, + } = useFormik({ + initialValues, + enableReinitialize: true, + validateOnBlur: false, + validationSchema, + }); + + useEffect(() => { + setFormFilled(Object.values(values).every((i) => i)); + }, [values]); + + useEffect(() => { + if (savingStatus === RequestStatus.SUCCESSFUL) { + handleReset(); + } + }, [savingStatus]); + + const hasErrorField = (fieldName) => !!errors[fieldName] && !!touched[fieldName]; + + return { + errors, + values, + isFormFilled, + dispatch, + handleBlur, + handleChange, + hasErrorField, + savingStatus, + }; +}; + +export default useAccessibility; diff --git a/src/accessibility-page/AccessibilityForm/index.js b/src/accessibility-page/AccessibilityForm/index.js new file mode 100644 index 000000000..a925bed87 --- /dev/null +++ b/src/accessibility-page/AccessibilityForm/index.js @@ -0,0 +1,3 @@ +import AccessibilityForm from './AccessibilityForm'; + +export default AccessibilityForm; diff --git a/src/accessibility-page/AccessibilityForm/messages.js b/src/accessibility-page/AccessibilityForm/messages.js new file mode 100644 index 000000000..8f8513dc7 --- /dev/null +++ b/src/accessibility-page/AccessibilityForm/messages.js @@ -0,0 +1,76 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + accessibilityPolicyFormEmailLabel: { + id: 'accessibilityPolicyFormEmailLabel', + defaultMessage: 'Email Address', + description: 'Label for the email form field', + }, + accessibilityPolicyFormErrorHighVolume: { + id: 'accessibilityPolicyFormErrorHighVolume', + defaultMessage: 'We are currently experiencing high volume. Try again later today or send an email message to {emailLink}.', + description: 'Error message when site is experiencing high volume that will include an email link', + }, + accessibilityPolicyFormErrorMissingFields: { + id: 'accessibilityPolicyFormErrorMissingFields', + defaultMessage: 'Make sure to fill in all fields.', + description: 'Error message to instruct user to fill in all fields', + }, + accessibilityPolicyFormHeader: { + id: 'accessibilityPolicyFormHeader', + defaultMessage: 'Studio Accessibility Feedback', + description: 'The heading for the form', + }, + accessibilityPolicyFormMessageLabel: { + id: 'accessibilityPolicyFormMessageLabel', + defaultMessage: 'Message', + description: 'Label for the message form field', + }, + accessibilityPolicyFormNameLabel: { + id: 'accessibilityPolicyFormNameLabel', + defaultMessage: 'Name', + description: 'Label for the name form field', + }, + accessibilityPolicyFormSubmitAria: { + id: 'accessibilityPolicyFormSubmitAria', + defaultMessage: 'Submit Accessibility Feedback Form', + description: 'Detailed aria-label for the submit button', + }, + accessibilityPolicyFormSubmitLabel: { + id: 'accessibilityPolicyFormSubmitLabel', + defaultMessage: 'Submit', + description: 'General label for the submit button', + }, + accessibilityPolicyFormSubmittingFeedbackLabel: { + id: 'accessibilityPolicyFormSubmittingFeedbackLabel', + defaultMessage: 'Submitting', + description: 'Loading message while form feedback is being submitted', + }, + accessibilityPolicyFormSuccess: { + id: 'accessibilityPolicyFormSuccess', + defaultMessage: 'Thank you for contacting edX!', + description: 'Simple thank you message when form submission is successful', + }, + accessibilityPolicyFormSuccessDetails: { + id: 'accessibilityPolicyFormSuccessDetails', + defaultMessage: 'Thank you for your feedback regarding the accessibility of Studio. We typically respond within one business day ({day_start} to {day_end}, {time_start} to {time_end}).', + description: 'Detailed thank you message when form submission is successful', + }, + accessibilityPolicyFormValidEmail: { + id: 'accessibilityPolicyFormValidEmail', + defaultMessage: 'Enter a valid email address.', + description: 'Error message for when an invalid email is entered into the form', + }, + accessibilityPolicyFormValidMessage: { + id: 'accessibilityPolicyFormValidMessage', + defaultMessage: 'Enter a message.', + description: 'Error message an invalid message is entered into the form', + }, + accessibilityPolicyFormValidName: { + id: 'accessibilityPolicyFormValidName', + defaultMessage: 'Enter a name.', + description: 'Error message an invalid name is entered into the form', + }, +}); + +export default messages; diff --git a/src/accessibility-page/AccessibilityPage.jsx b/src/accessibility-page/AccessibilityPage.jsx new file mode 100644 index 000000000..d3dd1c99a --- /dev/null +++ b/src/accessibility-page/AccessibilityPage.jsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { Helmet } from 'react-helmet'; +import { Container } from '@openedx/paragon'; +import { StudioFooter } from '@edx/frontend-component-footer'; + +import Header from '../header'; +import messages from './messages'; +import AccessibilityBody from './AccessibilityBody'; +import AccessibilityForm from './AccessibilityForm'; + +const AccessibilityPage = ({ + // injected + intl, +}) => { + const communityAccessibilityLink = 'https://www.edx.org/accessibility'; + const email = 'accessibility@edx.org'; + return ( + <> + + + {intl.formatMessage(messages.pageTitle, { + siteName: process.env.SITE_NAME, + })} + + +
+ + + + + + + ); +}; + +AccessibilityPage.propTypes = { + // injected + intl: intlShape.isRequired, +}; + +export default injectIntl(AccessibilityPage); diff --git a/src/accessibility-page/AccessibilityPage.test.jsx b/src/accessibility-page/AccessibilityPage.test.jsx new file mode 100644 index 000000000..f686daf4d --- /dev/null +++ b/src/accessibility-page/AccessibilityPage.test.jsx @@ -0,0 +1,46 @@ +import { + render, + screen, +} from '@testing-library/react'; +import { AppProvider } from '@edx/frontend-platform/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { initializeMockApp } from '@edx/frontend-platform'; +import initializeStore from '../store'; +import AccessibilityPage from './index'; + +const initialState = { + accessibilityPage: { + status: {}, + }, +}; +let store; + +const renderComponent = () => { + render( + + + + + , + ); +}; + +describe('', () => { + describe('renders', () => { + beforeEach(async () => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: false, + roles: [], + }, + }); + store = initializeStore(initialState); + }); + it('contains the policy body', () => { + renderComponent(); + expect(screen.getByText('Individualized Accessibility Process for Course Creators')).toBeVisible(); + }); + }); +}); diff --git a/src/accessibility-page/data/api.js b/src/accessibility-page/data/api.js new file mode 100644 index 000000000..7381384b0 --- /dev/null +++ b/src/accessibility-page/data/api.js @@ -0,0 +1,28 @@ +import { ensureConfig, getConfig } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; + +ensureConfig([ + 'STUDIO_BASE_URL', +], 'Course Apps API service'); + +export const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL; +export const getZendeskrUrl = () => `${getApiBaseUrl()}/zendesk_proxy/v0`; + +/** + * Posts the form data to zendesk endpoint + * @param {string} courseId + * @returns {Promise<[{}]>} + */ +export async function postAccessibilityForm({ name, email, message }) { + const data = { + name, + tags: ['studio_a11y'], + email: { + from: email, + subject: 'Studio Accessibility Request', + message, + }, + }; + + await getAuthenticatedHttpClient().post(getZendeskrUrl(), data); +} diff --git a/src/accessibility-page/data/slice.js b/src/accessibility-page/data/slice.js new file mode 100644 index 000000000..7d90356f1 --- /dev/null +++ b/src/accessibility-page/data/slice.js @@ -0,0 +1,23 @@ +/* eslint-disable no-param-reassign */ +import { createSlice } from '@reduxjs/toolkit'; + +const slice = createSlice({ + name: 'accessibilityPage', + initialState: { + savingStatus: '', + }, + reducers: { + updateSavingStatus: (state, { payload }) => { + state.savingStatus = payload.status; + }, + }, +}); + +export const { + updateLoadingStatus, + updateSavingStatus, +} = slice.actions; + +export const { + reducer, +} = slice; diff --git a/src/accessibility-page/data/thunks.js b/src/accessibility-page/data/thunks.js new file mode 100644 index 000000000..b6b2d121a --- /dev/null +++ b/src/accessibility-page/data/thunks.js @@ -0,0 +1,22 @@ +import { RequestStatus } from '../../data/constants'; +import { postAccessibilityForm } from './api'; +import { updateSavingStatus } from './slice'; + +function submitAccessibilityForm({ email, name, message }) { + return async (dispatch) => { + dispatch(updateSavingStatus({ status: RequestStatus.IN_PROGRESS })); + + try { + await postAccessibilityForm({ email, name, message }); + dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); + } catch (error) { + if (error.response && error.response.status === 429) { + dispatch(updateSavingStatus({ status: RequestStatus.FAILED })); + } else { + dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); + } + } + }; +} + +export default submitAccessibilityForm; diff --git a/src/accessibility-page/index.js b/src/accessibility-page/index.js new file mode 100644 index 000000000..089d0cbbf --- /dev/null +++ b/src/accessibility-page/index.js @@ -0,0 +1,3 @@ +import AccessibilityPage from './AccessibilityPage'; + +export default AccessibilityPage; diff --git a/src/accessibility-page/messages.js b/src/accessibility-page/messages.js new file mode 100644 index 000000000..6b97fb96e --- /dev/null +++ b/src/accessibility-page/messages.js @@ -0,0 +1,10 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + pageTitle: { + id: 'course-authoring.import.page.title', + defaultMessage: 'Studio Accessibility Policy| {siteName}', + }, +}); + +export default messages; diff --git a/src/constants.js b/src/constants.js index eb1b17b37..2913884a9 100644 --- a/src/constants.js +++ b/src/constants.js @@ -26,6 +26,10 @@ export const NOTIFICATION_MESSAGES = { deleting: 'Deleting', copying: 'Copying', pasting: 'Pasting', + discardChanges: 'Discarding changes', + publishing: 'Publishing', + hidingFromStudents: 'Hiding from students', + makingVisibleToStudents: 'Making visible to students', empty: '', }; diff --git a/src/content-tags-drawer/ContentTagsCollapsible.d.ts b/src/content-tags-drawer/ContentTagsCollapsible.d.ts new file mode 100644 index 000000000..55759439e --- /dev/null +++ b/src/content-tags-drawer/ContentTagsCollapsible.d.ts @@ -0,0 +1,39 @@ +import type {} from 'react-select/base'; +// This import is necessary for module augmentation. +// It allows us to extend the 'Props' interface in the 'react-select/base' module +// and add our custom property 'myCustomProp' to it. + +export interface TagTreeEntry { + explicit: boolean; + children: Record; + canChangeObjecttag: boolean; + canDeleteObjecttag: boolean; +} + +export interface TaxonomySelectProps { + taxonomyId: number; + searchTerm: string; + appliedContentTagsTree: Record; + stagedContentTagsTree: Record; + checkedTags: string[]; + handleCommitStagedTags: () => void; + handleCancelStagedTags: () => void; + handleSelectableBoxChange: React.ChangeEventHandler; +} + +// Unfortunately the only way to specify the custom props we pass into React Select +// is with this global type augmentation. +// https://react-select.com/typescript#custom-select-props +// If in the future other parts of this MFE need to use React Select for different things, +// we should change to using a 'react context' to share this data within , +// rather than using the custom )} - -
- - - {}} - onChange={handleSearchChange} - className="mb-2" - /> - - - -
-
-
{ ); }; -ContentTagsCollapsible.propTypes = { - contentId: PropTypes.string.isRequired, - taxonomyAndTagsData: PropTypes.shape({ - id: PropTypes.number, - name: PropTypes.string, - contentTags: PropTypes.arrayOf(PropTypes.shape({ - value: PropTypes.string, - lineage: PropTypes.arrayOf(PropTypes.string), - })), - canTagObject: PropTypes.bool.isRequired, - }).isRequired, -}; - export default ContentTagsCollapsible; diff --git a/src/content-tags-drawer/ContentTagsCollapsible.scss b/src/content-tags-drawer/ContentTagsCollapsible.scss index 3123eebbf..67a51a77e 100644 --- a/src/content-tags-drawer/ContentTagsCollapsible.scss +++ b/src/content-tags-drawer/ContentTagsCollapsible.scss @@ -27,3 +27,33 @@ .pgn__modal-popup__arrow { visibility: hidden; } + +.add-tags-button:not([disabled]):hover { + background-color: transparent; + color: $info-900 !important; +} + +.cancel-add-tags-button:hover { + background-color: transparent; + color: $gray-300 !important; +} + +.react-select-add-tags__control { + border-radius: 0 !important; +} + +.react-select-add-tags__control--is-focused { + border-color: black !important; + box-shadow: 0 0 0 1px black !important; +} + +.react-select-add-tags__multi-value__remove { + padding-right: 7px !important; + padding-left: 7px !important; + border-radius: 0 3px 3px 0; + + &:hover { + background-color: black !important; + color: white !important; + } +} diff --git a/src/content-tags-drawer/ContentTagsCollapsible.test.jsx b/src/content-tags-drawer/ContentTagsCollapsible.test.jsx index 772087c18..1b0ed8602 100644 --- a/src/content-tags-drawer/ContentTagsCollapsible.test.jsx +++ b/src/content-tags-drawer/ContentTagsCollapsible.test.jsx @@ -51,11 +51,29 @@ const data = { }, ], }, + stagedContentTags: [], + addStagedContentTag: jest.fn(), + removeStagedContentTag: jest.fn(), + setStagedTags: jest.fn(), }; -const ContentTagsCollapsibleComponent = ({ contentId, taxonomyAndTagsData }) => ( +const ContentTagsCollapsibleComponent = ({ + contentId, + taxonomyAndTagsData, + stagedContentTags, + addStagedContentTag, + removeStagedContentTag, + setStagedTags, +}) => ( - + ); @@ -70,6 +88,10 @@ describe('', () => { jest.useRealTimers(); // Restore real timers after the tests }); + afterEach(() => { + jest.clearAllMocks(); // Reset all mock function call counts after each test case + }); + async function getComponent(updatedData) { const componentData = (!updatedData ? data : updatedData); @@ -77,6 +99,10 @@ describe('', () => { , ); } @@ -130,59 +156,157 @@ describe('', () => { expect(getByText('3')).toBeInTheDocument(); }); - it('should render new tags as they are checked in the dropdown', async () => { + it('should call `addStagedContentTag` when tag checked in the dropdown', async () => { setupTaxonomyMock(); const { container, getByText, getAllByText } = await getComponent(); - // Expand the Taxonomy to view applied tags and "Add tags" button - const expandToggle = container.getElementsByClassName('collapsible-trigger')[0]; - fireEvent.click(expandToggle); - - // Click on "Add tags" button to open dropdown to select new tags - const addTagsButton = getByText(messages.addTagsButtonText.defaultMessage); - fireEvent.click(addTagsButton); - - // Wait for the dropdown selector for tags to open, - // Tag 3 should only appear there - expect(getByText('Tag 3')).toBeInTheDocument(); - expect(getAllByText('Tag 3').length === 1); - - const tag3 = getByText('Tag 3'); - - fireEvent.click(tag3); - - // After clicking on Tag 3, it should also appear in amongst - // the tag bubbles in the tree - expect(getAllByText('Tag 3').length === 2); - }); - - it('should remove tag when they are unchecked in the dropdown', async () => { - setupTaxonomyMock(); - const { container, getByText, getAllByText } = await getComponent(); - - // Expand the Taxonomy to view applied tags and "Add tags" button + // Expand the Taxonomy to view applied tags and "Add a tag" button const expandToggle = container.getElementsByClassName('collapsible-trigger')[0]; fireEvent.click(expandToggle); - // Check that Tag 2 appears in tag bubbles - expect(getByText('Tag 2')).toBeInTheDocument(); - - // Click on "Add tags" button to open dropdown to select new tags - const addTagsButton = getByText(messages.addTagsButtonText.defaultMessage); - fireEvent.click(addTagsButton); + // Click on "Add a tag" button to open dropdown to select new tags + const addTagsButton = getByText(messages.collapsibleAddTagsPlaceholderText.defaultMessage); + // Use `mouseDown/mouseUp` instead of `click` since the react-select didn't respond to `click` + fireEvent.mouseDown(addTagsButton); + fireEvent.mouseUp(addTagsButton); // Wait for the dropdown selector for tags to open, // Tag 3 should only appear there, (i.e. the dropdown is open, since Tag 3 is not applied) - expect(getByText('Tag 3')).toBeInTheDocument(); + expect(getAllByText('Tag 3').length).toBe(1); - // Get the Tag 2 checkbox and click on it - const tag2 = getAllByText('Tag 2')[1]; - fireEvent.click(tag2); + // Click to check Tag 3 and check the `addStagedContentTag` was called with the correct params + const tag3 = getByText('Tag 3'); + fireEvent.click(tag3); - // After clicking on Tag 2, it should be removed from - // the tag bubbles in so only the one in the dropdown appears - expect(getAllByText('Tag 2').length === 1); + const taxonomyId = 123; + const addedStagedTag = { + value: 'Tag%203', + label: 'Tag 3', + }; + expect(data.addStagedContentTag).toHaveBeenCalledTimes(1); + expect(data.addStagedContentTag).toHaveBeenCalledWith(taxonomyId, addedStagedTag); + }); + + it('should call `removeStagedContentTag` when tag staged tag unchecked in the dropdown', async () => { + setupTaxonomyMock(); + const { container, getByText, getAllByText } = await getComponent(); + + // Expand the Taxonomy to view applied tags and "Add a tag" button + const expandToggle = container.getElementsByClassName('collapsible-trigger')[0]; + + fireEvent.click(expandToggle); + + // Click on "Add a tag" button to open dropdown to select new tags + const addTagsButton = getByText(messages.collapsibleAddTagsPlaceholderText.defaultMessage); + // Use `mouseDown/mouseup` instead of `click` since the react-select didn't respond to `click` + fireEvent.mouseDown(addTagsButton); + fireEvent.mouseUp(addTagsButton); + + // Wait for the dropdown selector for tags to open, + // Tag 3 should only appear there, (i.e. the dropdown is open, since Tag 3 is not applied) + expect(getAllByText('Tag 3').length).toBe(1); + + // Click to check Tag 3 + const tag3 = getByText('Tag 3'); + fireEvent.click(tag3); + + // Click to uncheck Tag 3 and check the `removeStagedContentTag` was called with the correct params + fireEvent.click(tag3); + const taxonomyId = 123; + const tagValue = 'Tag%203'; + expect(data.removeStagedContentTag).toHaveBeenCalledTimes(1); + expect(data.removeStagedContentTag).toHaveBeenCalledWith(taxonomyId, tagValue); + }); + + it('should call `setStagedTags` to clear staged tags when clicking inline "Add" button', async () => { + setupTaxonomyMock(); + // Setup component to have staged tags + const { container, getByText } = await getComponent({ + ...data, + stagedContentTags: [{ + value: 'Tag%203', + label: 'Tag 3', + }], + }); + + // Expand the Taxonomy to view applied tags and staged tags + const expandToggle = container.getElementsByClassName('collapsible-trigger')[0]; + + fireEvent.click(expandToggle); + + // Click on inline "Add" button and check that the appropriate methods are called + const inlineAdd = getByText(messages.collapsibleInlineAddStagedTagsButtonText.defaultMessage); + fireEvent.click(inlineAdd); + + // Check that `setStagedTags` called with empty tags list to clear staged tags + const taxonomyId = 123; + expect(data.setStagedTags).toHaveBeenCalledTimes(1); + expect(data.setStagedTags).toHaveBeenCalledWith(taxonomyId, []); + }); + + it('should call `setStagedTags` to clear staged tags when clicking "Add tags" button in dropdown', async () => { + setupTaxonomyMock(); + // Setup component to have staged tags + const { container, getByText } = await getComponent({ + ...data, + stagedContentTags: [{ + value: 'Tag%203', + label: 'Tag 3', + }], + }); + + // Expand the Taxonomy to view applied tags and staged tags + const expandToggle = container.getElementsByClassName('collapsible-trigger')[0]; + + fireEvent.click(expandToggle); + + // Click on dropdown with staged tags to expand it + const selectTagsDropdown = container.getElementsByClassName('react-select-add-tags__control')[0]; + // Use `mouseDown` instead of `click` since the react-select didn't respond to `click` + fireEvent.mouseDown(selectTagsDropdown); + + // Click on "Add tags" button and check that the appropriate methods are called + const dropdownAdd = getByText(messages.collapsibleAddStagedTagsButtonText.defaultMessage); + fireEvent.click(dropdownAdd); + + // Check that `setStagedTags` called with empty tags list to clear staged tags + const taxonomyId = 123; + expect(data.setStagedTags).toHaveBeenCalledTimes(1); + expect(data.setStagedTags).toHaveBeenCalledWith(taxonomyId, []); + }); + + it('should close dropdown and clear staged tags when clicking "Cancel" inside dropdown', async () => { + // Setup component to have staged tags + const { container, getByText } = await getComponent({ + ...data, + stagedContentTags: [{ + value: 'Tag%203', + label: 'Tag 3', + }], + }); + + // Expand the Taxonomy to view applied tags and staged tags + const expandToggle = container.getElementsByClassName('collapsible-trigger')[0]; + + fireEvent.click(expandToggle); + + // Click on dropdown with staged tags to expand it + const selectTagsDropdown = container.getElementsByClassName('react-select-add-tags__control')[0]; + // Use `mouseDown` instead of `click` since the react-select didn't respond to `click` + fireEvent.mouseDown(selectTagsDropdown); + + // Click on inline "Add" button and check that the appropriate methods are called + const dropdownCancel = getByText(messages.collapsibleCancelStagedTagsButtonText.defaultMessage); + fireEvent.click(dropdownCancel); + + // Check that `setStagedTags` called with empty tags list to clear staged tags + const taxonomyId = 123; + expect(data.setStagedTags).toHaveBeenCalledTimes(1); + expect(data.setStagedTags).toHaveBeenCalledWith(taxonomyId, []); + + // Check that the dropdown is closed + expect(dropdownCancel).not.toBeInTheDocument(); }); it('should handle search term change', async () => { @@ -190,16 +314,17 @@ describe('', () => { container, getByText, getByRole, getByDisplayValue, } = await getComponent(); - // Expand the Taxonomy to view applied tags and "Add tags" button + // Expand the Taxonomy to view applied tags and "Add a tag" button const expandToggle = container.getElementsByClassName('collapsible-trigger')[0]; fireEvent.click(expandToggle); - // Click on "Add tags" button to open dropdown - const addTagsButton = getByText(messages.addTagsButtonText.defaultMessage); - fireEvent.click(addTagsButton); + // Click on "Add a tag" button to open dropdown + const addTagsButton = getByText(messages.collapsibleAddTagsPlaceholderText.defaultMessage); + // Use `mouseDown` instead of `click` since the react-select didn't respond to click + fireEvent.mouseDown(addTagsButton); // Get the search field - const searchField = getByRole('searchbox'); + const searchField = getByRole('combobox'); const searchTerm = 'memo'; @@ -226,14 +351,15 @@ describe('', () => { setupTaxonomyMock(); const { container, getByText, queryByText } = await getComponent(); - // Expand the Taxonomy to view applied tags and "Add tags" button + // Expand the Taxonomy to view applied tags and "Add a tag" button const expandToggle = container.getElementsByClassName('collapsible-trigger')[0]; fireEvent.click(expandToggle); - // Click on "Add tags" button to open dropdown - const addTagsButton = getByText(messages.addTagsButtonText.defaultMessage); - fireEvent.click(addTagsButton); + // Click on "Add a tag" button to open dropdown + const addTagsButton = getByText(messages.collapsibleAddTagsPlaceholderText.defaultMessage); + // Use `mouseDown` instead of `click` since the react-select didn't respond to `click` + fireEvent.mouseDown(addTagsButton); // Wait for the dropdown selector for tags to open, Tag 3 should appear // since it is not applied @@ -250,6 +376,24 @@ describe('', () => { expect(queryByText('Tag 3')).not.toBeInTheDocument(); }); + it('should remove applied tags when clicking on `x` of tag bubble', async () => { + setupTaxonomyMock(); + const { container, getByText } = await getComponent(); + + // Expand the Taxonomy to view applied tags + const expandToggle = container.getElementsByClassName('collapsible-trigger')[0]; + + fireEvent.click(expandToggle); + + // Click on 'x' of applied tag to remove it + const appliedTag = getByText('Tag 2'); + const xButtonAppliedTag = appliedTag.nextSibling; + xButtonAppliedTag.click(); + + // Check that the applied tag has been removed + expect(appliedTag).not.toBeInTheDocument(); + }); + it('should render taxonomy tags data without tags number badge', async () => { const updatedData = { ...data }; updatedData.taxonomyAndTagsData = { ...updatedData.taxonomyAndTagsData }; diff --git a/src/content-tags-drawer/ContentTagsCollapsibleHelper.jsx b/src/content-tags-drawer/ContentTagsCollapsibleHelper.jsx index aed895c45..6ded9481d 100644 --- a/src/content-tags-drawer/ContentTagsCollapsibleHelper.jsx +++ b/src/content-tags-drawer/ContentTagsCollapsibleHelper.jsx @@ -5,80 +5,85 @@ import { cloneDeep } from 'lodash'; import { useContentTaxonomyTagsUpdater } from './data/apiHooks'; +/** @typedef {import("../taxonomy/data/types.mjs").TaxonomyData} TaxonomyData */ +/** @typedef {import("./data/types.mjs").Tag} ContentTagData */ +/** @typedef {import("./ContentTagsCollapsible").TagTreeEntry} TagTreeEntry */ + /** - * Util function that consolidates two tag trees into one, sorting the keys in - * alphabetical order. + * Util function that sorts the keys of a tree in alphabetical order. * - * @param {object} tree1 - first tag tree - * @param {object} tree2 - second tag tree - * @returns {object} merged tree containing both tree1 and tree2 + * @param {object} tree - tree that needs it's keys sorted + * @returns {object} sorted tree */ -const mergeTrees = (tree1, tree2) => { - const mergedTree = cloneDeep(tree1); - - const sortKeysAlphabetically = (obj) => { - const sortedObj = {}; - Object.keys(obj) - .sort() - .forEach((key) => { - sortedObj[key] = obj[key]; - if (obj[key] && typeof obj[key] === 'object') { - sortedObj[key].children = sortKeysAlphabetically(obj[key].children); - } - }); - return sortedObj; - }; - - const mergeRecursively = (destination, source) => { - Object.entries(source).forEach(([key, sourceValue]) => { - const destinationValue = destination[key]; - - if (destinationValue && sourceValue && typeof destinationValue === 'object' && typeof sourceValue === 'object') { - mergeRecursively(destinationValue, sourceValue); - } else { - // eslint-disable-next-line no-param-reassign - destination[key] = cloneDeep(sourceValue); +const sortKeysAlphabetically = (tree) => { + const sortedObj = {}; + Object.keys(tree) + .sort() + .forEach((key) => { + sortedObj[key] = tree[key]; + if (tree[key] && typeof tree[key] === 'object') { + sortedObj[key].children = sortKeysAlphabetically(tree[key].children); } }); - }; - - mergeRecursively(mergedTree, tree2); - return sortKeysAlphabetically(mergedTree); + return sortedObj; }; /** - * Util function that removes the tag along with its ancestors if it was - * the only explicit child tag. + * Util function that returns the leafs of a tree. Mainly used to extract the explicit + * tags selected in the staged tags tree * - * @param {object} tree - tag tree to remove the tag from - * @param {string[]} tagsToRemove - full lineage of tag to remove. - * eg: ['grand parent', 'parent', 'tag'] + * @param {object} tree - tree to extract the leaf tags from + * @returns {Array} array of leaf (explicit) tags of provided tree */ -const removeTags = (tree, tagsToRemove) => { - if (!tree || !tagsToRemove.length) { - return; - } - const key = tagsToRemove[0]; - if (tree[key]) { - removeTags(tree[key].children, tagsToRemove.slice(1)); +const getLeafTags = (tree) => { + const leafKeys = []; - if (Object.keys(tree[key].children).length === 0 && (tree[key].explicit === false || tagsToRemove.length === 1)) { - // eslint-disable-next-line no-param-reassign - delete tree[key]; - } + function traverse(node) { + Object.keys(node).forEach(key => { + const child = node[key]; + if (Object.keys(child.children).length === 0) { + leafKeys.push(key); + } else { + traverse(child.children); + } + }); } + + traverse(tree); + return leafKeys; }; -/* +/** * Handles all the underlying logic for the ContentTagsCollapsible component + * @param {string} contentId The ID of the content we're tagging (e.g. usage key) + * @param {TaxonomyData & {contentTags: ContentTagData[]}} taxonomyAndTagsData + * @param {(taxonomyId: number, tag: {value: string, label: string}) => void} addStagedContentTag + * @param {(taxonomyId: number, tagValue: string) => void} removeStagedContentTag + * @param {{value: string, label: string}[]} stagedContentTags + * @returns {{ + * tagChangeHandler: (tagSelectableBoxValue: string, checked: boolean) => void, + * removeAppliedTagHandler: (tagSelectableBoxValue: string) => void, + * appliedContentTagsTree: Record, + * stagedContentTagsTree: Record, + * contentTagsCount: number, + * checkedTags: any, + * commitStagedTags: () => void, + * updateTags: import('@tanstack/react-query').UseMutationResult + * }} */ -const useContentTagsCollapsibleHelper = (contentId, taxonomyAndTagsData) => { +const useContentTagsCollapsibleHelper = ( + contentId, + taxonomyAndTagsData, + addStagedContentTag, + removeStagedContentTag, + stagedContentTags, +) => { const { id, contentTags, canTagObject, } = taxonomyAndTagsData; - // State to determine whether the tags are being updating so we can make a call + // State to determine whether an applied tag was removed so we make a call // to the update endpoint to the reflect those changes - const [updatingTags, setUpdatingTags] = React.useState(false); + const [removingAppliedTag, setRemoveAppliedTag] = React.useState(false); const updateTags = useContentTaxonomyTagsUpdater(contentId, id); // Keeps track of the content objects tags count (both implicit and explicit) @@ -86,32 +91,55 @@ const useContentTagsCollapsibleHelper = (contentId, taxonomyAndTagsData) => { // Keeps track of the tree structure for tags that are add by selecting/unselecting // tags in the dropdowns. - const [addedContentTags, setAddedContentTags] = React.useState({}); + const [stagedContentTagsTree, setStagedContentTagsTree] = React.useState({}); // To handle checking/unchecking tags in the SelectableBox - const [checkedTags, { add, remove, clear }] = useCheckboxSetValues(); + const [checkedTags, { add, remove }] = useCheckboxSetValues(); - // Handles making requests to the update endpoint whenever the checked tags change + // State to keep track of the staged tags (and along with ancestors) that should be removed + const [stagedTagsToRemove, setStagedTagsToRemove] = React.useState(/** @type string[] */([])); + + // Handles making requests to the backend when applied tags are removed React.useEffect(() => { // We have this check because this hook is fired when the component first loads // and reloads (on refocus). We only want to make a request to the update endpoint when - // the user is updating the tags. - if (updatingTags) { - setUpdatingTags(false); + // the user removes an applied tag + if (removingAppliedTag) { + setRemoveAppliedTag(false); + + // Filter out staged tags from the checktags so they do not get committed const tags = checkedTags.map(t => decodeURIComponent(t.split(',').slice(-1))); - updateTags.mutate({ tags }); + const staged = stagedContentTags.map(t => t.label); + const remainingAppliedTags = tags.filter(t => !staged.includes(t)); + + updateTags.mutate({ tags: remainingAppliedTags }); } - }, [contentId, id, canTagObject, checkedTags]); + }, [contentId, id, canTagObject, checkedTags, stagedContentTags]); + + // Handles the removal of staged content tags based on what was removed + // from the staged tags tree. We are doing it in a useEffect since the removeTag + // method is being called inside a setState of the parent component, which + // was causing warnings + React.useEffect(() => { + stagedTagsToRemove.forEach(tag => removeStagedContentTag(id, tag)); + }, [stagedTagsToRemove, removeStagedContentTag, id]); + + // Handles making requests to the update endpoint when the staged tags need to be committed + const commitStagedTags = React.useCallback(() => { + // Filter out only leaf nodes of staging tree to commit + const explicitStaged = getLeafTags(stagedContentTagsTree); + + // Filter out applied tags that should become implicit because a child tag was committed + const stagedLineages = stagedContentTags.map(st => decodeURIComponent(st.value).split(',').slice(0, -1)).flat(); + const applied = contentTags.map((t) => t.value).filter(t => !stagedLineages.includes(t)); + + updateTags.mutate({ tags: [...applied, ...explicitStaged] }); + }, [contentTags, stagedContentTags, stagedContentTagsTree, updateTags]); // This converts the contentTags prop to the tree structure mentioned above - const appliedContentTags = React.useMemo(() => { + const appliedContentTagsTree = React.useMemo(() => { let contentTagsCounter = 0; - // Clear all the tags that have not been commited and the checked boxes when - // fresh contentTags passed in so the latest state from the backend is rendered - setAddedContentTags({}); - clear(); - // When an error occurs while updating, the contentTags query is invalidated, // hence they will be recalculated, and the updateTags mutation should be reset. if (updateTags.isError) { @@ -134,8 +162,12 @@ const useContentTagsCollapsibleHelper = (contentId, taxonomyAndTagsData) => { // Populating the SelectableBox with "selected" (explicit) tags const value = item.lineage.map(l => encodeURIComponent(l)).join(','); - // eslint-disable-next-line no-unused-expressions - isExplicit ? add(value) : remove(value); + // Clear all the existing applied tags + remove(value); + // Add only the explicitly applied tags + if (isExplicit) { + add(value); + } contentTagsCounter += 1; } @@ -147,13 +179,53 @@ const useContentTagsCollapsibleHelper = (contentId, taxonomyAndTagsData) => { return resultTree; }, [contentTags, updateTags.isError]); - // This is the source of truth that represents the current state of tags in - // this Taxonomy as a tree. Whenever either the `appliedContentTags` (i.e. tags passed in - // the prop from the backed) change, or when the `addedContentTags` (i.e. tags added by - // selecting/unselecting them in the dropdown) change, the tree is recomputed. - const tagsTree = React.useMemo(() => ( - mergeTrees(appliedContentTags, addedContentTags) - ), [appliedContentTags, addedContentTags]); + /** + * Util function that removes the tag along with its ancestors if it was + * the only explicit child tag. It returns a list of staged tags (and ancestors) that + * were unstaged and should be removed + * + * @param {object} tree - tag tree to remove the tag from + * @param {string[]} tagsToRemove - remaining lineage of tag to remove at each recursive level. + * eg: ['grand parent', 'parent', 'tag'] + * @param {boolean} staged - whether we are removing staged tags or not + * @param {string[]} fullLineage - Full lineage of tag being removed + * @returns {string[]} array of staged tag values (with ancestors) that should be removed from staged tree + * + */ + const removeTags = React.useCallback((tree, tagsToRemove, staged, fullLineage) => { + const removedTags = []; + + const traverseAndRemoveTags = (subTree, innerTagsToRemove) => { + if (!subTree || !innerTagsToRemove.length) { + return; + } + const key = innerTagsToRemove[0]; + if (subTree[key]) { + traverseAndRemoveTags(subTree[key].children, innerTagsToRemove.slice(1)); + + if ( + Object.keys(subTree[key].children).length === 0 + && (subTree[key].explicit === false || innerTagsToRemove.length === 1) + ) { + // eslint-disable-next-line no-param-reassign + delete subTree[key]; + + // Remove tags (including ancestors) from staged tags select menu + if (staged) { + // Build value from lineage by traversing beginning till key, then encoding them + const toRemove = fullLineage.slice(0, fullLineage.indexOf(key) + 1).map(item => encodeURIComponent(item)); + if (toRemove.length > 0) { + removedTags.push(toRemove.join(',')); + } + } + } + } + }; + + traverseAndRemoveTags(tree, tagsToRemove); + + return removedTags; + }, []); // Add tag to the tree, and while traversing remove any selected ancestor tags // as they should become implicit @@ -163,6 +235,10 @@ const useContentTagsCollapsibleHelper = (contentId, taxonomyAndTagsData) => { tagLineage.forEach(tag => { const isExplicit = selectedTag === tag; + // Clear out the ancestor tags leading to newly selected tag + // as they automatically become implicit + value.push(encodeURIComponent(tag)); + if (!traversal[tag]) { traversal[tag] = { explicit: isExplicit, @@ -174,12 +250,8 @@ const useContentTagsCollapsibleHelper = (contentId, taxonomyAndTagsData) => { traversal[tag].explicit = isExplicit; } - // Clear out the ancestor tags leading to newly selected tag - // as they automatically become implicit - value.push(encodeURIComponent(tag)); // eslint-disable-next-line no-unused-expressions isExplicit ? add(value.join(',')) : remove(value.join(',')); - traversal = traversal[tag].children; }); }; @@ -188,26 +260,62 @@ const useContentTagsCollapsibleHelper = (contentId, taxonomyAndTagsData) => { const tagLineage = tagSelectableBoxValue.split(',').map(t => decodeURIComponent(t)); const selectedTag = tagLineage.slice(-1)[0]; - const addedTree = { ...addedContentTags }; if (checked) { + const stagedTree = cloneDeep(stagedContentTagsTree); // We "add" the tag to the SelectableBox.Set inside the addTags method - addTags(addedTree, tagLineage, selectedTag); + addTags(stagedTree, tagLineage, selectedTag); + + // Update the staged content tags tree + setStagedContentTagsTree(stagedTree); + + // Add content tag to taxonomy's staged tags select menu + addStagedContentTag( + id, + { + value: tagSelectableBoxValue, + label: selectedTag, + }, + ); } else { // Remove tag from the SelectableBox.Set remove(tagSelectableBoxValue); - // We remove them from both incase we are unselecting from an - // existing applied Tag or a newly added one - removeTags(addedTree, tagLineage); - removeTags(appliedContentTags, tagLineage); + // Remove tag along with it's from ancestors if it's the only child tag + // from the staged tags tree and update the staged content tags tree + setStagedContentTagsTree(prevStagedContentTagsTree => { + const updatedStagedContentTagsTree = cloneDeep(prevStagedContentTagsTree); + const tagsToRemove = removeTags(updatedStagedContentTagsTree, tagLineage, true, tagLineage); + setStagedTagsToRemove(tagsToRemove); + return updatedStagedContentTagsTree; + }); } + }, [ + stagedContentTagsTree, setStagedContentTagsTree, addTags, removeTags, + id, addStagedContentTag, removeStagedContentTag, + ]); - setAddedContentTags(addedTree); - setUpdatingTags(true); - }, []); + const removeAppliedTagHandler = React.useCallback((tagSelectableBoxValue) => { + const tagLineage = tagSelectableBoxValue.split(',').map(t => decodeURIComponent(t)); + + // Remove tag from the SelectableBox.Set + remove(tagSelectableBoxValue); + + // Remove tags from applied tags + const tagsToRemove = removeTags(appliedContentTagsTree, tagLineage, false, tagLineage); + setStagedTagsToRemove(tagsToRemove); + + setRemoveAppliedTag(true); + }, [appliedContentTagsTree, id, removeStagedContentTag]); return { - tagChangeHandler, tagsTree, contentTagsCount, checkedTags, + tagChangeHandler, + removeAppliedTagHandler, + appliedContentTagsTree: sortKeysAlphabetically(appliedContentTagsTree), + stagedContentTagsTree: sortKeysAlphabetically(stagedContentTagsTree), + contentTagsCount, + checkedTags, + commitStagedTags, + updateTags, }; }; diff --git a/src/content-tags-drawer/ContentTagsDrawer.jsx b/src/content-tags-drawer/ContentTagsDrawer.jsx index 9429a65f3..d0f987b12 100644 --- a/src/content-tags-drawer/ContentTagsDrawer.jsx +++ b/src/content-tags-drawer/ContentTagsDrawer.jsx @@ -1,5 +1,11 @@ // @ts-check -import React, { useMemo, useEffect } from 'react'; +import React, { + useMemo, + useEffect, + useState, + useCallback, +} from 'react'; +import PropTypes from 'prop-types'; import { Container, CloseButton, @@ -20,12 +26,54 @@ import Loading from '../generic/Loading'; /** @typedef {import("../taxonomy/data/types.mjs").TaxonomyData} TaxonomyData */ /** @typedef {import("./data/types.mjs").Tag} ContentTagData */ -const ContentTagsDrawer = () => { +/** + * Drawer with the functionality to show and manage tags in a certain content. + * It is used both in interfaces of this MFE and in edx-platform interfaces such as iframe. + * - If you want to use it as an iframe, the component obtains the `contentId` from the url parameters. + * Functions to close the drawer are handled internally. + * TODO: We can delete this method when is no longer used on edx-platform. + * - If you want to use it as react component, you need to pass the content id and the close functions + * through the component parameters. + */ +const ContentTagsDrawer = ({ id, onClose }) => { const intl = useIntl(); - const { contentId } = /** @type {{contentId: string}} */(useParams()); + // TODO: We can delete this when the iframe is no longer used on edx-platform + const params = useParams(); + let contentId = id; + + if (contentId === undefined) { + // TODO: We can delete this when the iframe is no longer used on edx-platform + contentId = params.contentId; + } const org = extractOrgFromContentId(contentId); + const [stagedContentTags, setStagedContentTags] = useState({}); + + // Add a content tags to the staged tags for a taxonomy + const addStagedContentTag = useCallback((taxonomyId, addedTag) => { + setStagedContentTags(prevStagedContentTags => { + const updatedStagedContentTags = { + ...prevStagedContentTags, + [taxonomyId]: [...(prevStagedContentTags[taxonomyId] ?? []), addedTag], + }; + return updatedStagedContentTags; + }); + }, [setStagedContentTags]); + + // Remove a content tag from the staged tags for a taxonomy + const removeStagedContentTag = useCallback((taxonomyId, tagValue) => { + setStagedContentTags(prevStagedContentTags => ({ + ...prevStagedContentTags, + [taxonomyId]: prevStagedContentTags[taxonomyId].filter((t) => t.value !== tagValue), + })); + }, [setStagedContentTags]); + + // Sets the staged content tags for taxonomy to the provided list of tags + const setStagedTags = useCallback((taxonomyId, tagsList) => { + setStagedContentTags(prevStagedContentTags => ({ ...prevStagedContentTags, [taxonomyId]: tagsList })); + }, [setStagedContentTags]); + const useTaxonomyListData = () => { const taxonomyListData = useTaxonomyListDataResponse(org); const isTaxonomyListLoaded = useIsTaxonomyListDataLoaded(org); @@ -39,17 +87,20 @@ const ContentTagsDrawer = () => { } = useContentTaxonomyTagsData(contentId); const { taxonomyListData, isTaxonomyListLoaded } = useTaxonomyListData(); - const closeContentTagsDrawer = () => { - // "*" allows communication with any origin - window.parent.postMessage('closeManageTagsDrawer', '*'); - }; + let onCloseDrawer = onClose; + if (onCloseDrawer === undefined) { + onCloseDrawer = () => { + // "*" allows communication with any origin + window.parent.postMessage('closeManageTagsDrawer', '*'); + }; + } useEffect(() => { const handleEsc = (event) => { /* Close drawer when ESC-key is pressed and selectable dropdown box not open */ const selectableBoxOpen = document.querySelector('[data-selectable-box="taxonomy-tags"]'); if (event.key === 'Escape' && !selectableBoxOpen) { - closeContentTagsDrawer(); + onCloseDrawer(); } }; document.addEventListener('keydown', handleEsc); @@ -86,7 +137,7 @@ const ContentTagsDrawer = () => {
- closeContentTagsDrawer()} data-testid="drawer-close-button" /> + onCloseDrawer()} data-testid="drawer-close-button" /> {intl.formatMessage(messages.headerSubtitle)} { isContentDataLoaded ?

{ contentData.displayName }

@@ -105,7 +156,14 @@ const ContentTagsDrawer = () => { { isTaxonomyListLoaded && isContentTaxonomyTagsLoaded ? taxonomies.map((data) => (
- +
)) @@ -116,4 +174,14 @@ const ContentTagsDrawer = () => { ); }; +ContentTagsDrawer.propTypes = { + id: PropTypes.string, + onClose: PropTypes.func, +}; + +ContentTagsDrawer.defaultProps = { + id: undefined, + onClose: undefined, +}; + export default ContentTagsDrawer; diff --git a/src/content-tags-drawer/ContentTagsDrawer.test.jsx b/src/content-tags-drawer/ContentTagsDrawer.test.jsx index b8fe58c3b..37fd2343f 100644 --- a/src/content-tags-drawer/ContentTagsDrawer.test.jsx +++ b/src/content-tags-drawer/ContentTagsDrawer.test.jsx @@ -1,18 +1,25 @@ import React from 'react'; import { IntlProvider } from '@edx/frontend-platform/i18n'; -import { act, render, fireEvent } from '@testing-library/react'; +import { + act, render, fireEvent, screen, +} from '@testing-library/react'; import ContentTagsDrawer from './ContentTagsDrawer'; import { useContentTaxonomyTagsData, useContentData, + useTaxonomyTagsData, } from './data/apiHooks'; import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded } from '../taxonomy/data/apiHooks'; +import messages from './messages'; + +const contentId = 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@7f47fe2dbcaf47c5a071671c741fe1ab'; +const mockOnClose = jest.fn(); jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), useParams: () => ({ - contentId: 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@7f47fe2dbcaf47c5a071671c741fe1ab', + contentId, }), })); @@ -28,6 +35,15 @@ jest.mock('./data/apiHooks', () => ({ useContentTaxonomyTagsUpdater: jest.fn(() => ({ isError: false, })), + useTaxonomyTagsData: jest.fn(() => ({ + hasMorePages: false, + tagPages: { + isLoading: true, + isError: false, + canAddTag: false, + data: [], + }, + })), })); jest.mock('../taxonomy/data/apiHooks', () => ({ @@ -35,13 +51,89 @@ jest.mock('../taxonomy/data/apiHooks', () => ({ useIsTaxonomyListDataLoaded: jest.fn(), })); -const RootWrapper = () => ( +const RootWrapper = (params) => ( - + ); describe('', () => { + const setupMockDataForStagedTagsTesting = () => { + useIsTaxonomyListDataLoaded.mockReturnValue(true); + useContentTaxonomyTagsData.mockReturnValue({ + isSuccess: true, + data: { + taxonomies: [ + { + name: 'Taxonomy 1', + taxonomyId: 123, + canTagObject: true, + tags: [ + { + value: 'Tag 1', + lineage: ['Tag 1'], + canDeleteObjecttag: true, + }, + { + value: 'Tag 2', + lineage: ['Tag 2'], + canDeleteObjecttag: true, + }, + ], + }, + ], + }, + }); + useTaxonomyListDataResponse.mockReturnValue({ + results: [{ + id: 123, + name: 'Taxonomy 1', + description: 'This is a description 1', + canTagObject: true, + }], + }); + + useTaxonomyTagsData.mockReturnValue({ + hasMorePages: false, + canAddTag: false, + tagPages: { + isLoading: false, + isError: false, + data: [{ + value: 'Tag 1', + externalId: null, + childCount: 0, + depth: 0, + parentValue: null, + id: 12345, + subTagsUrl: null, + canChangeTag: false, + canDeleteTag: false, + }, { + value: 'Tag 2', + externalId: null, + childCount: 0, + depth: 0, + parentValue: null, + id: 12346, + subTagsUrl: null, + canChangeTag: false, + canDeleteTag: false, + }, { + value: 'Tag 3', + externalId: null, + childCount: 0, + depth: 0, + parentValue: null, + id: 12347, + subTagsUrl: null, + canChangeTag: false, + canDeleteTag: false, + }], + }, + }); + }; + it('should render page and page title correctly', () => { const { getByText } = render(); expect(getByText('Manage tags')).toBeInTheDocument(); @@ -77,6 +169,17 @@ describe('', () => { }); }); + it('shows content using params', async () => { + useContentData.mockReturnValue({ + isSuccess: true, + data: { + displayName: 'Unit 1', + }, + }); + render(); + expect(screen.getByText('Unit 1')).toBeInTheDocument(); + }); + it('shows the taxonomies data including tag numbers after the query is complete', async () => { useIsTaxonomyListDataLoaded.mockReturnValue(true); useContentTaxonomyTagsData.mockReturnValue({ @@ -138,7 +241,102 @@ describe('', () => { }); }); - it('should call closeContentTagsDrawer when CloseButton is clicked', async () => { + it('should test adding a content tag to the staged tags for a taxonomy', () => { + setupMockDataForStagedTagsTesting(); + + const { container, getByText, getAllByText } = render(); + + // Expand the Taxonomy to view applied tags and "Add a tag" button + const expandToggle = container.getElementsByClassName('collapsible-trigger')[0]; + + fireEvent.click(expandToggle); + + // Click on "Add a tag" button to open dropdown + const addTagsButton = getByText(messages.collapsibleAddTagsPlaceholderText.defaultMessage); + // Use `mouseDown` instead of `click` since the react-select didn't respond to `click` + fireEvent.mouseDown(addTagsButton); + + // Tag 3 should only appear in dropdown selector, (i.e. the dropdown is open, since Tag 3 is not applied) + expect(getAllByText('Tag 3').length).toBe(1); + + // Click to check Tag 3 + const tag3 = getByText('Tag 3'); + fireEvent.click(tag3); + + // Check that Tag 3 has been staged, i.e. there should be 2 of them on the page + expect(getAllByText('Tag 3').length).toBe(2); + }); + + it('should test removing a staged content from a taxonomy', () => { + setupMockDataForStagedTagsTesting(); + + const { container, getByText, getAllByText } = render(); + + // Expand the Taxonomy to view applied tags and "Add a tag" button + const expandToggle = container.getElementsByClassName('collapsible-trigger')[0]; + + fireEvent.click(expandToggle); + + // Click on "Add a tag" button to open dropdown + const addTagsButton = getByText(messages.collapsibleAddTagsPlaceholderText.defaultMessage); + // Use `mouseDown` instead of `click` since the react-select didn't respond to `click` + fireEvent.mouseDown(addTagsButton); + + // Tag 3 should only appear in dropdown selector, (i.e. the dropdown is open, since Tag 3 is not applied) + expect(getAllByText('Tag 3').length).toBe(1); + + // Click to check Tag 3 + const tag3 = getByText('Tag 3'); + fireEvent.click(tag3); + + // Check that Tag 3 has been staged, i.e. there should be 2 of them on the page + expect(getAllByText('Tag 3').length).toBe(2); + + // Click it again to unstage it and confirm that there is only one on the page + fireEvent.click(tag3); + expect(getAllByText('Tag 3').length).toBe(1); + }); + + it('should test clearing staged tags for a taxonomy', () => { + setupMockDataForStagedTagsTesting(); + + const { + container, + getByText, + getAllByText, + queryByText, + } = render(); + + // Expand the Taxonomy to view applied tags and "Add a tag" button + const expandToggle = container.getElementsByClassName('collapsible-trigger')[0]; + + fireEvent.click(expandToggle); + + // Click on "Add a tag" button to open dropdown + const addTagsButton = getByText(messages.collapsibleAddTagsPlaceholderText.defaultMessage); + // Use `mouseDown` instead of `click` since the react-select didn't respond to `click` + fireEvent.mouseDown(addTagsButton); + + // Tag 3 should only appear in dropdown selector, (i.e. the dropdown is open, since Tag 3 is not applied) + expect(getAllByText('Tag 3').length).toBe(1); + + // Click to check Tag 3 + const tag3 = getByText('Tag 3'); + fireEvent.click(tag3); + + // Check that Tag 3 has been staged, i.e. there should be 2 of them on the page + expect(getAllByText('Tag 3').length).toBe(2); + + // Click on the Cancel button in the dropdown to clear the staged tags + const dropdownCancel = getByText(messages.collapsibleCancelStagedTagsButtonText.defaultMessage); + fireEvent.click(dropdownCancel); + + // Check that there are no more Tag 3 on the page, since the staged one is cleared + // and the dropdown has been closed + expect(queryByText('Tag 3')).not.toBeInTheDocument(); + }); + + it('should call closeManageTagsDrawer when CloseButton is clicked', async () => { const postMessageSpy = jest.spyOn(window.parent, 'postMessage'); const { getByTestId } = render(); @@ -152,7 +350,17 @@ describe('', () => { postMessageSpy.mockRestore(); }); - it('should call closeContentTagsDrawer when Escape key is pressed and no selectable box is active', () => { + it('should call onClose param when CloseButton is clicked', async () => { + render(); + + // Find the CloseButton element by its test ID and trigger a click event + const closeButton = screen.getByTestId('drawer-close-button'); + fireEvent.click(closeButton); + + expect(mockOnClose).toHaveBeenCalled(); + }); + + it('should call closeManageTagsDrawer when Escape key is pressed and no selectable box is active', () => { const postMessageSpy = jest.spyOn(window.parent, 'postMessage'); const { container } = render(); @@ -166,7 +374,7 @@ describe('', () => { postMessageSpy.mockRestore(); }); - it('should not call closeContentTagsDrawer when Escape key is pressed and a selectable box is active', () => { + it('should not call closeManageTagsDrawer when Escape key is pressed and a selectable box is active', () => { const postMessageSpy = jest.spyOn(window.parent, 'postMessage'); const { container } = render(); diff --git a/src/content-tags-drawer/ContentTagsDropDownSelector.jsx b/src/content-tags-drawer/ContentTagsDropDownSelector.jsx index d82a74e00..1cb622996 100644 --- a/src/content-tags-drawer/ContentTagsDropDownSelector.jsx +++ b/src/content-tags-drawer/ContentTagsDropDownSelector.jsx @@ -1,16 +1,15 @@ // @ts-check import React, { useEffect, useState, useCallback } from 'react'; import { - SelectableBox, Icon, Spinner, Button, } from '@openedx/paragon'; +import { SelectableBox } from '@edx/frontend-lib-content-components'; import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n'; -import { ArrowDropDown, ArrowDropUp } from '@openedx/paragon/icons'; +import { ArrowDropDown, ArrowDropUp, Add } from '@openedx/paragon/icons'; import PropTypes from 'prop-types'; import messages from './messages'; -import './ContentTagsDropDownSelector.scss'; import { useTaxonomyTagsData } from './data/apiHooks'; @@ -42,7 +41,7 @@ HighlightedText.defaultProps = { }; const ContentTagsDropDownSelector = ({ - taxonomyId, level, lineage, tagsTree, searchTerm, + taxonomyId, level, lineage, appliedContentTagsTree, stagedContentTagsTree, searchTerm, }) => { const intl = useIntl(); @@ -89,13 +88,30 @@ const ContentTagsDropDownSelector = ({ }; const isImplicit = (tag) => { - // Traverse the tags tree using the lineage - let traversal = tagsTree; + // Traverse the applied tags tree using the lineage + let appliedTraversal = appliedContentTagsTree; lineage.forEach(t => { - traversal = traversal[t]?.children || {}; + appliedTraversal = appliedTraversal[t]?.children || {}; }); + const isAppliedImplicit = (appliedTraversal[tag.value] && !appliedTraversal[tag.value].explicit); - return (traversal[tag.value] && !traversal[tag.value].explicit) || false; + // Traverse the staged tags tree using the lineage + let stagedTraversal = stagedContentTagsTree; + lineage.forEach(t => { + stagedTraversal = stagedTraversal[t]?.children || {}; + }); + const isStagedImplicit = (stagedTraversal[tag.value] && !stagedTraversal[tag.value].explicit); + + return isAppliedImplicit || isStagedImplicit || false; + }; + + const isApplied = (tag) => { + // Traverse the applied tags tree using the lineage + let appliedTraversal = appliedContentTagsTree; + lineage.forEach(t => { + appliedTraversal = appliedTraversal[t]?.children || {}; + }); + return !!appliedTraversal[tag.value]; }; const loadMoreTags = useCallback(() => { @@ -131,8 +147,8 @@ const ContentTagsDropDownSelector = ({ aria-label={intl.formatMessage(messages.taxonomyTagsCheckboxAriaLabel, { tag: tagData.value })} data-selectable-box="taxonomy-tags" value={[...lineage, tagData.value].map(t => encodeURIComponent(t)).join(',')} - isIndeterminate={isImplicit(tagData)} - disabled={isImplicit(tagData)} + isIndeterminate={isApplied(tagData) || isImplicit(tagData)} + disabled={isApplied(tagData) || isImplicit(tagData)} > @@ -156,7 +172,8 @@ const ContentTagsDropDownSelector = ({ taxonomyId={taxonomyId} level={level + 1} lineage={[...lineage, tagData.value]} - tagsTree={tagsTree} + appliedContentTagsTree={appliedContentTagsTree} + stagedContentTagsTree={stagedContentTagsTree} searchTerm={searchTerm} /> )} @@ -166,11 +183,12 @@ const ContentTagsDropDownSelector = ({ { hasMorePages ? ( -
+
@@ -197,7 +215,13 @@ ContentTagsDropDownSelector.propTypes = { taxonomyId: PropTypes.number.isRequired, level: PropTypes.number.isRequired, lineage: PropTypes.arrayOf(PropTypes.string), - tagsTree: PropTypes.objectOf( + appliedContentTagsTree: PropTypes.objectOf( + PropTypes.shape({ + explicit: PropTypes.bool.isRequired, + children: PropTypes.shape({}).isRequired, + }).isRequired, + ).isRequired, + stagedContentTagsTree: PropTypes.objectOf( PropTypes.shape({ explicit: PropTypes.bool.isRequired, children: PropTypes.shape({}).isRequired, diff --git a/src/content-tags-drawer/ContentTagsDropDownSelector.scss b/src/content-tags-drawer/ContentTagsDropDownSelector.scss index 4a3541e10..4c32ddb4d 100644 --- a/src/content-tags-drawer/ContentTagsDropDownSelector.scss +++ b/src/content-tags-drawer/ContentTagsDropDownSelector.scss @@ -4,11 +4,24 @@ .taxonomy-tags-load-more-button { flex: 1; + + &:hover { + background-color: transparent; + color: $info-900 !important; + } } .pgn__selectable_box.taxonomy-tags-selectable-box { box-shadow: none; padding: 0; + + // Override indeterminate [-] (implicit) checkbox styles to match checked checkbox styles + // In the future, this customizability should be implemented in paragon instead + input.pgn__form-checkbox-input { + &:indeterminate { + @extend :checked; /* stylelint-disable-line scss/at-extend-no-missing-placeholder */ + } + } } .pgn__selectable_box.taxonomy-tags-selectable-box:disabled, diff --git a/src/content-tags-drawer/ContentTagsDropDownSelector.test.jsx b/src/content-tags-drawer/ContentTagsDropDownSelector.test.jsx index 7800e99e7..ee067aa69 100644 --- a/src/content-tags-drawer/ContentTagsDropDownSelector.test.jsx +++ b/src/content-tags-drawer/ContentTagsDropDownSelector.test.jsx @@ -25,10 +25,12 @@ const data = { taxonomyId: 123, level: 0, tagsTree: {}, + appliedContentTagsTree: {}, + stagedContentTagsTree: {}, }; const ContentTagsDropDownSelectorComponent = ({ - taxonomyId, level, lineage, tagsTree, searchTerm, + taxonomyId, level, lineage, tagsTree, searchTerm, appliedContentTagsTree, stagedContentTagsTree, }) => ( ); @@ -53,15 +57,25 @@ describe('', () => { jest.clearAllMocks(); }); + async function getComponent(updatedData) { + const componentData = (!updatedData ? data : updatedData); + + return render( + , + ); + } + it('should render taxonomy tags drop down selector loading with spinner', async () => { await act(async () => { - const { getByRole } = render( - , - ); + const { getByRole } = await getComponent(); const spinner = getByRole('status'); expect(spinner.textContent).toEqual('Loading tags'); // Uses }); @@ -86,14 +100,8 @@ describe('', () => { }); await act(async () => { - const { container, getByText } = render( - , - ); + const { container, getByText } = await getComponent(); + await waitFor(() => { expect(getByText('Tag 1')).toBeInTheDocument(); expect(container.getElementsByClassName('taxonomy-tags-arrow-drop-down').length).toBe(0); @@ -120,13 +128,8 @@ describe('', () => { }); await act(async () => { - const { container, getByText } = render( - , - ); + const { container, getByText } = await getComponent(); + await waitFor(() => { expect(getByText('Tag 2')).toBeInTheDocument(); expect(container.getElementsByClassName('taxonomy-tags-arrow-drop-down').length).toBe(1); @@ -162,13 +165,7 @@ describe('', () => { }, }, }; - const { container, getByText } = render( - , - ); + const { container, getByText } = await getComponent(dataWithTagsTree); await waitFor(() => { expect(getByText('Tag 2')).toBeInTheDocument(); expect(container.getElementsByClassName('taxonomy-tags-arrow-drop-down').length).toBe(1); @@ -230,13 +227,7 @@ describe('', () => { }, }, }; - const { container, getByText } = render( - , - ); + const { container, getByText } = await getComponent(dataWithTagsTree); await waitFor(() => { expect(getByText('Tag 2')).toBeInTheDocument(); expect(container.getElementsByClassName('taxonomy-tags-arrow-drop-down').length).toBe(1); @@ -291,15 +282,7 @@ describe('', () => { const initalSearchTerm = 'test 1'; await act(async () => { - const { rerender } = render( - , - ); + const { rerender } = await getComponent({ ...data, searchTerm: initalSearchTerm }); await waitFor(() => { expect(useTaxonomyTagsData).toBeCalledWith(data.taxonomyId, null, 1, initalSearchTerm); @@ -312,6 +295,8 @@ describe('', () => { level={data.level} tagsTree={data.tagsTree} searchTerm={updatedSearchTerm} + appliedContentTagsTree={{}} + stagedContentTagsTree={{}} />); await waitFor(() => { @@ -326,6 +311,8 @@ describe('', () => { level={data.level} tagsTree={data.tagsTree} searchTerm={cleanSearchTerm} + appliedContentTagsTree={{}} + stagedContentTagsTree={{}} />); await waitFor(() => { @@ -347,15 +334,7 @@ describe('', () => { const searchTerm = 'uncommon search term'; await act(async () => { - const { getByText } = render( - , - ); + const { getByText } = await getComponent({ ...data, searchTerm }); await waitFor(() => { expect(useTaxonomyTagsData).toBeCalledWith(data.taxonomyId, null, 1, searchTerm); diff --git a/src/content-tags-drawer/TagBubble.jsx b/src/content-tags-drawer/TagBubble.jsx index 50c2b3560..b1b0c9b0b 100644 --- a/src/content-tags-drawer/TagBubble.jsx +++ b/src/content-tags-drawer/TagBubble.jsx @@ -14,7 +14,7 @@ const TagBubble = ({ const handleClick = React.useCallback(() => { if (!implicit && canRemove) { - removeTagHandler(lineage.join(','), false); + removeTagHandler(lineage.join(',')); } }, [implicit, lineage, canRemove, removeTagHandler]); diff --git a/src/content-tags-drawer/__mocks__/contentTaxonomyTagsCountMock.js b/src/content-tags-drawer/__mocks__/contentTaxonomyTagsCountMock.js new file mode 100644 index 000000000..3ce4d2050 --- /dev/null +++ b/src/content-tags-drawer/__mocks__/contentTaxonomyTagsCountMock.js @@ -0,0 +1,3 @@ +module.exports = { + 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b': 20, +}; diff --git a/src/content-tags-drawer/__mocks__/contentTaxonomyTagsTreeMock.js b/src/content-tags-drawer/__mocks__/contentTaxonomyTagsTreeMock.js new file mode 100644 index 000000000..687e3d357 --- /dev/null +++ b/src/content-tags-drawer/__mocks__/contentTaxonomyTagsTreeMock.js @@ -0,0 +1,35 @@ +module.exports = { + 'hierarchical taxonomy tag 1': { + children: { + 'hierarchical taxonomy tag 1.7': { + children: { + 'hierarchical taxonomy tag 1.7.59': { + children: {}, + }, + }, + }, + }, + }, + 'hierarchical taxonomy tag 2': { + children: { + 'hierarchical taxonomy tag 2.13': { + children: { + 'hierarchical taxonomy tag 2.13.46': { + children: {}, + }, + }, + }, + }, + }, + 'hierarchical taxonomy tag 3': { + children: { + 'hierarchical taxonomy tag 3.4': { + children: { + 'hierarchical taxonomy tag 3.4.50': { + children: {}, + }, + }, + }, + }, + }, +}; diff --git a/src/content-tags-drawer/__mocks__/index.js b/src/content-tags-drawer/__mocks__/index.js index 5ec302738..8c4274d64 100644 --- a/src/content-tags-drawer/__mocks__/index.js +++ b/src/content-tags-drawer/__mocks__/index.js @@ -2,3 +2,5 @@ export { default as taxonomyTagsMock } from './taxonomyTagsMock'; export { default as contentTaxonomyTagsMock } from './contentTaxonomyTagsMock'; export { default as contentDataMock } from './contentDataMock'; export { default as updateContentTaxonomyTagsMock } from './updateContentTaxonomyTagsMock'; +export { default as contentTaxonomyTagsCountMock } from './contentTaxonomyTagsCountMock'; +export { default as contentTaxonomyTagsTreeMock } from './contentTaxonomyTagsTreeMock'; diff --git a/src/content-tags-drawer/data/api.js b/src/content-tags-drawer/data/api.js index 28bd7a36c..86ee7746d 100644 --- a/src/content-tags-drawer/data/api.js +++ b/src/content-tags-drawer/data/api.js @@ -31,6 +31,7 @@ export const getTaxonomyTagsApiUrl = (taxonomyId, options = {}) => { export const getContentTaxonomyTagsApiUrl = (contentId) => new URL(`api/content_tagging/v1/object_tags/${contentId}/`, getApiBaseUrl()).href; export const getXBlockContentDataApiURL = (contentId) => new URL(`/xblock/outline/${contentId}`, getApiBaseUrl()).href; export const getLibraryContentDataApiUrl = (contentId) => new URL(`/api/libraries/v2/blocks/${contentId}/`, getApiBaseUrl()).href; +export const getContentTaxonomyTagsCountApiUrl = (contentId) => new URL(`api/content_tagging/v1/object_tag_counts/${contentId}/?count_implicit`, getApiBaseUrl()).href; /** * Get all tags that belong to taxonomy. @@ -54,6 +55,19 @@ export async function getContentTaxonomyTagsData(contentId) { return camelCaseObject(data[contentId]); } +/** + * Get the count of tags that are applied to the content object + * @param {string} contentId The id of the content object to fetch the count of the applied tags for + * @returns {Promise} + */ +export async function getContentTaxonomyTagsCount(contentId) { + const { data } = await getAuthenticatedHttpClient().get(getContentTaxonomyTagsCountApiUrl(contentId)); + if (contentId in data) { + return camelCaseObject(data[contentId]); + } + return 0; +} + /** * Fetch meta data (eg: display_name) about the content object (unit/compoenent) * @param {string} contentId The id of the content object (unit/component) diff --git a/src/content-tags-drawer/data/api.test.js b/src/content-tags-drawer/data/api.test.js index 9fa88dcb7..7ccb35354 100644 --- a/src/content-tags-drawer/data/api.test.js +++ b/src/content-tags-drawer/data/api.test.js @@ -6,6 +6,7 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { taxonomyTagsMock, contentTaxonomyTagsMock, + contentTaxonomyTagsCountMock, contentDataMock, updateContentTaxonomyTagsMock, } from '../__mocks__'; @@ -19,6 +20,8 @@ import { getContentTaxonomyTagsData, getContentData, updateContentTaxonomyTags, + getContentTaxonomyTagsCountApiUrl, + getContentTaxonomyTagsCount, } from './api'; let axiosMock; @@ -88,6 +91,24 @@ describe('content tags drawer api calls', () => { expect(result).toEqual(contentTaxonomyTagsMock[contentId]); }); + it('should get content taxonomy tags count', async () => { + const contentId = 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b'; + axiosMock.onGet(getContentTaxonomyTagsCountApiUrl(contentId)).reply(200, contentTaxonomyTagsCountMock); + const result = await getContentTaxonomyTagsCount(contentId); + + expect(axiosMock.history.get[0].url).toEqual(getContentTaxonomyTagsCountApiUrl(contentId)); + expect(result).toEqual(contentTaxonomyTagsCountMock[contentId]); + }); + + it('should get content taxonomy tags count as zero', async () => { + const contentId = 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b'; + axiosMock.onGet(getContentTaxonomyTagsCountApiUrl(contentId)).reply(200, {}); + const result = await getContentTaxonomyTagsCount(contentId); + + expect(axiosMock.history.get[0].url).toEqual(getContentTaxonomyTagsCountApiUrl(contentId)); + expect(result).toEqual(0); + }); + it('should get content data for course component', async () => { const contentId = 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b'; axiosMock.onGet(getXBlockContentDataApiURL(contentId)).reply(200, contentDataMock); diff --git a/src/content-tags-drawer/data/apiHooks.jsx b/src/content-tags-drawer/data/apiHooks.jsx index 1b95f6093..5c24c0aa6 100644 --- a/src/content-tags-drawer/data/apiHooks.jsx +++ b/src/content-tags-drawer/data/apiHooks.jsx @@ -11,6 +11,7 @@ import { getContentTaxonomyTagsData, getContentData, updateContentTaxonomyTags, + getContentTaxonomyTagsCount, } from './api'; /** @typedef {import("../../taxonomy/tag-list/data/types.mjs").TagListData} TagListData */ @@ -105,6 +106,17 @@ export const useContentTaxonomyTagsData = (contentId) => ( }) ); +/** + * Build the query to get the count og taxonomy tags applied to the content object + * @param {string} contentId The ID of the content object to fetch the count of the applied tags for + */ +export const useContentTaxonomyTagsCount = (contentId) => ( + useQuery({ + queryKey: ['contentTaxonomyTagsCount', contentId], + queryFn: () => getContentTaxonomyTagsCount(contentId), + }) +); + /** * Builds the query to get meta data about the content object * @param {string} contentId The id of the content object (unit/component) @@ -135,8 +147,11 @@ export const useContentTaxonomyTagsUpdater = (contentId, taxonomyId) => { * >} */ mutationFn: ({ tags }) => updateContentTaxonomyTags(contentId, taxonomyId, tags), - onSettled: () => { + onSettled: /* istanbul ignore next */ () => { queryClient.invalidateQueries({ queryKey: ['contentTaxonomyTags', contentId] }); + /// Invalidate query with pattern on course outline + queryClient.invalidateQueries({ queryKey: ['unitTagsCount'] }); + queryClient.invalidateQueries({ queryKey: ['contentTaxonomyTagsCount', contentId] }); }, }); }; diff --git a/src/content-tags-drawer/data/apiHooks.test.jsx b/src/content-tags-drawer/data/apiHooks.test.jsx index 4e12ef5ea..127d71cc5 100644 --- a/src/content-tags-drawer/data/apiHooks.test.jsx +++ b/src/content-tags-drawer/data/apiHooks.test.jsx @@ -6,6 +6,7 @@ import { useContentTaxonomyTagsData, useContentData, useContentTaxonomyTagsUpdater, + useContentTaxonomyTagsCount, } from './apiHooks'; import { updateContentTaxonomyTags } from './api'; @@ -134,6 +135,24 @@ describe('useContentTaxonomyTagsData', () => { }); }); +describe('useContentTaxonomyTagsCount', () => { + it('should return success response', () => { + useQuery.mockReturnValueOnce({ isSuccess: true, data: 'data' }); + const contentId = '123'; + const result = useContentTaxonomyTagsCount(contentId); + + expect(result).toEqual({ isSuccess: true, data: 'data' }); + }); + + it('should return failure response', () => { + useQuery.mockReturnValueOnce({ isSuccess: false }); + const contentId = '123'; + const result = useContentTaxonomyTagsCount(contentId); + + expect(result).toEqual({ isSuccess: false }); + }); +}); + describe('useContentData', () => { it('should return success response', () => { useQuery.mockReturnValueOnce({ isSuccess: true, data: 'data' }); diff --git a/src/content-tags-drawer/index.scss b/src/content-tags-drawer/index.scss new file mode 100644 index 000000000..d179bf86a --- /dev/null +++ b/src/content-tags-drawer/index.scss @@ -0,0 +1,2 @@ +@import "content-tags-drawer/TagBubble"; +@import "content-tags-drawer/tags-sidebar-controls/TagsSidebarControls"; diff --git a/src/content-tags-drawer/messages.js b/src/content-tags-drawer/messages.js index c54e6b7bc..47a8c1bc8 100644 --- a/src/content-tags-drawer/messages.js +++ b/src/content-tags-drawer/messages.js @@ -33,6 +33,32 @@ const messages = defineMessages({ id: 'course-authoring.content-tags-drawer.content-tags-collapsible.selectable-box.selection.aria.label', defaultMessage: 'taxonomy tags selection', }, + manageTagsButton: { + id: 'course-authoring.content-tags-drawer.button.manage', + defaultMessage: 'Manage Tags', + description: 'Label in the button that opens the drawer to edit content tags', + }, + tagsSidebarTitle: { + id: 'course-authoring.course-unit.sidebar.tags.title', + defaultMessage: 'Unit Tags', + description: 'Title of the tags sidebar', + }, + collapsibleAddTagsPlaceholderText: { + id: 'course-authoring.content-tags-drawer.content-tags-collapsible.custom-menu.placeholder-text', + defaultMessage: 'Add a tag', + }, + collapsibleAddStagedTagsButtonText: { + id: 'course-authoring.content-tags-drawer.content-tags-collapsible.custom-menu.save-staged-tags', + defaultMessage: 'Add tags', + }, + collapsibleCancelStagedTagsButtonText: { + id: 'course-authoring.content-tags-drawer.content-tags-collapsible.custom-menu.cancel-staged-tags', + defaultMessage: 'Cancel', + }, + collapsibleInlineAddStagedTagsButtonText: { + id: 'course-authoring.content-tags-drawer.content-tags-collapsible.custom-menu.inline-save-staged-tags', + defaultMessage: 'Add', + }, }); export default messages; diff --git a/src/content-tags-drawer/tags-sidebar-controls/TagsSidebarBody.jsx b/src/content-tags-drawer/tags-sidebar-controls/TagsSidebarBody.jsx new file mode 100644 index 000000000..29b2e244d --- /dev/null +++ b/src/content-tags-drawer/tags-sidebar-controls/TagsSidebarBody.jsx @@ -0,0 +1,112 @@ +// @ts-check +import React, { useState, useMemo } from 'react'; +import { + Card, Stack, Button, Sheet, Collapsible, Icon, +} from '@openedx/paragon'; +import { ArrowDropDown, ArrowDropUp } from '@openedx/paragon/icons'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { useParams } from 'react-router-dom'; +import { ContentTagsDrawer } from '..'; + +import messages from '../messages'; +import { useContentTaxonomyTagsData } from '../data/apiHooks'; +import { LoadingSpinner } from '../../generic/Loading'; +import TagsTree from './TagsTree'; + +const TagsSidebarBody = () => { + const intl = useIntl(); + const [showManageTags, setShowManageTags] = useState(false); + const contentId = useParams().blockId; + const onClose = () => setShowManageTags(false); + + const { + data: contentTaxonomyTagsData, + isSuccess: isContentTaxonomyTagsLoaded, + } = useContentTaxonomyTagsData(contentId || ''); + + const buildTagsTree = (contentTags) => { + const resultTree = {}; + contentTags.forEach(item => { + let currentLevel = resultTree; + + item.lineage.forEach((key) => { + if (!currentLevel[key]) { + currentLevel[key] = { + children: {}, + canChangeObjecttag: item.canChangeObjecttag, + canDeleteObjecttag: item.canDeleteObjecttag, + }; + } + + currentLevel = currentLevel[key].children; + }); + }); + + return resultTree; + }; + + const tree = useMemo(() => { + const result = []; + if (isContentTaxonomyTagsLoaded && contentTaxonomyTagsData) { + contentTaxonomyTagsData.taxonomies.forEach((taxonomy) => { + result.push({ + ...taxonomy, + tags: buildTagsTree(taxonomy.tags), + }); + }); + } + return result; + }, [isContentTaxonomyTagsLoaded, contentTaxonomyTagsData]); + + return ( + <> + + + { isContentTaxonomyTagsLoaded + ? ( + + {tree.map((taxonomy) => ( +
+ } + iconWhenOpen={} + > + + +
+ ))} +
+ ) + : ( +
+ +
+ )} + + +
+
+ + + + + ); +}; + +TagsSidebarBody.propTypes = {}; + +export default TagsSidebarBody; diff --git a/src/content-tags-drawer/tags-sidebar-controls/TagsSidebarBody.test.jsx b/src/content-tags-drawer/tags-sidebar-controls/TagsSidebarBody.test.jsx new file mode 100644 index 000000000..32be90bf4 --- /dev/null +++ b/src/content-tags-drawer/tags-sidebar-controls/TagsSidebarBody.test.jsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import TagsSidebarBody from './TagsSidebarBody'; +import { useContentTaxonomyTagsData } from '../data/apiHooks'; +import { contentTaxonomyTagsMock } from '../__mocks__'; + +const contentId = 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b'; + +jest.mock('../data/apiHooks', () => ({ + useContentTaxonomyTagsData: jest.fn(() => ({ + isSuccess: false, + data: {}, + })), +})); +jest.mock('../ContentTagsDrawer', () => jest.fn(() =>
Mocked ContentTagsDrawer
)); + +const RootWrapper = () => ( + + + +); + +describe('', () => { + it('shows spinner before the content data query is complete', () => { + render(); + expect(screen.getByRole('status')).toBeInTheDocument(); + }); + + it('should render data after wuery is complete', () => { + useContentTaxonomyTagsData.mockReturnValue({ + isSuccess: true, + data: contentTaxonomyTagsMock[contentId], + }); + render(); + const taxonomyButton = screen.getByRole('button', { name: /hierarchicaltaxonomy/i }); + expect(taxonomyButton).toBeInTheDocument(); + + /// ContentTagsDrawer must be closed + expect(screen.queryByText('Mocked ContentTagsDrawer')).not.toBeInTheDocument(); + }); + + it('should open ContentTagsDrawer', () => { + useContentTaxonomyTagsData.mockReturnValue({ + isSuccess: true, + data: contentTaxonomyTagsMock[contentId], + }); + render(); + + const manageButton = screen.getByRole('button', { name: /manage tags/i }); + fireEvent.click(manageButton); + + expect(screen.getByText('Mocked ContentTagsDrawer')).toBeInTheDocument(); + }); +}); diff --git a/src/content-tags-drawer/tags-sidebar-controls/TagsSidebarControls.scss b/src/content-tags-drawer/tags-sidebar-controls/TagsSidebarControls.scss new file mode 100644 index 000000000..a3c0978f8 --- /dev/null +++ b/src/content-tags-drawer/tags-sidebar-controls/TagsSidebarControls.scss @@ -0,0 +1,23 @@ +.tags-sidebar { + .tags-sidebar-body { + .tags-sidebar-taxonomy { + .collapsible-trigger { + font-weight: bold; + border: none; + justify-content: start; + padding-left: 0; + padding-bottom: 0; + + .collapsible-icon { + order: -1; + margin-left: 0; + } + } + + .collapsible-body { + padding-top: 0; + padding-bottom: 0; + } + } + } +} diff --git a/src/content-tags-drawer/tags-sidebar-controls/TagsSidebarHeader.jsx b/src/content-tags-drawer/tags-sidebar-controls/TagsSidebarHeader.jsx new file mode 100644 index 000000000..e3927deb8 --- /dev/null +++ b/src/content-tags-drawer/tags-sidebar-controls/TagsSidebarHeader.jsx @@ -0,0 +1,36 @@ +// @ts-check +import React from 'react'; +import { Stack } from '@openedx/paragon'; +import { useParams } from 'react-router-dom'; +import { useIntl } from '@edx/frontend-platform/i18n'; + +import messages from '../messages'; +import { useContentTaxonomyTagsCount } from '../data/apiHooks'; +import TagCount from '../../generic/tag-count'; + +const TagsSidebarHeader = () => { + const intl = useIntl(); + const contentId = useParams().blockId; + + const { + data: contentTaxonomyTagsCount, + isSuccess: isContentTaxonomyTagsCountLoaded, + } = useContentTaxonomyTagsCount(contentId || ''); + + return ( + +

+ {intl.formatMessage(messages.tagsSidebarTitle)} +

+ { isContentTaxonomyTagsCountLoaded + && } +
+ ); +}; + +TagsSidebarHeader.propTypes = {}; + +export default TagsSidebarHeader; diff --git a/src/content-tags-drawer/tags-sidebar-controls/TagsSidebarHeader.test.jsx b/src/content-tags-drawer/tags-sidebar-controls/TagsSidebarHeader.test.jsx new file mode 100644 index 000000000..ab0f9339e --- /dev/null +++ b/src/content-tags-drawer/tags-sidebar-controls/TagsSidebarHeader.test.jsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import TagsSidebarHeader from './TagsSidebarHeader'; +import { useContentTaxonomyTagsCount } from '../data/apiHooks'; + +jest.mock('../data/apiHooks', () => ({ + useContentTaxonomyTagsCount: jest.fn(() => ({ + isSuccess: false, + data: 17, + })), +})); + +const RootWrapper = () => ( + + + +); + +describe('', () => { + it('should not render count on loading', () => { + render(); + expect(screen.getByRole('heading', { name: /unit tags/i })).toBeInTheDocument(); + expect(screen.queryByText('17')).not.toBeInTheDocument(); + }); + + it('should render count after query is complete', () => { + useContentTaxonomyTagsCount.mockReturnValue({ + isSuccess: true, + data: 17, + }); + render(); + expect(screen.getByRole('heading', { name: /unit tags/i })).toBeInTheDocument(); + expect(screen.getByText('17')).toBeInTheDocument(); + }); +}); diff --git a/src/content-tags-drawer/tags-sidebar-controls/TagsTree.jsx b/src/content-tags-drawer/tags-sidebar-controls/TagsTree.jsx new file mode 100644 index 000000000..df9923271 --- /dev/null +++ b/src/content-tags-drawer/tags-sidebar-controls/TagsTree.jsx @@ -0,0 +1,50 @@ +// @ts-check +import React from 'react'; +import PropTypes from 'prop-types'; +import { Icon } from '@openedx/paragon'; +import { Tag } from '@openedx/paragon/icons'; + +const TagsTree = ({ tags, rootDepth, parentKey }) => { + if (Object.keys(tags).length === 0) { + return null; + } + + // Used to Generate tabs for the parents of this tree + const tabsNumberArray = Array.from({ length: rootDepth }, (_, index) => index + 1); + + return ( +
+ {Object.keys(tags).map((key) => ( +
+
+ { + tabsNumberArray.map((index) => ) + } + {key} +
+ { tags[key].children + && ( + + )} +
+ ))} +
+ ); +}; + +TagsTree.propTypes = { + tags: PropTypes.shape({}).isRequired, + parentKey: PropTypes.string, + rootDepth: PropTypes.number, +}; + +TagsTree.defaultProps = { + rootDepth: 0, + parentKey: undefined, +}; + +export default TagsTree; diff --git a/src/content-tags-drawer/tags-sidebar-controls/TagsTree.test.jsx b/src/content-tags-drawer/tags-sidebar-controls/TagsTree.test.jsx new file mode 100644 index 000000000..0ca8c5333 --- /dev/null +++ b/src/content-tags-drawer/tags-sidebar-controls/TagsTree.test.jsx @@ -0,0 +1,13 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import TagsTree from './TagsTree'; +import { contentTaxonomyTagsTreeMock } from '../__mocks__'; + +describe('', () => { + it('should render component and tags correctly', () => { + render(); + expect(screen.getByText('hierarchical taxonomy tag 1')).toBeInTheDocument(); + expect(screen.getByText('hierarchical taxonomy tag 2.13')).toBeInTheDocument(); + expect(screen.getByText('hierarchical taxonomy tag 3.4.50')).toBeInTheDocument(); + }); +}); diff --git a/src/content-tags-drawer/tags-sidebar-controls/index.jsx b/src/content-tags-drawer/tags-sidebar-controls/index.jsx new file mode 100644 index 000000000..98ffc5e7c --- /dev/null +++ b/src/content-tags-drawer/tags-sidebar-controls/index.jsx @@ -0,0 +1,13 @@ +import TagsSidebarHeader from './TagsSidebarHeader'; +import TagsSidebarBody from './TagsSidebarBody'; + +const TagsSidebarControls = () => ( + <> + + + +); + +TagsSidebarControls.propTypes = {}; + +export default TagsSidebarControls; diff --git a/src/course-checklist/AriaLiveRegion.jsx b/src/course-checklist/AriaLiveRegion.jsx new file mode 100644 index 000000000..06685be3d --- /dev/null +++ b/src/course-checklist/AriaLiveRegion.jsx @@ -0,0 +1,36 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { injectIntl, FormattedMessage } from '@edx/frontend-platform/i18n'; + +import messages from './messages'; + +const AriaLiveRegion = ({ + isCourseLaunchChecklistLoading, + isCourseBestPracticeChecklistLoading, + enableQuality, +}) => { + const courseLaunchLoadingMessage = isCourseLaunchChecklistLoading + ? + : ; + + const courseBestPracticesLoadingMessage = isCourseBestPracticeChecklistLoading + ? + : ; + + return ( +
+
+ {courseLaunchLoadingMessage} +
+ {enableQuality ?
{courseBestPracticesLoadingMessage}
: null} +
+ ); +}; + +AriaLiveRegion.propTypes = { + isCourseLaunchChecklistLoading: PropTypes.bool.isRequired, + isCourseBestPracticeChecklistLoading: PropTypes.bool.isRequired, + enableQuality: PropTypes.bool.isRequired, +}; + +export default injectIntl(AriaLiveRegion); diff --git a/src/course-checklist/ChecklistSection/ChecklistItemBody.jsx b/src/course-checklist/ChecklistSection/ChecklistItemBody.jsx new file mode 100644 index 000000000..f48bdc69b --- /dev/null +++ b/src/course-checklist/ChecklistSection/ChecklistItemBody.jsx @@ -0,0 +1,70 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n'; +import { + ActionRow, + Button, + Hyperlink, + Icon, +} from '@openedx/paragon'; +import { CheckCircle, RadioButtonUnchecked } from '@openedx/paragon/icons'; +import messages from './messages'; + +const ChecklistItemBody = ({ + checkId, + isCompleted, + updateLink, + // injected + intl, +}) => ( + +
+ {isCompleted ? ( + + ) : ( + + )} +
+
+
+ +
+
+ +
+
+ + {updateLink && ( + + + + )} +
+); + +ChecklistItemBody.defaultProps = { + updateLink: null, +}; + +ChecklistItemBody.propTypes = { + checkId: PropTypes.string.isRequired, + isCompleted: PropTypes.bool.isRequired, + updateLink: PropTypes.string, + // injected + intl: intlShape.isRequired, +}; + +export default injectIntl(ChecklistItemBody); diff --git a/src/course-checklist/ChecklistSection/ChecklistItemComment.jsx b/src/course-checklist/ChecklistSection/ChecklistItemComment.jsx new file mode 100644 index 000000000..b254a79c1 --- /dev/null +++ b/src/course-checklist/ChecklistSection/ChecklistItemComment.jsx @@ -0,0 +1,123 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { injectIntl, FormattedMessage, FormattedNumber } from '@edx/frontend-platform/i18n'; +import { Hyperlink, Icon } from '@openedx/paragon'; +import { ModeComment } from '@openedx/paragon/icons'; +import messages from './messages'; + +const ChecklistItemComment = ({ + checkId, + outlineUrl, + data, +}) => { + const commentWrapper = (comment) => ( +
+
+ +
+
+ {comment} +
+
+ ); + + if (checkId === 'gradingPolicy') { + const sumOfWeights = data?.grades.sumOfWeights || 0; + const showGradingCommentSection = Object.keys(data).length > 0 && sumOfWeights !== 1; + + const weightSumPercentage = (sumOfWeights * 100).toFixed(2); + const comment = ( + + ), + }} + /> + ); + return (showGradingCommentSection ? ( + commentWrapper(comment) + ) : null); + } + + if (checkId === 'assignmentDeadlines') { + const showDeadlinesCommentSection = Object.keys(data).length > 0 + && ( + data.assignments.assignmentsWithDatesBeforeStart.length > 0 + || data?.assignments.assignmentsWithDatesAfterEnd.length > 0 + || data?.assignments.assignmentsWithOraDatesBeforeStart.length > 0 + || data?.assignments.assignmentsWithOraDatesAfterEnd.length > 0 + ); + + const allGradedAssignmentsOutsideDateRange = [].concat( + data?.assignments.assignmentsWithDatesBeforeStart, + data?.assignments.assignmentsWithDatesAfterEnd, + data?.assignments.assignmentsWithOraDatesBeforeStart, + data?.assignments.assignmentsWithOraDatesAfterEnd, + ); + + // de-dupe in case one assignment has multiple violations + const assignmentsMap = new Map(); + allGradedAssignmentsOutsideDateRange.forEach( + (assignment) => { assignmentsMap.set(assignment.id, assignment); }, + ); + const gradedAssignmentsOutsideDateRange = []; + assignmentsMap.forEach( + (value) => { + gradedAssignmentsOutsideDateRange.push(value); + }, + ); + + const comment = ( + <> + +
    + {gradedAssignmentsOutsideDateRange.map(assignment => ( +
  • + +
  • + ))} +
+ + ); + return (showDeadlinesCommentSection ? ( + commentWrapper(comment) + ) : null); + } + + return null; +}; + +ChecklistItemComment.propTypes = { + checkId: PropTypes.string.isRequired, + outlineUrl: PropTypes.string.isRequired, + data: PropTypes.oneOfType([ + PropTypes.shape({ + grades: PropTypes.shape({ + sumOfWeights: PropTypes.number, + }), + }).isRequired, + PropTypes.shape({ + assignments: PropTypes.shape({ + totalNumber: PropTypes.number, + totalVisible: PropTypes.number, + /* eslint-disable react/forbid-prop-types */ + assignmentsWithDatesBeforeStart: PropTypes.array, + assignmentsWithDatesAfterEnd: PropTypes.array, + assignmentsWithOraDatesBeforeStart: PropTypes.array, + assignmentsWithOraDatesAfterEnd: PropTypes.array, + /* eslint-enable react/forbid-prop-types */ + }), + }).isRequired, + ]).isRequired, +}; + +export default injectIntl(ChecklistItemComment); diff --git a/src/course-checklist/ChecklistSection/ChecklistSection.jsx b/src/course-checklist/ChecklistSection/ChecklistSection.jsx new file mode 100644 index 000000000..46fe71889 --- /dev/null +++ b/src/course-checklist/ChecklistSection/ChecklistSection.jsx @@ -0,0 +1,142 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { injectIntl } from '@edx/frontend-platform/i18n'; +import { Container, Stack } from '@openedx/paragon'; + +import { LoadingSpinner } from '../../generic/Loading'; +import { getCompletionCount, useChecklistState } from './hooks'; +import ChecklistItemBody from './ChecklistItemBody'; +import ChecklistItemComment from './ChecklistItemComment'; +import { checklistItems } from './utils/courseChecklistData'; + +const ChecklistSection = ({ + dataHeading, + data, + idPrefix, + isLoading, + updateLinks, +}) => { + const dataList = checklistItems[idPrefix]; + const getCompletionCountID = () => (`${idPrefix}-completion-count`); + const { checklistState } = useChecklistState({ data, dataList }); + const { checks, totalCompletedChecks, values } = checklistState; + + return ( + +

{dataHeading}

+ {isLoading ? ( +
+ +
+ ) : ( + <> +
+ {getCompletionCount(checks, totalCompletedChecks)} +
+ + {checks.map(check => { + const checkId = check.id; + const isCompleted = values[checkId]; + const updateLink = updateLinks?.[checkId]; + const outlineUrl = updateLinks.outline; + return ( +
+ +
+ +
+
+ ); + })} +
+ + )} +
+ ); +}; + +ChecklistSection.defaultProps = { + updateLinks: {}, + data: {}, +}; + +ChecklistSection.propTypes = { + dataHeading: PropTypes.string.isRequired, + data: PropTypes.oneOfType([ + PropTypes.shape({ + assignments: PropTypes.shape({ + totalNumber: PropTypes.number, + totalVisible: PropTypes.number, + numWithDatesBeforeEnd: PropTypes.number, + numWithDates: PropTypes.number, + numWithDatesAfterStart: PropTypes.number, + }), + dates: PropTypes.shape({ + hasStartDate: PropTypes.bool, + hasEndDate: PropTypes.bool, + }), + updates: PropTypes.shape({ + hasUpdate: PropTypes.bool, + }), + certificates: PropTypes.shape({ + isEnabled: PropTypes.bool, + isActivated: PropTypes.bool, + hasCertificate: PropTypes.bool, + }), + grades: PropTypes.shape({ + sumOfWeights: PropTypes.number, + }), + is_self_paced: PropTypes.bool, + }).isRequired, + PropTypes.shape({ + assignments: PropTypes.shape({ + totalNumber: PropTypes.number, + totalVisible: PropTypes.number, + /* eslint-disable react/forbid-prop-types */ + assignmentsWithDatesBeforeStart: PropTypes.array, + assignmentsWithDatesAfterEnd: PropTypes.array, + assignmentsWithOraDatesBeforeStart: PropTypes.array, + assignmentsWithOraDatesAfterEnd: PropTypes.array, + /* eslint-enable react/forbid-prop-types */ + }), + dates: PropTypes.shape({ + hasStartDate: PropTypes.bool, + hasEndDate: PropTypes.bool, + }), + updates: PropTypes.shape({ + hasUpdate: PropTypes.bool, + }), + certificates: PropTypes.shape({ + isEnabled: PropTypes.bool, + isActivated: PropTypes.bool, + hasCertificate: PropTypes.bool, + }), + grades: PropTypes.shape({ + hasGradingPolicy: PropTypes.bool, + sumOfWeights: PropTypes.number, + }), + proctoring: PropTypes.shape({ + needsProctoringEscalationEmail: PropTypes.bool, + hasProctoringEscalation_email: PropTypes.bool, + }), + isSelfPaced: PropTypes.bool, + }).isRequired, + ]), + idPrefix: PropTypes.string.isRequired, + isLoading: PropTypes.bool.isRequired, + updateLinks: PropTypes.shape({ + welcomeMessage: PropTypes.string, + gradingPolicy: PropTypes.string, + certificate: PropTypes.string, + courseDates: PropTypes.string, + proctoringEmail: PropTypes.string, + outline: PropTypes.string, + }), +}; + +export default injectIntl(ChecklistSection); diff --git a/src/course-checklist/ChecklistSection/ChecklistSection.scss b/src/course-checklist/ChecklistSection/ChecklistSection.scss new file mode 100644 index 000000000..f06797013 --- /dev/null +++ b/src/course-checklist/ChecklistSection/ChecklistSection.scss @@ -0,0 +1,22 @@ +.assignment-list-item { + list-style: none; + display: inline-block; + + &::after { + content: ","; + } + + &:last-child { + &::after { content: ""; } + } +} + +.assignment-list { + display: inline; + padding-inline-start: map-get($spacers, 1); +} + +//complete checklist item style +.checklist-item-complete { + box-shadow: -5px 0 0 0 $success-500; +} diff --git a/src/course-checklist/ChecklistSection/ChecklistSection.test.jsx b/src/course-checklist/ChecklistSection/ChecklistSection.test.jsx new file mode 100644 index 000000000..1c8317c90 --- /dev/null +++ b/src/course-checklist/ChecklistSection/ChecklistSection.test.jsx @@ -0,0 +1,255 @@ +/* eslint-disable */ +import { + render, + within, + screen, +} from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { camelCaseObject, initializeMockApp } from '@edx/frontend-platform'; +import { AppProvider } from '@edx/frontend-platform/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; + +import initializeStore from '../../store'; +import { initialState,generateCourseLaunchData } from '../factories/mockApiResponses'; +import messages from './messages'; +import ChecklistSection from './index'; +import { checklistItems } from './utils/courseChecklistData'; +import getUpdateLinks from '../utils'; + +const testData = camelCaseObject(generateCourseLaunchData()); + + +const defaultProps = { + data: testData, + dataHeading: 'Test checklist', + idPrefix: 'launchChecklist', + updateLinks: getUpdateLinks('courseId'), + isLoading: false, +}; + +const testChecklistData = checklistItems[defaultProps.idPrefix]; + +const completedItemIds = ['welcomeMessage', 'courseDates'] + +const renderComponent = (props) => { + render( + + + + + , + ); +}; + +let store; + +describe('ChecklistSection', () => { + beforeEach(() => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: false, + roles: [], + }, + }); + store = initializeStore(initialState); + }); + + it('a heading using the dataHeading prop', () => { + renderComponent(defaultProps); + + expect(screen.getByText(defaultProps.dataHeading)).toBeVisible(); + }); + + it('completion count text', () => { + renderComponent(defaultProps); + const completionText = `${completedItemIds.length}/6 completed`; + expect(screen.getByTestId('completion-subheader').textContent).toEqual(completionText); + }); + + it('a loading spinner when isLoading prop is true', () => { + renderComponent({ ...defaultProps, isLoading: true }); + + const completionSubheader = screen.queryByTestId('completion-subheader'); + expect(completionSubheader).toBeNull(); + + const loadingSpinner = screen.getByTestId('loading-spinner'); + expect(loadingSpinner).toBeVisible(); + }); + + it('the correct number of checks', () => { + renderComponent(defaultProps); + + const listItems = screen.getAllByTestId('checklist-item', { exact: false }); + expect(listItems).toHaveLength(6); + }); + + it('welcomeMessage comment section should be null', () => { + renderComponent(defaultProps); + + const comment = screen.getByTestId('comment-section-welcomeMessage'); + expect(comment.children).toHaveLength(0); + }); + + it('certificate comment section should be null', () => { + renderComponent(defaultProps); + + const comment = screen.getByTestId('comment-section-certificate'); + expect(comment.children).toHaveLength(0); + }); + + it('courseDates comment section should be null', () => { + renderComponent(defaultProps); + + const comment = screen.getByTestId('comment-section-courseDates'); + expect(comment.children).toHaveLength(0); + }); + + it('proctoringEmail comment section should be null', () => { + renderComponent(defaultProps); + + const comment = screen.getByTestId('comment-section-proctoringEmail'); + expect(comment.children).toHaveLength(0); + }); + + describe('gradingPolicy comment section', () => { + it('should be null if sum of weights is equal to 1', () => { + const props = { + ...defaultProps, + data: { + ...defaultProps.data, + grades: { + ...defaultProps.data.grades, + sumOfWeights: 1, + } + }, + }; + renderComponent(props); + + const comment = screen.getByTestId('comment-section-gradingPolicy'); + expect(comment.children).toHaveLength(0); + }); + + it('should have comment section', () => { + renderComponent(defaultProps); + + const comment = screen.getByTestId('comment-section-gradingPolicy'); + expect(comment.children).toHaveLength(1); + + expect(screen.getByText( + 'Your current grading policy adds up to', + { exact: false }, + )).toBeVisible(); + }); + }); + + describe('assignmentDeadlines comment section', () => { + it('should be null if assignments with dates before start and after end are empty', () => { + const props = { + ...defaultProps, + data: { + ...defaultProps.data, + assignments: { + ...defaultProps.data.assignments, + assignmentsWithDatesAfterEnd: [], + assignmentsWithOraDatesBeforeStart: [], + } + }, + }; + renderComponent(props); + + const comment = screen.getByTestId('comment-section-assignmentDeadlines'); + expect(comment.children).toHaveLength(0); + }); + + it('should have comment section', () => { + renderComponent(defaultProps); + + const comment = screen.getByTestId('comment-section-assignmentDeadlines'); + const assigmentLinks = within(comment).getAllByRole('link'); + + expect(comment.children).toHaveLength(1); + + expect(screen.getByText( + messages.assignmentDeadlinesComment.defaultMessage, + { exact: false }, + )).toBeVisible(); + + expect(assigmentLinks).toHaveLength(2); + + expect(assigmentLinks[0].textContent).toEqual('Subsection'); + + expect(assigmentLinks[1].textContent).toEqual('ORA subsection'); + }); + }); +}); + +testChecklistData.forEach((check) => { + describe(`check with id '${check.id}'`, () => { + let checkItem; + beforeEach(() => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: false, + roles: [], + }, + }); + store = initializeStore(initialState); + renderComponent(defaultProps); + checkItem = screen.getAllByTestId(`checklist-item-${check.id}`); + }); + + it('renders', () => { + expect(checkItem).toHaveLength(1); + }); + + it('has correct icon', () => { + const icon = screen.getAllByTestId(`icon-${check.id}`) + + expect(icon).toHaveLength(1); + + const { queryByTestId } = within(icon[0]); + if (completedItemIds.includes(check.id)) { + expect(queryByTestId('completed-icon')).not.toBeNull(); + } else { + expect(queryByTestId('uncompleted-icon')).not.toBeNull(); + } + }); + + it('has correct short description', () => { + const { getByText } = within(checkItem[0]); + const shortDescription = messages[`${check.id}ShortDescription`].defaultMessage; + expect(getByText(shortDescription)).toBeVisible(); + }); + + it('has correct long description', () => { + const { getByText } = within(checkItem[0]); + const longDescription = messages[`${check.id}LongDescription`].defaultMessage; + expect(getByText(longDescription)).toBeVisible(); + }); + + describe('has correct link', () => { + const links = getUpdateLinks('courseId') + const shouldShowLink = Object.keys(links).includes(check.id); + + if (shouldShowLink) { + it('with a Hyperlink', () => { + const { getByRole, getByText } = within(checkItem[0]); + + expect(getByText('Update')).toBeVisible(); + + expect(getByRole('link').href).toMatch(links[check.id]); + }); + } else { + it('without a Hyperlink', () => { + const { queryByText } = within(checkItem[0]); + + expect(queryByText('Update')).toBeNull(); + }); + } + }); + }); +}); diff --git a/src/course-checklist/ChecklistSection/hooks.jsx b/src/course-checklist/ChecklistSection/hooks.jsx new file mode 100644 index 000000000..8032e8bb9 --- /dev/null +++ b/src/course-checklist/ChecklistSection/hooks.jsx @@ -0,0 +1,71 @@ +import { useEffect, useState } from 'react'; +import { FormattedMessage } from '@edx/frontend-platform/i18n'; + +import messages from './messages'; +import getFilteredChecklist from './utils/getFilteredChecklist'; +import getValidatedValue from './utils/getValidatedValue'; + +export const useChecklistState = ({ data, dataList }) => { + const [checklistState, setChecklistState] = useState({ + checks: [], + totalCompletedChecks: 0, + values: {}, + }); + + const updateChecklistState = () => { + if (Object.keys(data).length > 0) { + const { isSelfPaced } = data; + const hasCertificatesEnabled = data.certificates && data.certificates.isEnabled; + const hasHighlightsEnabled = data.sections && data.sections.highlightsEnabled; + const needsProctoringEscalationEmail = ( + data.proctoring && data.proctoring.needsProctoringEscalationEmail + ); + const checks = getFilteredChecklist( + dataList, + isSelfPaced, + hasCertificatesEnabled, + hasHighlightsEnabled, + needsProctoringEscalationEmail, + ); + + const values = {}; + let totalCompletedChecks = 0; + + checks.forEach((check) => { + const value = getValidatedValue(data, check.id); + + if (value) { + totalCompletedChecks += 1; + } + + values[check.id] = value; + }); + + setChecklistState({ + checks, + totalCompletedChecks, + values, + }); + } + }; + + useEffect(() => { + updateChecklistState(); + }, [data]); + + return { + checklistState, + setChecklistState, + }; +}; + +export const getCompletionCount = (checks, totalCompletedChecks) => { + const totalChecks = Object.values(checks).length; + + return ( + + ); +}; diff --git a/src/course-checklist/ChecklistSection/index.js b/src/course-checklist/ChecklistSection/index.js new file mode 100644 index 000000000..fa1df413b --- /dev/null +++ b/src/course-checklist/ChecklistSection/index.js @@ -0,0 +1,3 @@ +import ChecklistSection from './ChecklistSection'; + +export default ChecklistSection; diff --git a/src/course-checklist/ChecklistSection/messages.js b/src/course-checklist/ChecklistSection/messages.js new file mode 100644 index 000000000..362f568d1 --- /dev/null +++ b/src/course-checklist/ChecklistSection/messages.js @@ -0,0 +1,146 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + welcomeMessageShortDescription: { + id: 'welcomeMessageShortDescription', + defaultMessage: 'Add a welcome message', + description: 'Label for a section that describes a welcome message for a course', + }, + welcomeMessageLongDescription: { + id: 'welcomeMessageLongDescription', + defaultMessage: 'Personally welcome learners into your course and prepare learners for a positive course experience.', + description: 'Description for a section that prompts a user to enter a welcome message for a course', + }, + gradingPolicyShortDescription: { + id: 'gradingPolicyShortDescription', + defaultMessage: 'Create your course grading policy', + description: 'Label for a section that describes a grading policy for a course', + }, + gradingPolicyLongDescription: { + id: 'gradingPolicyLongDescription', + defaultMessage: 'Establish your grading policy, including assignment types and passing score. All assignments add up to 100%.', + description: 'Description for a section that prompts a user to enter a grading policy for a course', + }, + gradingPolicyComment: { + id: 'gradingPolicyComment', + defaultMessage: 'Your current grading policy adds up to {percent}%.', + description: 'Description for a section that displays a course\'s grading policy total', + }, + certificateShortDescription: { + id: 'certificateShortDescription', + defaultMessage: 'Enable your certificate', + description: 'Label for a section that describes a certificate for completing a course', + }, + certificateLongDescription: { + id: 'certificateLongDescription', + defaultMessage: 'Make sure that all text is correct, signatures have been uploaded, and the certificate has been activated.', + description: 'Description for a section that prompts a user to create a course completion certificate', + }, + courseDatesShortDescription: { + id: 'courseDatesShortDescription', + defaultMessage: 'Set important course dates', + description: 'Label for a section that describes a certificate for completing a course', + }, + courseDatesLongDescription: { + id: 'courseDatesLongDescription', + defaultMessage: 'Establish your course schedule, including when the course starts and ends.', + description: 'Description for a section that prompts a user to set up a course schedule', + }, + assignmentDeadlinesShortDescription: { + id: 'assignmentDeadlinesShortDescription', + defaultMessage: 'Validate assignment deadlines', + description: 'Label for a section that describes course assignment deadlines', + }, + assignmentDeadlinesLongDescription: { + id: 'assignmentDeadlinesLongDescription', + defaultMessage: 'Ensure all assignment deadlines are between course start and end dates.', + description: 'Description for a section that prompts a user to enter course assignment deadlines', + }, + assignmentDeadlinesComment: { + id: 'assignmentDeadlinesComment', + defaultMessage: 'The following assignments have deadlines that do not fall between course start and end date:', + description: 'Description for a section that displays which assignments are outside of a course\'s start and end date', + }, + videoDurationShortDescription: { + id: 'videoDurationShortDescription', + defaultMessage: 'Check video duration', + description: 'Label for a section that describes video durations', + }, + videoDurationLongDescription: { + id: 'videoDurationLongDescription', + defaultMessage: 'Learners engage best with short videos followed by opportunities to practice. Ensure that 80% or more of course videos are less than 10 minutes long.', + description: 'Description for a section that prompts a user to follow best practices for video length', + }, + mobileFriendlyVideoShortDescription: { + id: 'mobileFriendlyVideoShortDescription', + defaultMessage: 'Create mobile-friendly video', + description: 'Label for a section that describes mobile friendly videos', + }, + mobileFriendlyVideoLongDescription: { + id: 'mobileFriendlyVideoLongDescription', + defaultMessage: 'Mobile-friendly videos can be viewed across all supported devices. Ensure that at least 90% of course videos are mobile friendly by uploading course videos to the edX video pipeline.', + description: 'Description for a section that prompts a user to follow best practices for mobile friendly videos', + }, + diverseSequencesShortDescription: { + id: 'diverseSequencesShortDescription', + defaultMessage: 'Build diverse learning sequences', + description: 'Label for a section that describes diverse sequences of educational content', + }, + diverseSequencesLongDescription: { + id: 'diverseSequencesLongDescription', + defaultMessage: 'Research shows that a diverse content experience drives learner engagement. We recommend that 80% or more of your learning sequences or subsections include multiple content types (such as video, discussion, or problem).', + description: 'Description for a section that prompts a user to follow best practices diverse sequences of educational content', + }, + weeklyHighlightsShortDescription: { + id: 'weeklyHighlightsShortDescription', + defaultMessage: 'Set weekly highlights', + description: 'Label for a section that describes weekly highlights', + }, + weeklyHighlightsLongDescription: { + id: 'weeklyHighlightsLongDescription', + defaultMessage: 'Enable and specify weekly highlights to keep learners engaged and on track in your course.', + description: 'Description for a section that prompts a user to follow best practices for course weekly highlights', + }, + unitDepthShortDescription: { + id: 'unitDepthShortDescription', + defaultMessage: 'Manage unit depth', + description: 'Label for a section that describes course unit depth', + }, + unitDepthLongDescription: { + id: 'unitDepthLongDescription', + defaultMessage: 'Breaking up course content into manageable pieces promotes learner engagement. We recommend units contain no more than three components.', + description: 'Description for a section that prompts a user to follow best practices for course unit depth', + }, + proctoringEmailShortDescription: { + id: 'proctoringEmailShortDescription', + defaultMessage: 'Add a proctortrack escalation email', + description: 'Label for a section that describes proctoring escalation email', + }, + proctoringEmailLongDescription: { + id: 'proctoringEmailLongDescription', + defaultMessage: 'Courses using Proctortrack require an escalation email. Ensure learners and Support can contact your course team regarding proctoring issues (e.g. appeals, exam resets, etc).', + description: 'Description for a section that prompts the user to add a Proctortrack escalation email for the course', + }, + updateLinkLabel: { + id: 'updateLinkLabel', + defaultMessage: 'Update', + description: 'Label for a link that takes the user to a page where they can update settings', + }, + completionCountLabel: { + id: 'completionCountLabel', + defaultMessage: '{completed}/{total} completed', + description: 'Label that describes how many tasks have been completed out of a total number of tasks', + }, + completedItemLabel: { + id: 'completedItemLabel', + defaultMessage: 'completed', + description: 'Label that describes a completed task', + }, + uncompletedItemLabel: { + id: 'uncompletedItemLabel', + defaultMessage: 'uncompleted', + description: 'Label that describes an uncompleted task', + }, +}); + +export default messages; diff --git a/src/course-checklist/ChecklistSection/utils/courseChecklistData.jsx b/src/course-checklist/ChecklistSection/utils/courseChecklistData.jsx new file mode 100644 index 000000000..c59d1f83b --- /dev/null +++ b/src/course-checklist/ChecklistSection/utils/courseChecklistData.jsx @@ -0,0 +1,56 @@ +export const filters = { + ALL: 'ALL', + SELF_PACED: 'SELF_PACED', + INSTRUCTOR_PACED: 'INSTRUCTOR_PACED', +}; + +export const checklistItems = { + launchChecklist: [ + { + id: 'welcomeMessage', + pacingTypeFilter: filters.ALL, + }, + { + id: 'gradingPolicy', + pacingTypeFilter: filters.ALL, + }, + { + id: 'certificate', + pacingTypeFilter: filters.ALL, + }, + { + id: 'courseDates', + pacingTypeFilter: filters.ALL, + }, + { + id: 'assignmentDeadlines', + pacingTypeFilter: filters.INSTRUCTOR_PACED, + }, + { + id: 'proctoringEmail', + pacingTypeFilter: filters.ALL, + }, + ], + bestPracticesChecklist: [ + { + id: 'videoDuration', + pacingTypeFilter: filters.ALL, + }, + { + id: 'mobileFriendlyVideo', + pacingTypeFilter: filters.ALL, + }, + { + id: 'diverseSequences', + pacingTypeFilter: filters.ALL, + }, + { + id: 'weeklyHighlights', + pacingTypeFilter: filters.SELF_PACED, + }, + { + id: 'unitDepth', + pacingTypeFilter: filters.ALL, + }, + ], +}; diff --git a/src/course-checklist/ChecklistSection/utils/courseChecklistValidators.js b/src/course-checklist/ChecklistSection/utils/courseChecklistValidators.js new file mode 100644 index 000000000..583ca4bac --- /dev/null +++ b/src/course-checklist/ChecklistSection/utils/courseChecklistValidators.js @@ -0,0 +1,76 @@ +export const hasWelcomeMessage = updates => ( + updates.hasUpdate +); + +export const hasGradingPolicy = grades => ( + grades.hasGradingPolicy + && parseFloat(grades.sumOfWeights.toPrecision(2), 10) === 1.0 +); + +export const hasCertificate = certificates => ( + certificates.isActivated && certificates.hasCertificate +); + +export const hasDates = dates => ( + dates.hasStartDate && dates.hasEndDate +); + +export const hasAssignmentDeadlines = (assignments, dates) => { + if (!hasDates(dates)) { + return false; + } if (assignments.totalNumber === 0) { + return false; + } if (assignments.assignmentsWithDatesBeforeStart.length > 0) { + return false; + } if (assignments.assignmentsWithDatesAfterEnd.length > 0) { + return false; + } if (assignments.assignmentsWithOraDatesBeforeStart.length > 0) { + return false; + } if (assignments.assignmentsWithOraDatesAfterEnd.length > 0) { + return false; + } + + return true; +}; + +export const hasShortVideoDuration = (videos) => { + if (videos.totalNumber === 0) { + return true; + } if (videos.totalNumber > 0 && videos.durations.median <= 600) { + return true; + } + + return false; +}; + +export const hasMobileFriendlyVideos = (videos) => { + if (videos.totalNumber === 0) { + return true; + } if (videos.totalNumber > 0 && (videos.numMobileEncoded / videos.totalNumber) >= 0.9) { + return true; + } + + return false; +}; + +export const hasDiverseSequences = (subsections) => { + if (subsections.totalVisible === 0) { + return false; + } if (subsections.totalVisible > 0) { + return ((subsections.numWithOneBlockType / subsections.totalVisible) < 0.2); + } + + return false; +}; + +export const hasWeeklyHighlights = sections => ( + sections.highlightsActiveForCourse && sections.highlightsEnabled +); + +export const hasShortUnitDepth = units => ( + units.numBlocks.median <= 3 +); + +export const hasProctoringEscalationEmail = proctoring => ( + proctoring.hasProctoringEscalationEmail +); diff --git a/src/course-checklist/ChecklistSection/utils/courseChecklistValidators.test.js b/src/course-checklist/ChecklistSection/utils/courseChecklistValidators.test.js new file mode 100644 index 000000000..401475bc2 --- /dev/null +++ b/src/course-checklist/ChecklistSection/utils/courseChecklistValidators.test.js @@ -0,0 +1,297 @@ +import * as validators from './courseChecklistValidators'; + +describe('courseCheckValidators utility functions', () => { + describe('hasWelcomeMessage', () => { + it('returns true when course run has an update', () => { + expect(validators.hasWelcomeMessage({ hasUpdate: true })).toEqual(true); + }); + + it('returns false when course run does not have an update', () => { + expect(validators.hasWelcomeMessage({ hasUpdate: false })).toEqual(false); + }); + }); + + describe('hasGradingPolicy', () => { + it('returns true when sum of weights is 1', () => { + expect(validators.hasGradingPolicy( + { hasGradingPolicy: true, sumOfWeights: 1 }, + )).toEqual(true); + }); + + it('returns true when sum of weights is not 1 due to floating point approximation (1.00004)', () => { + expect(validators.hasGradingPolicy( + { hasGradingPolicy: true, sumOfWeights: 1.00004 }, + )).toEqual(true); + }); + + it('returns false when sum of weights is not 1', () => { + expect(validators.hasGradingPolicy( + { hasGradingPolicy: true, sumOfWeights: 2 }, + )).toEqual(false); + }); + + it('returns true when hasGradingPolicy is true', () => { + expect(validators.hasGradingPolicy( + { hasGradingPolicy: true, sumOfWeights: 1 }, + )).toEqual(true); + }); + + it('returns false when hasGradingPolicy is false', () => { + expect(validators.hasGradingPolicy( + { hasGradingPolicy: false, sumOfWeights: 1 }, + )).toEqual(false); + }); + }); + + describe('hasCertificate', () => { + it('returns true when certificates are activated and course run has a certificate', () => { + expect(validators.hasCertificate({ isActivated: true, hasCertificate: true })) + .toEqual(true); + }); + + it('returns false when certificates are not activated and course run has a certificate', () => { + expect(validators.hasCertificate({ isActivated: false, hasCertificate: true })) + .toEqual(false); + }); + + it('returns false when certificates are activated and course run does not have a certificate', () => { + expect(validators.hasCertificate({ isActivated: true, hasCertificate: false })) + .toEqual(false); + }); + + it('returns false when certificates are not activated and course run does not have a certificate', () => { + expect(validators.hasCertificate({ isActivated: false, hasCertificate: false })) + .toEqual(false); + }); + }); + + describe('hasDates', () => { + it('returns true when course run has start date and end date', () => { + expect(validators.hasDates({ hasStartDate: true, hasEndDate: true })).toEqual(true); + }); + + it('returns false when course run has no start date and end date', () => { + expect(validators.hasDates({ hasStartDate: false, hasEndDate: true })).toEqual(false); + }); + + it('returns true when course run has start date and no end date', () => { + expect(validators.hasDates({ hasStartDate: true, hasEndDate: false })).toEqual(false); + }); + + it('returns true when course run has no start date and no end date', () => { + expect(validators.hasDates({ hasStartDate: false, hasEndDate: false })).toEqual(false); + }); + }); + + describe('hasAssignmentDeadlines', () => { + it('returns true when a course run has start and end date and all assignments are within range', () => { + expect(validators.hasAssignmentDeadlines( + { + assignmentsWithDatesBeforeStart: 0, + assignmentsWithDatesAfterEnd: 0, + assignmentsWithOraDatesAfterEnd: 0, + assignmentsWithOraDatesBeforeStart: 0, + }, + { + hasStartDate: true, + hasEndDate: true, + }, + )).toEqual(true); + }); + + it('returns false when a course run has no start and no end date', () => { + expect(validators.hasAssignmentDeadlines( + {}, + { + hasStartDate: false, + hasEndDate: false, + }, + )).toEqual(false); + }); + + it('returns false when a course has start and end date and no assignments', () => { + expect(validators.hasAssignmentDeadlines( + { + totalNumber: 0, + }, + { + hasStartDate: true, + hasEndDate: true, + }, + )).toEqual(false); + }); + + it('returns false when a course run has start and end date and assignments before start', () => { + expect(validators.hasAssignmentDeadlines( + { + assignmentsWithDatesBeforeStart: ['test'], + assignmentsWithDatesAfterEnd: 0, + assignmentsWithOraDatesAfterEnd: 0, + assignmentsWithOraDatesBeforeStart: 0, + }, + { + hasStartDate: true, + hasEndDate: true, + }, + )).toEqual(false); + }); + + it('returns false when a course run has start and end date and assignments after end', () => { + expect(validators.hasAssignmentDeadlines( + { + assignmentsWithDatesBeforeStart: 0, + assignmentsWithDatesAfterEnd: ['test'], + assignmentsWithOraDatesAfterEnd: 0, + assignmentsWithOraDatesBeforeStart: 0, + }, + { + hasStartDate: true, + hasEndDate: true, + }, + )).toEqual(false); + }); + }); + + it( + 'returns false when a course run has start and end date and an ora with a date before start', + () => { + expect(validators.hasAssignmentDeadlines( + { + assignmentsWithDatesBeforeStart: 0, + assignmentsWithDatesAfterEnd: 0, + assignmentsWithOraDatesAfterEnd: 0, + assignmentsWithOraDatesBeforeStart: ['test'], + }, + { + hasStartDate: true, + hasEndDate: true, + }, + )).toEqual(false); + }, + ); + + it( + 'returns false when a course run has start and end date and an ora with a date after end', + () => { + expect(validators.hasAssignmentDeadlines( + { + assignmentsWithDatesBeforeStart: 0, + assignmentsWithDatesAfterEnd: 0, + assignmentsWithOraDatesAfterEnd: ['test'], + assignmentsWithOraDatesBeforeStart: 0, + }, + { + hasStartDate: true, + hasEndDate: true, + }, + )).toEqual(false); + }, + ); + + describe('hasShortVideoDuration', () => { + it('returns true if course run has no videos', () => { + expect(validators.hasShortVideoDuration({ totalNumber: 0 })).toEqual(true); + }); + + it('returns true if course run videos have a median duration <= to 600', () => { + expect(validators.hasShortVideoDuration({ totalNumber: 1, durations: { median: 100 } })) + .toEqual(true); + }); + + it('returns true if course run videos have a median duration > to 600', () => { + expect(validators.hasShortVideoDuration({ totalNumber: 10, durations: { median: 700 } })) + .toEqual(false); + }); + }); + + describe('hasMobileFriendlyVideos', () => { + it('returns true if course run has no videos', () => { + expect(validators.hasMobileFriendlyVideos({ totalNumber: 0 })).toEqual(true); + }); + + it('returns true if course run videos are >= 90% mobile friendly', () => { + expect(validators.hasMobileFriendlyVideos({ totalNumber: 10, numMobileEncoded: 9 })) + .toEqual(true); + }); + + it('returns true if course run videos are < 90% mobile friendly', () => { + expect(validators.hasMobileFriendlyVideos({ totalNumber: 10, numMobileEncoded: 8 })) + .toEqual(false); + }); + }); + + describe('hasDiverseSequences', () => { + it('returns true if < 20% of visible subsections have more than one block type', () => { + expect(validators.hasDiverseSequences({ totalVisible: 10, numWithOneBlockType: 1 })) + .toEqual(true); + }); + + it('returns false if no visible subsections', () => { + expect(validators.hasDiverseSequences({ totalVisible: 0 })).toEqual(false); + }); + + it('returns false if >= 20% of visible subsections have more than one block type', () => { + expect(validators.hasDiverseSequences({ totalVisible: 10, numWithOneBlockType: 3 })) + .toEqual(false); + }); + + it('return false if < 0 visible subsections', () => { + expect(validators.hasDiverseSequences({ totalVisible: -1, numWithOneBlockType: 1 })) + .toEqual(false); + }); + }); + + describe('hasWeeklyHighlights', () => { + it('returns true when course run has highlights enabled', () => { + const data = { highlightsActiveForCourse: true, highlightsEnabled: true }; + expect(validators.hasWeeklyHighlights(data)).toEqual(true); + }); + + it('returns false when course run has highlights enabled', () => { + const data = { highlightsActiveForCourse: false, highlightsEnabled: false }; + expect(validators.hasWeeklyHighlights(data)).toEqual(false); + + data.highlightsEnabled = true; + data.highlightsActiveForCourse = false; + expect(validators.hasWeeklyHighlights(data)).toEqual(false); + + data.highlightsEnabled = false; + data.highlightsActiveForCourse = true; + expect(validators.hasWeeklyHighlights(data)).toEqual(false); + }); + }); + + describe('hasShortUnitDepth', () => { + it('returns true when course run has median number of blocks <= 3', () => { + const units = { + numBlocks: { + median: 3, + }, + }; + + expect(validators.hasShortUnitDepth(units)).toEqual(true); + }); + + it('returns false when course run has median number of blocks > 3', () => { + const units = { + numBlocks: { + median: 4, + }, + }; + + expect(validators.hasShortUnitDepth(units)).toEqual(false); + }); + }); + + describe('hasProctoringEscalationEmail', () => { + it('returns true when the course has a proctoring escalation email', () => { + const proctoring = { hasProctoringEscalationEmail: true }; + expect(validators.hasProctoringEscalationEmail(proctoring)).toEqual(true); + }); + + it('returns false when the course does not have a proctoring escalation email', () => { + const proctoring = { hasProctoringEscalationEmail: false }; + expect(validators.hasProctoringEscalationEmail(proctoring)).toEqual(false); + }); + }); +}); diff --git a/src/course-checklist/ChecklistSection/utils/getFilteredChecklist.js b/src/course-checklist/ChecklistSection/utils/getFilteredChecklist.js new file mode 100644 index 000000000..e85846240 --- /dev/null +++ b/src/course-checklist/ChecklistSection/utils/getFilteredChecklist.js @@ -0,0 +1,32 @@ +import { filters } from './courseChecklistData'; + +const getFilteredChecklist = ( + checklist, + isSelfPaced, + hasCertificatesEnabled, + hasHighlightsEnabled, + needsProctoringEscalationEmail, +) => { + let filteredCheckList; + + if (isSelfPaced) { + filteredCheckList = checklist.filter(data => data.pacingTypeFilter === filters.ALL + || data.pacingTypeFilter === filters.SELF_PACED); + } else { + filteredCheckList = checklist.filter(data => data.pacingTypeFilter === filters.ALL + || data.pacingTypeFilter === filters.INSTRUCTOR_PACED); + } + + filteredCheckList = filteredCheckList.filter(data => data.id !== 'certificate' + || hasCertificatesEnabled); + + filteredCheckList = filteredCheckList.filter(data => data.id !== 'weeklyHighlights' + || hasHighlightsEnabled); + + filteredCheckList = filteredCheckList.filter(data => data.id !== 'proctoringEmail' + || needsProctoringEscalationEmail); + + return filteredCheckList; +}; + +export default getFilteredChecklist; diff --git a/src/course-checklist/ChecklistSection/utils/getFilteredChecklist.test.js b/src/course-checklist/ChecklistSection/utils/getFilteredChecklist.test.js new file mode 100644 index 000000000..bffcf157f --- /dev/null +++ b/src/course-checklist/ChecklistSection/utils/getFilteredChecklist.test.js @@ -0,0 +1,149 @@ +import { filters } from './courseChecklistData'; +import getFilteredChecklist from './getFilteredChecklist'; + +const checklist = [ + { + id: 'welcomeMessage', + pacingTypeFilter: filters.ALL, + }, + { + id: 'gradingPolicy', + pacingTypeFilter: filters.ALL, + }, + { + id: 'certificate', + pacingTypeFilter: filters.ALL, + }, + { + id: 'courseDates', + pacingTypeFilter: filters.ALL, + }, + { + id: 'assignmentDeadlines', + pacingTypeFilter: filters.INSTRUCTOR_PACED, + }, + { + id: 'weeklyHighlights', + pacingTypeFilter: filters.SELF_PACED, + }, + { + id: 'proctoringEmail', + pacingTypeFilter: filters.ALL, + }, +]; + +let courseData; +describe('getFilteredChecklist utility function', () => { + beforeEach(() => { + courseData = { + isSelfPaced: true, + hasCertificatesEnabled: true, + hasHighlightsEnabled: true, + needsProctoringEscalationEmail: true, + }; + }); + it('returns only checklist items with filters ALL and SELF_PACED when isSelfPaced is true', () => { + const filteredChecklist = getFilteredChecklist( + checklist, + courseData.isSelfPaced, + courseData.hasCertificatesEnabled, + courseData.hasHighlightsEnabled, + courseData.needsProctoringEscalationEmail, + ); + + filteredChecklist.forEach((( + item => expect(item.pacingTypeFilter === filters.ALL + || item.pacingTypeFilter === filters.SELF_PACED) + ))); + + expect(filteredChecklist.filter(item => item.pacingTypeFilter === filters.ALL).length) + .toEqual(checklist.filter(item => item.pacingTypeFilter === filters.ALL).length); + expect(filteredChecklist.filter(item => item.pacingTypeFilter === filters.SELF_PACED).length) + .toEqual(checklist.filter(item => item.pacingTypeFilter === filters.SELF_PACED).length); + }); + + it('returns only checklist items with filters ALL and INSTRUCTOR_PACED when isSelfPaced is false', () => { + courseData.isSelfPaced = false; + const filteredChecklist = getFilteredChecklist( + checklist, + courseData.isSelfPaced, + courseData.hasCertificatesEnabled, + courseData.hasHighlightsEnabled, + courseData.needsProctoringEscalationEmail, + ); + + filteredChecklist.forEach((( + item => expect(item.pacingTypeFilter === filters.ALL + || item.pacingTypeFilter === filters.INSTRUCTOR_PACED) + ))); + + expect(filteredChecklist.filter(item => item.pacingTypeFilter === filters.ALL).length) + .toEqual(checklist.filter(item => item.pacingTypeFilter === filters.ALL).length); + expect(filteredChecklist + .filter(item => item.pacingTypeFilter === filters.INSTRUCTOR_PACED).length) + .toEqual(checklist.filter(item => item.pacingTypeFilter === filters.INSTRUCTOR_PACED).length); + }); + + it('excludes certificates when they are disabled', () => { + let filteredChecklist = getFilteredChecklist( + checklist, + courseData.isSelfPaced, + courseData.hasCertificatesEnabled, + courseData.hasHighlightsEnabled, + courseData.needsProctoringEscalationEmail, + ); + expect(checklist.filter(item => item.id === 'certificate').length).toEqual(1); + + courseData.hasCertificatesEnabled = false; + filteredChecklist = getFilteredChecklist( + checklist, + courseData.isSelfPaced, + courseData.hasCertificatesEnabled, + courseData.hasHighlightsEnabled, + courseData.needsProctoringEscalationEmail, + ); + expect(filteredChecklist.filter(item => item.id === 'certificate').length).toEqual(0); + }); + + it('excludes weekly highlights when they are disabled', () => { + let filteredChecklist = getFilteredChecklist( + checklist, + courseData.isSelfPaced, + courseData.hasCertificatesEnabled, + courseData.hasHighlightsEnabled, + courseData.needsProctoringEscalationEmail, + ); + expect(filteredChecklist.filter(item => item.id === 'weeklyHighlights').length).toEqual(1); + + courseData.hasHighlightsEnabled = false; + filteredChecklist = getFilteredChecklist( + checklist, + courseData.isSelfPaced, + courseData.hasCertificatesEnabled, + courseData.hasHighlightsEnabled, + courseData.needsProctoringEscalationEmail, + ); + expect(filteredChecklist.filter(item => item.id === 'weeklyHighlights').length).toEqual(0); + }); + + it('excludes proctoring escalation email when not needed', () => { + let filteredChecklist = getFilteredChecklist( + checklist, + courseData.isSelfPaced, + courseData.hasCertificatesEnabled, + courseData.hasHighlightsEnabled, + courseData.needsProctoringEscalationEmail, + ); + expect(filteredChecklist.filter(item => item.id === 'proctoringEmail').length).toEqual(1); + + courseData.needsProctoringEscalationEmail = false; + filteredChecklist = getFilteredChecklist( + checklist, + courseData.isSelfPaced, + courseData.hasCertificatesEnabled, + courseData.hasHighlightsEnabled, + courseData.needsProctoringEscalationEmail, + ); + expect(filteredChecklist.filter(item => item.id === 'proctoringEmail').length).toEqual(0); + }); +}); diff --git a/src/course-checklist/ChecklistSection/utils/getValidatedValue.js b/src/course-checklist/ChecklistSection/utils/getValidatedValue.js new file mode 100644 index 000000000..695ac602e --- /dev/null +++ b/src/course-checklist/ChecklistSection/utils/getValidatedValue.js @@ -0,0 +1,32 @@ +import * as healthValidators from './courseChecklistValidators'; + +const getValidatedValue = (data, id) => { + switch (id) { + case 'welcomeMessage': + return healthValidators.hasWelcomeMessage(data.updates); + case 'gradingPolicy': + return healthValidators.hasGradingPolicy(data.grades); + case 'certificate': + return healthValidators.hasCertificate(data.certificates); + case 'courseDates': + return healthValidators.hasDates(data.dates); + case 'assignmentDeadlines': + return healthValidators.hasAssignmentDeadlines(data.assignments, data.dates); + case 'videoDuration': + return healthValidators.hasShortVideoDuration(data.videos); + case 'mobileFriendlyVideo': + return healthValidators.hasMobileFriendlyVideos(data.videos); + case 'diverseSequences': + return healthValidators.hasDiverseSequences(data.subsections); + case 'weeklyHighlights': + return healthValidators.hasWeeklyHighlights(data.sections); + case 'unitDepth': + return healthValidators.hasShortUnitDepth(data.units); + case 'proctoringEmail': + return healthValidators.hasProctoringEscalationEmail(data.proctoring); + default: + throw new Error(`Unknown validator ${id}.`); + } +}; + +export default getValidatedValue; diff --git a/src/course-checklist/ChecklistSection/utils/getValidatedValue.test.js b/src/course-checklist/ChecklistSection/utils/getValidatedValue.test.js new file mode 100644 index 000000000..bed8f76ef --- /dev/null +++ b/src/course-checklist/ChecklistSection/utils/getValidatedValue.test.js @@ -0,0 +1,166 @@ +import * as validators from './courseChecklistValidators'; +import getValidatedValue from './getValidatedValue'; + +describe('getValidatedValue utility function', () => { + const localValidators = validators; + it('welcome message', () => { + const spy = jest.fn(); + localValidators.hasWelcomeMessage = spy; + + const props = { + data: { + updates: {}, + }, + }; + + getValidatedValue(props, 'welcomeMessage'); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('grading policy', () => { + const spy = jest.fn(); + localValidators.hasGradingPolicy = spy; + + const props = { + data: { + grades: {}, + }, + }; + + getValidatedValue(props, 'gradingPolicy'); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('certificate', () => { + const spy = jest.fn(); + localValidators.hasCertificate = spy; + + const props = { + data: { + certificates: {}, + }, + }; + + getValidatedValue(props, 'certificate'); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('course dates', () => { + const spy = jest.fn(); + localValidators.hasDates = spy; + + const props = { + data: { + dates: {}, + }, + }; + + getValidatedValue(props, 'courseDates'); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('assignment deadlines', () => { + const spy = jest.fn(); + localValidators.hasAssignmentDeadlines = spy; + + const props = { + data: { + assignments: {}, + dates: {}, + }, + }; + + getValidatedValue(props, 'assignmentDeadlines'); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('video duration', () => { + const spy = jest.fn(); + localValidators.hasShortVideoDuration = spy; + + const props = { + data: { + videos: {}, + }, + }; + + getValidatedValue(props, 'videoDuration'); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('mobile friendly video', () => { + const spy = jest.fn(); + localValidators.hasMobileFriendlyVideos = spy; + + const props = { + data: { + videos: {}, + }, + }; + + getValidatedValue(props, 'mobileFriendlyVideo'); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('diverse sequences', () => { + const spy = jest.fn(); + localValidators.hasDiverseSequences = spy; + + const props = { + data: { + subsections: {}, + }, + }; + + getValidatedValue(props, 'diverseSequences'); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('weekly highlights', () => { + const spy = jest.fn(); + localValidators.hasWeeklyHighlights = spy; + + const props = { + data: { + sections: {}, + }, + }; + + getValidatedValue(props, 'weeklyHighlights'); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('unit depth', () => { + const spy = jest.fn(); + localValidators.hasShortUnitDepth = spy; + + const props = { + data: { + units: {}, + }, + }; + + getValidatedValue(props, 'unitDepth'); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('proctoring email', () => { + const spy = jest.fn(); + localValidators.hasProctoringEscalationEmail = spy; + + const props = { + data: { + proctoring: {}, + }, + }; + + getValidatedValue(props, 'proctoringEmail'); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('other', () => { + const sampleID = 'edX'; + expect(() => getValidatedValue({}, sampleID)).toThrow(Error); + expect(() => getValidatedValue({}, sampleID)).toThrow(`Unknown validator ${sampleID}`); + }); +}); diff --git a/src/course-checklist/CourseChecklist.jsx b/src/course-checklist/CourseChecklist.jsx new file mode 100644 index 000000000..5766bfe45 --- /dev/null +++ b/src/course-checklist/CourseChecklist.jsx @@ -0,0 +1,96 @@ +import React, { useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { getConfig } from '@edx/frontend-platform'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { Helmet } from 'react-helmet'; +import { useDispatch, useSelector } from 'react-redux'; +import { Container, Stack } from '@openedx/paragon'; + +import { useModel } from '../generic/model-store'; +import SubHeader from '../generic/sub-header/SubHeader'; +import messages from './messages'; +import AriaLiveRegion from './AriaLiveRegion'; +import { RequestStatus } from '../data/constants'; +import ChecklistSection from './ChecklistSection'; +import { fetchCourseLaunchQuery, fetchCourseBestPracticesQuery } from './data/thunks'; +import getUpdateLinks from './utils'; + +const CourseChecklist = ({ + courseId, + // injected, + intl, +}) => { + const dispatch = useDispatch(); + const courseDetails = useModel('courseDetails', courseId); + const enableQuality = getConfig().ENABLE_CHECKLIST_QUALITY === 'true'; + const updateLinks = getUpdateLinks(courseId); + + useEffect(() => { + dispatch(fetchCourseLaunchQuery({ courseId })); + dispatch(fetchCourseBestPracticesQuery({ courseId })); + }, [courseId]); + + const { + loadingStatus, + launchData, + bestPracticeData, + } = useSelector(state => state.courseChecklist); + + const { bestPracticeChecklistLoadingStatus, launchChecklistLoadingStatus } = loadingStatus; + + const isCourseLaunchChecklistLoading = bestPracticeChecklistLoadingStatus === RequestStatus.IN_PROGRESS; + const isCourseBestPracticeChecklistLoading = launchChecklistLoadingStatus === RequestStatus.IN_PROGRESS; + + return ( + <> + + + {intl.formatMessage(messages.pageTitle, { + headingTitle: intl.formatMessage(messages.headingTitle), + courseName: courseDetails?.name, + siteName: process.env.SITE_NAME, + })} + + + + + + + + {enableQuality && ( + + )} + + + + ); +}; + +CourseChecklist.propTypes = { + courseId: PropTypes.string.isRequired, + // injected + intl: intlShape.isRequired, +}; + +export default injectIntl(CourseChecklist); diff --git a/src/course-checklist/CourseChecklist.scss b/src/course-checklist/CourseChecklist.scss new file mode 100644 index 000000000..afcbff6dc --- /dev/null +++ b/src/course-checklist/CourseChecklist.scss @@ -0,0 +1 @@ +@import "./ChecklistSection/ChecklistSection"; diff --git a/src/course-checklist/CourseChecklist.test.jsx b/src/course-checklist/CourseChecklist.test.jsx new file mode 100644 index 000000000..53d52af77 --- /dev/null +++ b/src/course-checklist/CourseChecklist.test.jsx @@ -0,0 +1,153 @@ +import { + render, + waitFor, + screen, +} from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { getConfig, setConfig, initializeMockApp } from '@edx/frontend-platform'; +import MockAdapter from 'axios-mock-adapter'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { AppProvider } from '@edx/frontend-platform/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; + +import initializeStore from '../store'; +import { RequestStatus } from '../data/constants'; +import { executeThunk } from '../utils'; +import { getCourseLaunchApiUrl, getCourseBestPracticesApiUrl } from './data/api'; +import { fetchCourseLaunchQuery, fetchCourseBestPracticesQuery } from './data/thunks'; +import { + courseId, + initialState, + generateCourseLaunchData, + generateCourseBestPracticesData, +} from './factories/mockApiResponses'; +import messages from './messages'; +import CourseChecklist from './index'; + +let axiosMock; +let store; + +const renderComponent = () => { + render( + + + + + , + ); +}; + +const mockStore = async (status) => { + axiosMock.onGet(getCourseLaunchApiUrl(courseId)).reply(status, generateCourseLaunchData()); + axiosMock.onGet(getCourseBestPracticesApiUrl(courseId)).reply(status, generateCourseBestPracticesData()); + + await executeThunk(fetchCourseLaunchQuery(courseId), store.dispatch); + await executeThunk(fetchCourseBestPracticesQuery(courseId), store.dispatch); +}; + +describe('CourseChecklistPage', () => { + beforeEach(async () => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: false, + roles: [], + }, + }); + store = initializeStore(initialState); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + }); + describe('renders', () => { + describe('if enable_quality prop is true', () => { + it('two checklist components ', () => { + renderComponent(); + mockStore(200); + + expect(screen.getByText(messages.launchChecklistLabel.defaultMessage)).toBeVisible(); + + expect(screen.getByText(messages.bestPracticesChecklistLabel.defaultMessage)).toBeVisible(); + }); + + describe('an aria-live region with', () => { + it('an aria-live region', () => { + renderComponent(); + const ariaLiveRegion = screen.getByRole('status'); + + expect(ariaLiveRegion).toBeDefined(); + + expect(ariaLiveRegion.classList.contains('sr-only')).toBe(true); + }); + + it('correct content when the launch checklist has loaded', async () => { + renderComponent(); + mockStore(404); + await waitFor(() => { + const { launchChecklistStatus } = store.getState().courseChecklist.loadingStatus; + + expect(launchChecklistStatus).not.toEqual(RequestStatus.SUCCESSFUL); + + expect(screen.getByText(messages.launchChecklistDoneLoadingLabel.defaultMessage)).toBeInTheDocument(); + }); + }); + + it('correct content when the best practices checklist is loading', async () => { + renderComponent(); + mockStore(404); + await waitFor(() => { + const { bestPracticeChecklistStatus } = store.getState().courseChecklist.loadingStatus; + + expect(bestPracticeChecklistStatus).not.toEqual(RequestStatus.IN_PROGRESS); + + expect( + screen.getByText(messages.bestPracticesChecklistDoneLoadingLabel.defaultMessage), + ).toBeInTheDocument(); + }); + }); + }); + }); + describe('if enable_quality prop is false', () => { + beforeEach(() => { + setConfig({ + ...getConfig(), + ENABLE_CHECKLIST_QUALITY: 'false', + }); + }); + + it('one checklist components ', () => { + renderComponent(); + mockStore(200); + + expect(screen.getByText(messages.launchChecklistLabel.defaultMessage)).toBeVisible(); + + expect(screen.queryByText(messages.bestPracticesChecklistLabel.defaultMessage)).toBeNull(); + }); + + describe('an aria-live region with', () => { + it('correct content when the launch checklist has loaded', async () => { + renderComponent(); + mockStore(404); + await waitFor(() => { + const { launchChecklistStatus } = store.getState().courseChecklist.loadingStatus; + + expect(launchChecklistStatus).not.toEqual(RequestStatus.SUCCESSFUL); + + expect(screen.getByText(messages.launchChecklistDoneLoadingLabel.defaultMessage)).toBeInTheDocument(); + }); + }); + + it('correct content when the best practices checklist is loading', async () => { + renderComponent(); + mockStore(404); + await waitFor(() => { + const { bestPracticeChecklistStatus } = store.getState().courseChecklist.loadingStatus; + + expect(bestPracticeChecklistStatus).not.toEqual(RequestStatus.IN_PROGRESS); + + expect(screen.queryByText(messages.bestPracticesChecklistDoneLoadingLabel.defaultMessage)).toBeNull(); + }); + }); + }); + }); + }); +}); diff --git a/src/course-checklist/data/api.js b/src/course-checklist/data/api.js new file mode 100644 index 000000000..c9524005c --- /dev/null +++ b/src/course-checklist/data/api.js @@ -0,0 +1,64 @@ +// @ts-check +import { camelCaseObject, getConfig } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; + +const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL; + +export const getCourseBestPracticesApiUrl = ({ + courseId, + excludeGraded, + all, +}) => `${getApiBaseUrl()}/api/courses/v1/quality/${courseId}/?exclude_graded=${excludeGraded}&all=${all}`; + +export const getCourseLaunchApiUrl = ({ + courseId, + gradedOnly, + validateOras, + all, +}) => `${getApiBaseUrl()}/api/courses/v1/validation/${courseId}/?graded_only=${gradedOnly}&validate_oras=${validateOras}&all=${all}`; + +/** + * Get course best practices. + * @param {{courseId: string, excludeGraded: boolean, all: boolean}} options + * @returns {Promise<{isSelfPaced: boolean, sections: any, subsection: any, units: any, videos: any }>} + */ +export async function getCourseBestPractices({ + courseId, + excludeGraded, + all, +}) { + const { data } = await getAuthenticatedHttpClient() + .get(getCourseBestPracticesApiUrl({ courseId, excludeGraded, all })); + + return camelCaseObject(data); +} + +/** @typedef {object} courseLaunchData + * @property {boolean} isSelfPaced + * @property {object} dates + * @property {object} assignments + * @property {object} grades + * @property {number} grades.sum_of_weights + * @property {object} certificates + * @property {object} updates + * @property {object} proctoring + */ + +/** + * Get course launch. + * @param {{courseId: string, gradedOnly: boolean, validateOras: boolean, all: boolean}} options + * @returns {Promise} + */ +export async function getCourseLaunch({ + courseId, + gradedOnly, + validateOras, + all, +}) { + const { data } = await getAuthenticatedHttpClient() + .get(getCourseLaunchApiUrl({ + courseId, gradedOnly, validateOras, all, + })); + + return camelCaseObject(data); +} diff --git a/src/course-checklist/data/slice.js b/src/course-checklist/data/slice.js new file mode 100644 index 000000000..d50c7ecb2 --- /dev/null +++ b/src/course-checklist/data/slice.js @@ -0,0 +1,41 @@ +/* eslint-disable no-param-reassign */ +import { createSlice } from '@reduxjs/toolkit'; + +import { RequestStatus } from '../../data/constants'; + +const slice = createSlice({ + name: 'courseChecklist', + initialState: { + loadingStatus: { + launchChecklistStatus: RequestStatus.IN_PROGRESS, + bestPracticeChecklistStatus: RequestStatus.IN_PROGRESS, + }, + launchData: {}, + bestPracticeData: {}, + }, + reducers: { + fetchLaunchChecklistSuccess: (state, { payload }) => { + state.launchData = payload.data; + }, + updateLaunchChecklistStatus: (state, { payload }) => { + state.loadingStatus.launchChecklistStatus = payload.status; + }, + fetchBestPracticeChecklistSuccess: (state, { payload }) => { + state.bestPracticeData = payload.data; + }, + updateBestPracticeChecklisttStatus: (state, { payload }) => { + state.loadingStatus.bestPracticeChecklistStatus = payload.status; + }, + }, +}); + +export const { + fetchLaunchChecklistSuccess, + updateLaunchChecklistStatus, + fetchBestPracticeChecklistSuccess, + updateBestPracticeChecklisttStatus, +} = slice.actions; + +export const { + reducer, +} = slice; diff --git a/src/course-checklist/data/thunks.js b/src/course-checklist/data/thunks.js new file mode 100644 index 000000000..20be7648a --- /dev/null +++ b/src/course-checklist/data/thunks.js @@ -0,0 +1,46 @@ +import { RequestStatus } from '../../data/constants'; +import { + getCourseBestPractices, + getCourseLaunch, +} from './api'; +import { + fetchLaunchChecklistSuccess, + updateLaunchChecklistStatus, + fetchBestPracticeChecklistSuccess, + updateBestPracticeChecklisttStatus, +} from './slice'; + +export function fetchCourseLaunchQuery({ + courseId, + gradedOnly = true, + validateOras = true, + all = true, +}) { + return async (dispatch) => { + try { + const data = await getCourseLaunch({ + courseId, gradedOnly, validateOras, all, + }); + dispatch(fetchLaunchChecklistSuccess({ data })); + dispatch(updateLaunchChecklistStatus({ status: RequestStatus.SUCCESSFUL })); + } catch (error) { + dispatch(updateLaunchChecklistStatus({ status: RequestStatus.FAILED })); + } + }; +} + +export function fetchCourseBestPracticesQuery({ + courseId, + excludeGraded = true, + all = true, +}) { + return async (dispatch) => { + try { + const data = await getCourseBestPractices({ courseId, excludeGraded, all }); + dispatch(fetchBestPracticeChecklistSuccess({ data })); + dispatch(updateBestPracticeChecklisttStatus({ status: RequestStatus.SUCCESSFUL })); + } catch (error) { + dispatch(updateBestPracticeChecklisttStatus({ status: RequestStatus.FAILED })); + } + }; +} diff --git a/src/course-checklist/factories/mockApiResponses.jsx b/src/course-checklist/factories/mockApiResponses.jsx new file mode 100644 index 000000000..eec4f3439 --- /dev/null +++ b/src/course-checklist/factories/mockApiResponses.jsx @@ -0,0 +1,104 @@ +import { RequestStatus } from '../../data/constants'; + +export const courseId = 'course-v1:edX+DemoX+Demo_Course'; + +export const initialState = { + courseDetail: { + courseId, + status: 'sucessful', + }, + courseChecklist: { + loadingStatus: { + launchChecklistStatus: RequestStatus.IN_PROGRESS, + bestPracticeChecklistStatus: RequestStatus.IN_PROGRESS, + }, + launchData: {}, + bestPracticesData: {}, + }, +}; + +export const generateCourseBestPracticesData = () => ({ + is_self_paced: false, + sections: { + total_number: 2, + total_visible: 2, + number_with_highlights: 0, + highlights_active_for_course: false, + highlights_enabled: true, + }, + subsections: { + total_visible: 1, + num_with_one_block_type: 1, + num_block_types: { + min: 1, + max: 1, + mean: 1.0, + median: 1.0, + mode: 1, + }, + }, + units: { + total_visible: 1, + num_blocks: { + min: 1, + max: 1, + mean: 1.0, + median: 1.0, + mode: 1, + }, + }, + videos: { + total_number: 10, + num_mobile_encoded: 5, + num_with_val_id: 10, + durations: { + min: 9.409, + max: 168.001, + mean: 41.0, + median: 9.0, + mode: 9.409, + }, + }, +}); + +export const generateCourseLaunchData = () => ({ + is_self_paced: false, + dates: { + has_start_date: true, + has_end_date: true, + }, + assignments: { + total_number: 2, + total_visible: 2, + assignments_with_dates_before_start: [], + assignments_with_dates_after_end: [ + { + id: 'block-v1', + display_name: 'Subsection', + }, + ], + assignments_with_ora_dates_before_start: [ + { + id: 'block-v2', + display_name: 'ORA subsection', + }, + ], + assignments_with_ora_dates_after_end: [], + }, + grades: { + has_grading_policy: true, + sum_of_weights: 0.9500000000000001, + }, + certificates: { + is_activated: false, + has_certificate: false, + is_enabled: true, + }, + updates: { + has_update: true, + }, + proctoring: { + needs_proctoring_escalation_email: true, + has_proctoring_escalation_email: false, + }, +}); diff --git a/src/course-checklist/index.js b/src/course-checklist/index.js new file mode 100644 index 000000000..a9a60ae4e --- /dev/null +++ b/src/course-checklist/index.js @@ -0,0 +1,3 @@ +import CourseChecklist from './CourseChecklist'; + +export default CourseChecklist; diff --git a/src/course-checklist/messages.js b/src/course-checklist/messages.js new file mode 100644 index 000000000..7176459c8 --- /dev/null +++ b/src/course-checklist/messages.js @@ -0,0 +1,49 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + pageTitle: { + id: 'course-authoring.export.page.title', + defaultMessage: '{headingTitle} | {courseName} | {siteName}', + }, + headingTitle: { + id: 'course-authoring.course-checklist.heading.title', + defaultMessage: 'Checklists', + description: 'Header text for the Checklist page', + }, + headingSubtitle: { + id: 'course-authoring.course-checklist.heading.subtitle', + defaultMessage: 'Tools', + }, + launchChecklistLabel: { + id: 'launchChecklistLabel', + defaultMessage: 'Launch checklist', + description: 'Header text for a checklist that describes actions to have completed before a course should launch', + }, + bestPracticesChecklistLabel: { + id: 'bestPracticesChecklistLabel', + defaultMessage: 'Best practices checklist', + description: 'Header text for a checklist that describes best practices for a course', + }, + launchChecklistLoadingLabel: { + id: 'doneLoadingChecklistStatusLabel', + defaultMessage: 'Launch Checklist data is loading', + description: 'Label telling the user that the Launch Checklist is loading', + }, + launchChecklistDoneLoadingLabel: { + id: 'launchChecklistDoneLoadingLabel', + defaultMessage: 'Launch Checklist data is done loading', + description: 'Label telling the user that the Launch Checklist is done loading', + }, + bestPracticesChecklistLoadingLabel: { + id: 'bestPracticesChecklistLoadingLabel', + defaultMessage: 'Best Practices Checklist data is loading', + description: 'Label telling the user that the Best Practices Checklist is loading', + }, + bestPracticesChecklistDoneLoadingLabel: { + id: 'bestPracticesChecklistDoneLoadingLabel', + defaultMessage: 'Best Practices Checklist data is done loading', + description: 'Label telling the user that the Best Practices Checklist is done loading', + }, +}); + +export default messages; diff --git a/src/course-checklist/utils.js b/src/course-checklist/utils.js new file mode 100644 index 000000000..3e6b549ff --- /dev/null +++ b/src/course-checklist/utils.js @@ -0,0 +1,12 @@ +import { getConfig } from '@edx/frontend-platform'; + +const getUpdateLinks = (courseId) => ({ + welcomeMessage: `${getConfig().STUDIO_BASE_URL}/course_info/${courseId}`, + gradingPolicy: `${getConfig().STUDIO_BASE_URL}/settings/grading/${courseId}`, + certificate: `${getConfig().STUDIO_BASE_URL}/certificates/${courseId}`, + courseDates: `${getConfig().STUDIO_BASE_URL}/settings/details/${courseId}#schedule`, + proctoringEmail: 'pages-and-resources/proctoring/settings', + outline: `${getConfig().STUDIO_BASE_URL}/course/${courseId}`, +}); + +export default getUpdateLinks; diff --git a/src/course-outline/CourseOutline.jsx b/src/course-outline/CourseOutline.jsx index 986969ee5..82af8687c 100644 --- a/src/course-outline/CourseOutline.jsx +++ b/src/course-outline/CourseOutline.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import PropTypes from 'prop-types'; import { useIntl } from '@edx/frontend-platform/i18n'; import { @@ -15,8 +15,11 @@ import { Warning as WarningIcon, } from '@openedx/paragon/icons'; import { useSelector } from 'react-redux'; -import { DraggableList } from '@edx/frontend-lib-content-components'; -import { arrayMove } from '@dnd-kit/sortable'; +import { + arrayMove, + SortableContext, + verticalListSortingStrategy, +} from '@dnd-kit/sortable'; import { LoadingSpinner } from '../generic/Loading'; import { getProcessingNotification } from '../generic/processing-notification/data/selectors'; @@ -24,8 +27,11 @@ import { RequestStatus } from '../data/constants'; import SubHeader from '../generic/sub-header/SubHeader'; import ProcessingNotification from '../generic/processing-notification'; import InternetConnectionAlert from '../generic/internet-connection-alert'; +import DeleteModal from '../generic/delete-modal/DeleteModal'; import AlertMessage from '../generic/alert-message'; import getPageHeadTitle from '../generic/utils'; +import { getCurrentItem } from './data/selectors'; +import { COURSE_BLOCK_NAMES } from './constants'; import HeaderNavigations from './header-navigations/HeaderNavigations'; import OutlineSideBar from './outline-sidebar/OutlineSidebar'; import StatusBar from './status-bar/StatusBar'; @@ -37,10 +43,16 @@ import HighlightsModal from './highlights-modal/HighlightsModal'; import EmptyPlaceholder from './empty-placeholder/EmptyPlaceholder'; import PublishModal from './publish-modal/PublishModal'; import ConfigureModal from './configure-modal/ConfigureModal'; -import DeleteModal from './delete-modal/DeleteModal'; import PageAlerts from './page-alerts/PageAlerts'; +import DraggableList from './drag-helper/DraggableList'; +import { + canMoveSection, + possibleUnitMoves, + possibleSubsectionMoves, +} from './drag-helper/utils'; import { useCourseOutline } from './hooks'; import messages from './messages'; +import useUnitTagsCount from './data/apiHooks'; const CourseOutline = ({ courseId }) => { const intl = useIntl(); @@ -89,10 +101,7 @@ const CourseOutline = ({ courseId }) => { handleNewSubsectionSubmit, handleNewUnitSubmit, getUnitUrl, - handleSectionDragAndDrop, - handleSubsectionDragAndDrop, handleVideoSharingOptionChange, - handleUnitDragAndDrop, handleCopyToClipboardClick, handlePasteClipboardClick, notificationDismissUrl, @@ -104,77 +113,46 @@ const CourseOutline = ({ courseId }) => { mfeProctoredExamSettingsUrl, handleDismissNotification, advanceSettingsUrl, + prevContainerInfo, + handleSectionDragAndDrop, + handleSubsectionDragAndDrop, + handleUnitDragAndDrop, } = useCourseOutline({ courseId }); const [sections, setSections] = useState(sectionsList); - let initialSections = [...sectionsList]; + const restoreSectionList = () => { + setSections(() => [...sectionsList]); + }; const { isShow: isShowProcessingNotification, title: processingNotificationTitle, } = useSelector(getProcessingNotification); - const finalizeSectionOrder = () => (newSections) => { - initialSections = [...sectionsList]; - handleSectionDragAndDrop(newSections.map(section => section.id), () => { - setSections(() => initialSections); + const { category } = useSelector(getCurrentItem); + const deleteCategory = COURSE_BLOCK_NAMES[category]?.name.toLowerCase(); + + const unitsIdPattern = useMemo(() => { + let pattern = ''; + sections.forEach((section) => { + section.childInfo.children.forEach((subsection) => { + subsection.childInfo.children.forEach((unit) => { + if (pattern !== '') { + pattern += `,${unit.id}`; + } else { + pattern += unit.id; + } + }); + }); }); - }; + return pattern; + }, [sections]); - const setSubsection = (index) => (updatedSubsection) => { - const section = { ...sections[index] }; - section.childInfo = { ...section.childInfo }; - section.childInfo.children = updatedSubsection(); - setSections([...sections.slice(0, index), section, ...sections.slice(index + 1)]); - }; - - const finalizeSubsectionOrder = (section) => () => (newSubsections) => { - initialSections = [...sectionsList]; - handleSubsectionDragAndDrop(section.id, newSubsections.map(subsection => subsection.id), () => { - setSections(() => initialSections); - }); - }; - - const setUnit = (sectionIndex, subsectionIndex) => (updatedUnits) => { - const section = { ...sections[sectionIndex] }; - section.childInfo = { ...section.childInfo }; - - const subsection = { ...section.childInfo.children[subsectionIndex] }; - subsection.childInfo = { ...subsection.childInfo }; - subsection.childInfo.children = updatedUnits(); - - const updatedSubsections = [...section.childInfo.children]; - updatedSubsections[subsectionIndex] = subsection; - section.childInfo.children = updatedSubsections; - setSections([...sections.slice(0, sectionIndex), section, ...sections.slice(sectionIndex + 1)]); - }; - - const finalizeUnitOrder = (section, subsection) => () => (newUnits) => { - initialSections = [...sectionsList]; - handleUnitDragAndDrop(section.id, subsection.id, newUnits.map(unit => unit.id), () => { - setSections(() => initialSections); - }); - }; - - /** - * Check if item can be moved by given step. - * Inner function returns false if the new index after moving by given step - * is out of bounds of item length. - * If it is within bounds, returns draggable flag of the item in the new index. - * This helps us avoid moving the item to a position of unmovable item. - * @param {Array} items - * @returns {(id, step) => bool} - */ - const canMoveItem = (items) => (id, step) => { - const newId = id + step; - const indexCheck = newId >= 0 && newId < items.length; - if (!indexCheck) { - return false; - } - const newItem = items[newId]; - return newItem.actions.draggable; - }; + const { + data: unitsTagCounts, + isSuccess: isUnitsTagCountsLoaded, + } = useUnitTagsCount(unitsIdPattern); /** * Move section to new index @@ -187,54 +165,58 @@ const CourseOutline = ({ courseId }) => { } setSections((prevSections) => { const newSections = arrayMove(prevSections, currentIndex, newIndex); - finalizeSectionOrder()(newSections); + handleSectionDragAndDrop(newSections.map(section => section.id)); return newSections; }); }; /** - * Returns a function for given section which can move a subsection inside it - * to a new position - * @param {any} sectionIndex + * Uses details from move information and moves subsection * @param {any} section - * @param {any} subsections - * @returns {(currentIndex, newIndex) => void} + * @param {any} moveDetails + * @returns {void} */ - const updateSubsectionOrderByIndex = (sectionIndex, section, subsections) => (currentIndex, newIndex) => { - if (currentIndex === newIndex) { + const updateSubsectionOrderByIndex = (section, moveDetails) => { + const { fn, args, sectionId } = moveDetails; + if (!args) { return; } - setSubsection(sectionIndex)(() => { - const newSubsections = arrayMove(subsections, currentIndex, newIndex); - finalizeSubsectionOrder(section)()(newSubsections); - return newSubsections; - }); + const [sectionsCopy, newSubsections] = fn(...args); + if (newSubsections && sectionId) { + setSections(sectionsCopy); + handleSubsectionDragAndDrop( + sectionId, + section.id, + newSubsections.map(subsection => subsection.id), + restoreSectionList, + ); + } }; /** - * Returns a function for given section & subsection which can move a unit - * inside it to a new position - * @param {any} sectionIndex + * Uses details from move information and moves unit * @param {any} section - * @param {any} subsection - * @param {any} units - * @returns {(currentIndex, newIndex) => void} + * @param {any} moveDetails + * @returns {void} */ - const updateUnitOrderByIndex = ( - sectionIndex, - subsectionIndex, - section, - subsection, - units, - ) => (currentIndex, newIndex) => { - if (currentIndex === newIndex) { + const updateUnitOrderByIndex = (section, moveDetails) => { + const { + fn, args, sectionId, subsectionId, + } = moveDetails; + if (!args) { return; } - setUnit(sectionIndex, subsectionIndex)(() => { - const newUnits = arrayMove(units, currentIndex, newIndex); - finalizeUnitOrder(section, subsection)()(newUnits); - return newUnits; - }); + const [sectionsCopy, newUnits] = fn(...args); + if (newUnits && sectionId && subsectionId) { + setSections(sectionsCopy); + handleUnitDragAndDrop( + sectionId, + section.id, + subsectionId, + newUnits.map(unit => unit.id), + restoreSectionList, + ); + } }; useEffect(() => { @@ -258,6 +240,7 @@ const CourseOutline = ({ courseId }) => {
{
{sections.length ? ( <> - - {sections.map((section, sectionIndex) => ( - - + + {sections.map((section, sectionIndex) => ( + - {section.childInfo.children.map((subsection, subsectionIndex) => ( - - + {section.childInfo.children.map((subsection, subsectionIndex) => ( + - {subsection.childInfo.children.map((unit, unitIndex) => ( - - ))} - - - ))} - - - ))} + + {subsection.childInfo.children.map((unit, unitIndex) => ( + + ))} + + + ))} + + + ))} + {courseActions.childAddable && (