diff --git a/.env b/.env index 9c900c37fb..d0bb7ec0d8 100644 --- a/.env +++ b/.env @@ -31,11 +31,9 @@ ENABLE_ACCESSIBILITY_PAGE=false ENABLE_PROGRESS_GRAPH_SETTINGS=false ENABLE_TEAM_TYPE_SETTING=false ENABLE_NEW_EDITOR_PAGES=true -ENABLE_NEW_COURSE_OUTLINE_PAGE = false -ENABLE_NEW_VIDEO_UPLOAD_PAGE = false -ENABLE_UNIT_PAGE = false -ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN = false -ENABLE_TAGGING_TAXONOMY_PAGES = false +ENABLE_UNIT_PAGE=false +ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=false +ENABLE_TAGGING_TAXONOMY_PAGES=false BBB_LEARN_MORE_URL='' HOTJAR_APP_ID='' HOTJAR_VERSION=6 diff --git a/.env.development b/.env.development index 55b8ce70cd..045c52f2df 100644 --- a/.env.development +++ b/.env.development @@ -33,10 +33,9 @@ ENABLE_ACCESSIBILITY_PAGE=false ENABLE_PROGRESS_GRAPH_SETTINGS=false ENABLE_TEAM_TYPE_SETTING=false ENABLE_NEW_EDITOR_PAGES=true -ENABLE_NEW_VIDEO_UPLOAD_PAGE = false -ENABLE_UNIT_PAGE = false -ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN = false -ENABLE_TAGGING_TAXONOMY_PAGES = true +ENABLE_UNIT_PAGE=false +ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=false +ENABLE_TAGGING_TAXONOMY_PAGES=true BBB_LEARN_MORE_URL='' HOTJAR_APP_ID='' HOTJAR_VERSION=6 diff --git a/.env.test b/.env.test index 8c810bb90b..67ad2994bb 100644 --- a/.env.test +++ b/.env.test @@ -29,10 +29,8 @@ USER_INFO_COOKIE_NAME='edx-user-info' ENABLE_PROGRESS_GRAPH_SETTINGS=false ENABLE_TEAM_TYPE_SETTING=false ENABLE_NEW_EDITOR_PAGES=true -ENABLE_NEW_COURSE_OUTLINE_PAGE = true -ENABLE_NEW_VIDEO_UPLOAD_PAGE = true -ENABLE_UNIT_PAGE = true -ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN = true -ENABLE_TAGGING_TAXONOMY_PAGES = true +ENABLE_UNIT_PAGE=true +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" diff --git a/.gitignore b/.gitignore index 7ba8a0e7fd..9770f7309d 100755 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,6 @@ temp/babel-plugin-react-intl /temp /.vscode /module.config.js + +# Local environment overrides +.env.private diff --git a/Makefile b/Makefile index 9e34fc9287..c1be0e2cba 100644 --- a/Makefile +++ b/Makefile @@ -54,12 +54,15 @@ pull_translations: rm -rf src/i18n/messages mkdir src/i18n/messages cd src/i18n/messages \ - && atlas pull --filter=$(transifex_langs) \ + && atlas pull $(ATLAS_OPTIONS) \ + translations/frontend-component-ai-translations/src/i18n/messages:frontend-component-ai-translations \ + translations/frontend-lib-content-components/src/i18n/messages:frontend-lib-content-components \ + translations/frontend-platform/src/i18n/messages:frontend-platform \ translations/paragon/src/i18n/messages:paragon \ translations/frontend-component-footer/src/i18n/messages:frontend-component-footer \ translations/frontend-app-course-authoring/src/i18n/messages:frontend-app-course-authoring - $(intl_imports) paragon frontend-component-footer 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. diff --git a/README.rst b/README.rst index 8959458aa7..724d9dfc78 100644 --- a/README.rst +++ b/README.rst @@ -260,7 +260,7 @@ Requirements * ``edx-platform`` Waffle flags: - * ``contentstore.new_studio_mfe.use_tagging_taxonomy_list_page``: this feature flag must be enabled. + * ``new_studio_mfe.use_tagging_taxonomy_list_page``: this feature flag must be enabled. Configuration ------------- diff --git a/jest.config.js b/jest.config.js index 05becd37f3..db3f44b207 100644 --- a/jest.config.js +++ b/jest.config.js @@ -2,16 +2,17 @@ const { createConfig } = require('@openedx/frontend-build'); module.exports = createConfig('jest', { setupFilesAfterEnv: [ + 'jest-expect-message', '/src/setupTest.js', ], coveragePathIgnorePatterns: [ 'src/setupTest.js', 'src/i18n', ], - snapshotSerializers: [ - 'enzyme-to-json/serializer', - ], moduleNameMapper: { '^lodash-es$': 'lodash', }, + modulePathIgnorePatterns: [ + '/src/pages-and-resources/utils.test.jsx', + ], }); diff --git a/package-lock.json b/package-lock.json index eae202c609..933d6d64d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,12 +9,13 @@ "version": "0.1.0", "license": "AGPL-3.0", "dependencies": { + "@dnd-kit/sortable": "^8.0.0", "@edx/brand": "npm:@openedx/brand-openedx@^1.2.2", - "@edx/frontend-component-ai-translations-edx": "^1.4.2", + "@edx/frontend-component-ai-translations": "^1.4.0", "@edx/frontend-component-footer": "^12.3.0", "@edx/frontend-component-header": "^4.7.0", "@edx/frontend-enterprise-hotjar": "^2.0.0", - "@edx/frontend-lib-content-components": "^1.177.8", + "@edx/frontend-lib-content-components": "^1.178.2", "@edx/frontend-platform": "5.6.1", "@fortawesome/fontawesome-svg-core": "1.2.36", "@fortawesome/free-brands-svg-icons": "5.15.4", @@ -24,6 +25,7 @@ "@openedx/paragon": "^21.11.3", "@reduxjs/toolkit": "1.9.7", "@tanstack/react-query": "4.36.1", + "broadcast-channel": "^7.0.0", "classnames": "2.2.6", "core-js": "3.8.1", "email-validator": "2.0.4", @@ -59,13 +61,11 @@ "@testing-library/react": "12.1.5", "@testing-library/react-hooks": "^8.0.1", "@testing-library/user-event": "^13.2.1", - "@wojtekmaj/enzyme-adapter-react-17": "0.8.0", "axios-mock-adapter": "1.22.0", - "enzyme": "3.11.0", - "enzyme-to-json": "^3.6.2", "glob": "7.2.3", "husky": "7.0.4", "jest-canvas-mock": "^2.5.2", + "jest-expect-message": "^1.1.3", "react-test-renderer": "17.0.2", "reactifex": "1.1.1", "ts-loader": "^9.5.0" @@ -2021,10 +2021,11 @@ "integrity": "sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA==" }, "node_modules/@babel/runtime": { - "version": "7.21.0", - "license": "MIT", + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.4.tgz", + "integrity": "sha512-2Yv65nlWnWlSpe3fXEyX5i7fx5kIKo4Qbcj+hMO0odwaneFjfXw5fdum+4yL20O0QiaHpia0cYQ9xpNMqrBwHg==", "dependencies": { - "regenerator-runtime": "^0.13.11" + "regenerator-runtime": "^0.14.0" }, "engines": { "node": ">=6.9.0" @@ -2048,8 +2049,9 @@ "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" }, "node_modules/@babel/runtime/node_modules/regenerator-runtime": { - "version": "0.13.11", - "license": "MIT" + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" }, "node_modules/@babel/template": { "version": "7.22.15", @@ -2353,9 +2355,9 @@ } }, "node_modules/@dnd-kit/accessibility": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.0.1.tgz", - "integrity": "sha512-HXRrwS9YUYQO9lFRc/49uO/VICbM+O+ZRpFDe9Pd1rwVv2PCNkRiTZRdxrDgng/UkvdC3Re9r2vwPpXXrWeFzg==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.0.tgz", + "integrity": "sha512-ea7IkhKvlJUv9iSHJOnxinBcoOI3ppGnnL+VDJ75O45Nss6HtZd8IdN8touXPDtASfeI2T2LImb8VOZcL47wjQ==", "dependencies": { "tslib": "^2.0.0" }, @@ -2364,12 +2366,12 @@ } }, "node_modules/@dnd-kit/core": { - "version": "6.0.8", - "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.0.8.tgz", - "integrity": "sha512-lYaoP8yHTQSLlZe6Rr9qogouGUz9oRUj4AHhDQGQzq/hqaJRpFo65X+JKsdHf8oUFBzx5A+SJPUvxAwTF2OabA==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.1.0.tgz", + "integrity": "sha512-J3cQBClB4TVxwGo3KEjssGEXNJqGVWx17aRTZ1ob0FliR5IjYgTxl5YJbKTzA6IzrtelotH19v6y7uoIRUZPSg==", "dependencies": { - "@dnd-kit/accessibility": "^3.0.0", - "@dnd-kit/utilities": "^3.2.1", + "@dnd-kit/accessibility": "^3.1.0", + "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { @@ -2378,22 +2380,22 @@ } }, "node_modules/@dnd-kit/sortable": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-7.0.2.tgz", - "integrity": "sha512-wDkBHHf9iCi1veM834Gbk1429bd4lHX4RpAwT0y2cHLf246GAvU2sVw/oxWNpPKQNQRQaeGXhAVgrOl1IT+iyA==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-8.0.0.tgz", + "integrity": "sha512-U3jk5ebVXe1Lr7c2wU7SBZjcWdQP+j7peHJfCspnA81enlu88Mgd7CC8Q+pub9ubP7eKVETzJW+IBAhsqbSu/g==", "dependencies": { - "@dnd-kit/utilities": "^3.2.0", + "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { - "@dnd-kit/core": "^6.0.7", + "@dnd-kit/core": "^6.1.0", "react": ">=16.8.0" } }, "node_modules/@dnd-kit/utilities": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.1.tgz", - "integrity": "sha512-OOXqISfvBw/1REtkSK2N3Fi2EQiLMlWUlqnOK/UpOISqBZPWpE6TqL+jcPtMOkE8TqYGiURvRdPSI9hltNUjEA==", + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", "dependencies": { "tslib": "^2.0.0" }, @@ -2566,17 +2568,19 @@ "node": ">=8" } }, - "node_modules/@edx/frontend-component-ai-translations-edx": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/@edx/frontend-component-ai-translations-edx/-/frontend-component-ai-translations-edx-1.4.2.tgz", - "integrity": "sha512-+gs9eDDonLcvikfclslOO0Y53WLi+RrKKB7PqpQcpojwhFJc5ZY+3Hk+tYtElheOBcrZJsptBtgExwQIH8Uqnw==", + "node_modules/@edx/frontend-component-ai-translations": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@edx/frontend-component-ai-translations/-/frontend-component-ai-translations-1.4.0.tgz", + "integrity": "sha512-Y+XjfkyExqu1N6gt1bZnrW6+C/eqrwh+Xx21xt9TcsNoy8c/Gf3zPpoExrREpaI+OB4FNAP9KQHlOGcLQV7NQQ==", "dependencies": { - "@edx/brand": "npm:@openedx/brand-openedx@^1.2.2", "@edx/frontend-platform": "5.6.1", - "@edx/paragon": "21.5.6", + "@edx/paragon": "^21.5.6", "babel-polyfill": "6.26.0", - "prop-types": "^15.5.10", - "react-responsive": "8.2.0" + "prop-types": "15.8.1", + "react": "17.0.2", + "react-dom": "17.0.2", + "react-responsive": "8.2.0", + "react-router-dom": "6.16.0" }, "peerDependencies": { "@edx/frontend-platform": "^4.0.0 || ^5.0.0 || ^6.0.0", @@ -2585,7 +2589,17 @@ "react-dom": "^16.9.0 || ^17.0.0" } }, - "node_modules/@edx/frontend-component-ai-translations-edx/node_modules/react-responsive": { + "node_modules/@edx/frontend-component-ai-translations/node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/@edx/frontend-component-ai-translations/node_modules/react-responsive": { "version": "8.2.0", "resolved": "https://registry.npmjs.org/react-responsive/-/react-responsive-8.2.0.tgz", "integrity": "sha512-iagCqVrw4QSjhxKp3I/YK6+ODkWY6G+YPElvdYKiUUbywwh9Ds0M7r26Fj2/7dWFFbOpcGnJE6uE7aMck8j5Qg==", @@ -2787,9 +2801,9 @@ } }, "node_modules/@edx/frontend-lib-content-components": { - "version": "1.177.9", - "resolved": "https://registry.npmjs.org/@edx/frontend-lib-content-components/-/frontend-lib-content-components-1.177.9.tgz", - "integrity": "sha512-BeqYE2rDePTagfyHOjijhZAvTOUhHh7D/I4KTik/0g5+C48VM97xB5Z4ZGxJ/BqzAX6j8JVurqkTMhshCP1u4g==", + "version": "1.178.2", + "resolved": "https://registry.npmjs.org/@edx/frontend-lib-content-components/-/frontend-lib-content-components-1.178.2.tgz", + "integrity": "sha512-tiwtFpLzzdoiPYDgOaXNtKB9tUi6u7eBxtTcR68lcGFAx7JAHJFzqN6Jt/VDcFzxhtHAuAEeu2S3Wp3wGkTSAA==", "dependencies": { "@codemirror/lang-html": "^6.0.0", "@codemirror/lang-xml": "^6.0.0", @@ -2807,6 +2821,7 @@ "fast-xml-parser": "^4.0.10", "frontend-components-tinymce-advanced-plugins": "^1.0.2", "lodash-es": "^4.17.21", + "lodash.flatten": "^4.4.0", "moment": "^2.29.4", "moment-shortformat": "^2.1.0", "react-dropzone": "^14.2.3", @@ -2833,6 +2848,19 @@ "react-dom": "^16.14.0 || ^17.0.0" } }, + "node_modules/@edx/frontend-lib-content-components/node_modules/@dnd-kit/sortable": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-7.0.2.tgz", + "integrity": "sha512-wDkBHHf9iCi1veM834Gbk1429bd4lHX4RpAwT0y2cHLf246GAvU2sVw/oxWNpPKQNQRQaeGXhAVgrOl1IT+iyA==", + "dependencies": { + "@dnd-kit/utilities": "^3.2.0", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.0.7", + "react": ">=16.8.0" + } + }, "node_modules/@edx/frontend-lib-content-components/node_modules/react-responsive": { "version": "8.2.0", "resolved": "https://registry.npmjs.org/react-responsive/-/react-responsive-8.2.0.tgz", @@ -2929,6 +2957,14 @@ "@newrelic/publish-sourcemap": "^5.0.1" } }, + "node_modules/@edx/openedx-atlas": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@edx/openedx-atlas/-/openedx-atlas-0.6.0.tgz", + "integrity": "sha512-wZO7hA4VJ/bXjaQNNR7KXGYyTCNs1mBJd3HwQK2EmOwFZYFNX6nzSAm9S7HCfi/kb1PCRpmp3wJt+v/Eu9BEQg==", + "bin": { + "atlas": "atlas" + } + }, "node_modules/@edx/paragon": { "version": "21.5.6", "resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-21.5.6.tgz", @@ -3053,39 +3089,382 @@ "node": ">=7.0.0" } }, - "node_modules/@edx/paragon/node_modules/color-name": { + "node_modules/@edx/paragon/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "node_modules/@edx/paragon/node_modules/glob": { + "version": "8.0.3", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^5.0.1", + "once": "^1.3.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@edx/paragon/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/@edx/paragon/node_modules/minimatch": { + "version": "5.1.0", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@edx/paragon/node_modules/prop-types": { + "version": "15.8.1", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/@edx/paragon/node_modules/react-responsive": { + "version": "8.2.0", + "license": "MIT", + "dependencies": { + "hyphenate-style-name": "^1.0.0", + "matchmediaquery": "^0.3.0", + "prop-types": "^15.6.1", + "shallow-equal": "^1.1.0" + }, + "engines": { + "node": ">= 0.10" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@edx/paragon/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@edx/paragon/node_modules/uuid": { + "version": "9.0.0", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/@edx/react-unit-test-utils": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@edx/react-unit-test-utils/-/react-unit-test-utils-1.7.0.tgz", + "integrity": "sha512-wuUIelYGa9P5/n4+qDGevWH+cQS8iUOzTHpoc5yujqDED85i733U2Yt6cI+jSTAfQtCzPBn2pkGYdCLz5zL+hw==", + "dev": true, + "dependencies": { + "@edx/browserslist-config": "^1.1.1", + "@edx/frontend-platform": "4.6.0", + "@edx/paragon": "^20.44.0", + "@reduxjs/toolkit": "^1.5.1", + "@testing-library/dom": "^9.3.0", + "@testing-library/jest-dom": "^5.16.5", + "@testing-library/react": "^12.1.5", + "@testing-library/react-hooks": "^8.0.1", + "classnames": "^2.2.6", + "core-js": "3.6.5", + "lodash": "^4.17.21", + "react-dev-utils": "^12.0.1", + "react-test-renderer": "17.0.2" + }, + "peerDependencies": { + "@edx/frontend-build": ">=8.1.0", + "react": "^16.9.0 || ^17.0.0" + } + }, + "node_modules/@edx/react-unit-test-utils/node_modules/@edx/frontend-platform": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@edx/frontend-platform/-/frontend-platform-4.6.0.tgz", + "integrity": "sha512-NZ1I3BgUZl7bqvDwSnnL+LxqZOdOUGZU55KiwvknqiKU8RS5Lx9tc4arp+NcX1u58xy/Xbinv+mriSO6PPxQNQ==", + "dev": true, + "dependencies": { + "@cospired/i18n-iso-languages": "4.1.0", + "@formatjs/intl-pluralrules": "4.3.3", + "@formatjs/intl-relativetimeformat": "10.0.1", + "axios": "0.27.2", + "axios-cache-interceptor": "0.10.7", + "form-urlencoded": "4.1.4", + "glob": "7.2.3", + "history": "4.10.1", + "i18n-iso-countries": "4.3.1", + "jwt-decode": "3.1.2", + "localforage": "1.10.0", + "localforage-memoryStorageDriver": "0.9.2", + "lodash.camelcase": "4.3.0", + "lodash.memoize": "4.1.2", + "lodash.merge": "4.6.2", + "lodash.snakecase": "4.1.1", + "pubsub-js": "1.9.4", + "react-intl": "^5.25.0", + "universal-cookie": "4.0.4" + }, + "bin": { + "intl-imports.js": "i18n/scripts/intl-imports.js", + "transifex-utils.js": "i18n/scripts/transifex-utils.js" + }, + "peerDependencies": { + "@edx/frontend-build": ">= 8.1.0", + "@edx/paragon": ">= 10.0.0 < 21.0.0", + "prop-types": "^15.7.2", + "react": "^16.9.0 || ^17.0.0", + "react-dom": "^16.9.0 || ^17.0.0", + "react-redux": "^7.1.1", + "react-router-dom": "^5.0.1", + "redux": "^4.0.4" + } + }, + "node_modules/@edx/react-unit-test-utils/node_modules/@edx/paragon": { + "version": "20.46.3", + "resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-20.46.3.tgz", + "integrity": "sha512-cHxoxoOREVFbBqW9IRAtlIAQo1lcF9JJXkLoEw1Vam6oetKSa5Mc0SL5kykbV+1iRPP7kS8A0Csf5nRr0oolLQ==", + "dev": true, + "dependencies": { + "@fortawesome/fontawesome-svg-core": "^6.1.1", + "@fortawesome/react-fontawesome": "^0.1.18", + "@popperjs/core": "^2.11.4", + "bootstrap": "^4.6.2", + "classnames": "^2.3.1", + "email-prop-type": "^3.0.0", + "file-selector": "^0.6.0", + "font-awesome": "^4.7.0", + "glob": "^8.0.3", + "lodash.uniqby": "^4.7.0", + "mailto-link": "^2.0.0", + "prop-types": "^15.8.1", + "react-bootstrap": "^1.6.5", + "react-colorful": "^5.6.1", + "react-dropzone": "^14.2.1", + "react-focus-on": "^3.5.4", + "react-loading-skeleton": "^3.1.0", + "react-popper": "^2.2.5", + "react-proptype-conditional-require": "^1.0.4", + "react-responsive": "^8.2.0", + "react-table": "^7.7.0", + "react-transition-group": "^4.4.2", + "tabbable": "^5.3.3", + "uncontrollable": "^7.2.1", + "uuid": "^9.0.0" + }, + "peerDependencies": { + "react": "^16.8.6 || ^17.0.0", + "react-dom": "^16.8.6 || ^17.0.0", + "react-intl": "^5.25.1" + } + }, + "node_modules/@edx/react-unit-test-utils/node_modules/@edx/paragon/node_modules/glob": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", + "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^5.0.1", + "once": "^1.3.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@edx/react-unit-test-utils/node_modules/@fortawesome/fontawesome-common-types": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.5.1.tgz", + "integrity": "sha512-GkWzv+L6d2bI5f/Vk6ikJ9xtl7dfXtoRu3YGE6nq0p/FFqA1ebMOAWg3XgRyb0I6LYyYkiAo+3/KrwuBp8xG7A==", + "dev": true, + "hasInstallScript": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/@edx/react-unit-test-utils/node_modules/@fortawesome/fontawesome-svg-core": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.5.1.tgz", + "integrity": "sha512-MfRCYlQPXoLlpem+egxjfkEuP9UQswTrlCOsknus/NcMoblTH2g0jPrapbcIb04KGA7E2GZxbAccGZfWoYgsrQ==", + "dev": true, + "hasInstallScript": true, + "dependencies": { + "@fortawesome/fontawesome-common-types": "6.5.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@edx/react-unit-test-utils/node_modules/@fortawesome/react-fontawesome": { + "version": "0.1.19", + "resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-0.1.19.tgz", + "integrity": "sha512-Hyb+lB8T18cvLNX0S3llz7PcSOAJMLwiVKBuuzwM/nI5uoBw+gQjnf9il0fR1C3DKOI5Kc79pkJ4/xB0Uw9aFQ==", + "dev": true, + "dependencies": { + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "@fortawesome/fontawesome-svg-core": "~1 || ~6", + "react": ">=16.x" + } + }, + "node_modules/@edx/react-unit-test-utils/node_modules/@testing-library/dom": { + "version": "9.3.3", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.3.tgz", + "integrity": "sha512-fB0R+fa3AUqbLHWyxXa2kGVtf1Fe1ZZFr0Zp6AIbIAzXb2mKbEXl+PCQNUOaq5lbTab5tfctfXRNsWXxa2f7Aw==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.1.3", + "chalk": "^4.1.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@edx/react-unit-test-utils/node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true + }, + "node_modules/@edx/react-unit-test-utils/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@edx/react-unit-test-utils/node_modules/aria-query": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz", + "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==", + "dev": true, + "dependencies": { + "deep-equal": "^2.0.5" + } + }, + "node_modules/@edx/react-unit-test-utils/node_modules/axios": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.27.2.tgz", + "integrity": "sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==", + "dev": true, + "dependencies": { + "follow-redirects": "^1.14.9", + "form-data": "^4.0.0" + } + }, + "node_modules/@edx/react-unit-test-utils/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@edx/react-unit-test-utils/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@edx/react-unit-test-utils/node_modules/classnames": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.4.0.tgz", + "integrity": "sha512-lWxiIlphgAhTLN657pwU/ofFxsUTOWc2CRIFeoV5st0MGRJHStUnWIUJgDHxjUO/F0mXzGufXIM4Lfu/8h+MpA==", + "dev": true + }, + "node_modules/@edx/react-unit-test-utils/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/@edx/react-unit-test-utils/node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true }, - "node_modules/@edx/paragon/node_modules/glob": { - "version": "8.0.3", - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^5.0.1", - "once": "^1.3.0" - }, - "engines": { - "node": ">=12" - }, + "node_modules/@edx/react-unit-test-utils/node_modules/core-js": { + "version": "3.6.5", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.6.5.tgz", + "integrity": "sha512-vZVEEwZoIsI+vPEuoF9Iqf5H7/M3eeQqWlQnYa8FSKKePuYTf5MWnxb5SDAzCa60b3JBRS5g9b+Dq7b1y/RCrA==", + "deprecated": "core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.", + "dev": true, + "hasInstallScript": true, "funding": { - "url": "https://github.com/sponsors/isaacs" + "type": "opencollective", + "url": "https://opencollective.com/core-js" } }, - "node_modules/@edx/paragon/node_modules/has-flag": { + "node_modules/@edx/react-unit-test-utils/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, "engines": { "node": ">=8" } }, - "node_modules/@edx/paragon/node_modules/minimatch": { - "version": "5.1.0", - "license": "ISC", + "node_modules/@edx/react-unit-test-utils/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dev": true, "dependencies": { "brace-expansion": "^2.0.1" }, @@ -3093,18 +3472,22 @@ "node": ">=10" } }, - "node_modules/@edx/paragon/node_modules/prop-types": { + "node_modules/@edx/react-unit-test-utils/node_modules/prop-types": { "version": "15.8.1", - "license": "MIT", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, - "node_modules/@edx/paragon/node_modules/react-responsive": { + "node_modules/@edx/react-unit-test-utils/node_modules/react-responsive": { "version": "8.2.0", - "license": "MIT", + "resolved": "https://registry.npmjs.org/react-responsive/-/react-responsive-8.2.0.tgz", + "integrity": "sha512-iagCqVrw4QSjhxKp3I/YK6+ODkWY6G+YPElvdYKiUUbywwh9Ds0M7r26Fj2/7dWFFbOpcGnJE6uE7aMck8j5Qg==", + "dev": true, "dependencies": { "hyphenate-style-name": "^1.0.0", "matchmediaquery": "^0.3.0", @@ -3118,10 +3501,51 @@ "react": ">=16.8.0" } }, - "node_modules/@edx/paragon/node_modules/supports-color": { + "node_modules/@edx/react-unit-test-utils/node_modules/react-router": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-5.3.4.tgz", + "integrity": "sha512-Ys9K+ppnJah3QuaRiLxk+jDWOR1MekYQrlytiXxC1RyfbdsZkS5pvKAzCCr031xHixZwpnsYNT5xysdFHQaYsA==", + "dev": true, + "peer": true, + "dependencies": { + "@babel/runtime": "^7.12.13", + "history": "^4.9.0", + "hoist-non-react-statics": "^3.1.0", + "loose-envify": "^1.3.1", + "path-to-regexp": "^1.7.0", + "prop-types": "^15.6.2", + "react-is": "^16.6.0", + "tiny-invariant": "^1.0.2", + "tiny-warning": "^1.0.0" + }, + "peerDependencies": { + "react": ">=15" + } + }, + "node_modules/@edx/react-unit-test-utils/node_modules/react-router-dom": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-5.3.4.tgz", + "integrity": "sha512-m4EqFMHv/Ih4kpcBCONHbkT68KoAeHN4p3lAGoNryfHi0dMy0kCzEZakiKRsvg5wHZ/JLrLW8o8KomWiz/qbYQ==", + "dev": true, + "peer": true, + "dependencies": { + "@babel/runtime": "^7.12.13", + "history": "^4.9.0", + "loose-envify": "^1.3.1", + "prop-types": "^15.6.2", + "react-router": "5.3.4", + "tiny-invariant": "^1.0.2", + "tiny-warning": "^1.0.0" + }, + "peerDependencies": { + "react": ">=15" + } + }, + "node_modules/@edx/react-unit-test-utils/node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -3129,9 +3553,15 @@ "node": ">=8" } }, - "node_modules/@edx/paragon/node_modules/uuid": { - "version": "9.0.0", - "license": "MIT", + "node_modules/@edx/react-unit-test-utils/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], "bin": { "uuid": "dist/bin/uuid" } @@ -6905,14 +7335,6 @@ "@types/node": "*" } }, - "node_modules/@types/cheerio": { - "version": "0.22.31", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/connect": { "version": "3.4.36", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.36.tgz", @@ -7491,52 +7913,6 @@ } } }, - "node_modules/@wojtekmaj/enzyme-adapter-react-17": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/@wojtekmaj/enzyme-adapter-react-17/-/enzyme-adapter-react-17-0.8.0.tgz", - "integrity": "sha512-zeUGfQRziXW7R7skzNuJyi01ZwuKCH8WiBNnTgUJwdS/CURrJwAhWsfW7nG7E30ak8Pu3ZwD9PlK9skBfAoOBw==", - "dev": true, - "dependencies": { - "@wojtekmaj/enzyme-adapter-utils": "^0.2.0", - "enzyme-shallow-equal": "^1.0.0", - "has": "^1.0.0", - "prop-types": "^15.7.0", - "react-is": "^17.0.0", - "react-test-renderer": "^17.0.0" - }, - "funding": { - "url": "https://github.com/wojtekmaj/enzyme-adapter-react-17?sponsor=1" - }, - "peerDependencies": { - "enzyme": "^3.0.0", - "react": "^17.0.0-0", - "react-dom": "^17.0.0-0" - } - }, - "node_modules/@wojtekmaj/enzyme-adapter-react-17/node_modules/react-is": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true - }, - "node_modules/@wojtekmaj/enzyme-adapter-utils": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@wojtekmaj/enzyme-adapter-utils/-/enzyme-adapter-utils-0.2.0.tgz", - "integrity": "sha512-ZvZm9kZxZEKAbw+M1/Q3iDuqQndVoN8uLnxZ8bzxm7KgGTBejrGRoJAp8f1EN8eoO3iAjBNEQnTDW/H4Ekb0FQ==", - "dev": true, - "dependencies": { - "function.prototype.name": "^1.1.0", - "has": "^1.0.0", - "object.fromentries": "^2.0.0", - "prop-types": "^15.7.0" - }, - "funding": { - "url": "https://github.com/wojtekmaj/enzyme-adapter-utils?sponsor=1" - }, - "peerDependencies": { - "react": "^17.0.0-0" - } - }, "node_modules/@xtuc/ieee754": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", @@ -7874,24 +8250,6 @@ "node": ">=0.10.0" } }, - "node_modules/array.prototype.filter": { - "version": "1.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.3", - "es-abstract": "^1.19.0", - "es-array-method-boxes-properly": "^1.0.0", - "is-string": "^1.0.7" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/array.prototype.flat": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.2.tgz", @@ -10032,6 +10390,20 @@ "node": ">=8" } }, + "node_modules/broadcast-channel": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/broadcast-channel/-/broadcast-channel-7.0.0.tgz", + "integrity": "sha512-a2tW0Ia1pajcPBOGUF2jXlDnvE9d5/dg6BG9h60OmRUcZVr/veUrU8vEQFwwQIhwG3KVzYwSk3v2nRRGFgQDXQ==", + "dependencies": { + "@babel/runtime": "7.23.4", + "oblivious-set": "1.4.0", + "p-queue": "6.6.2", + "unload": "2.4.1" + }, + "funding": { + "url": "https://github.com/sponsors/pubkey" + } + }, "node_modules/browser-process-hrtime": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz", @@ -10146,11 +10518,13 @@ "license": "MIT" }, "node_modules/call-bind": { - "version": "1.0.2", - "license": "MIT", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.5.tgz", + "integrity": "sha512-C3nQxfFZxFRVoJoGKKI8y3MOEo129NQ+FgQ08iye+Mk4zNZZGdjfs06bVTr+DBSlA66Q2VEcMki/cUCP4SercQ==", "dependencies": { - "function-bind": "^1.1.1", - "get-intrinsic": "^1.0.2" + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.1", + "set-function-length": "^1.1.1" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -10281,42 +10655,6 @@ "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==" }, - "node_modules/cheerio": { - "version": "1.0.0-rc.12", - "dev": true, - "license": "MIT", - "dependencies": { - "cheerio-select": "^2.1.0", - "dom-serializer": "^2.0.0", - "domhandler": "^5.0.3", - "domutils": "^3.0.1", - "htmlparser2": "^8.0.1", - "parse5": "^7.0.0", - "parse5-htmlparser2-tree-adapter": "^7.0.0" - }, - "engines": { - "node": ">= 6" - }, - "funding": { - "url": "https://github.com/cheeriojs/cheerio?sponsor=1" - } - }, - "node_modules/cheerio-select": { - "version": "2.1.0", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "boolbase": "^1.0.0", - "css-select": "^5.1.0", - "css-what": "^6.1.0", - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3", - "domutils": "^3.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/fb55" - } - }, "node_modules/child_process": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/child_process/-/child_process-1.0.2.tgz", @@ -12244,6 +12582,44 @@ "version": "0.3.8", "license": "MIT" }, + "node_modules/deep-equal": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", + "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==", + "dev": true, + "dependencies": { + "array-buffer-byte-length": "^1.0.0", + "call-bind": "^1.0.5", + "es-get-iterator": "^1.1.3", + "get-intrinsic": "^1.2.2", + "is-arguments": "^1.1.1", + "is-array-buffer": "^3.0.2", + "is-date-object": "^1.0.5", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.2", + "isarray": "^2.0.5", + "object-is": "^1.1.5", + "object-keys": "^1.1.1", + "object.assign": "^4.1.4", + "regexp.prototype.flags": "^1.5.1", + "side-channel": "^1.0.4", + "which-boxed-primitive": "^1.0.2", + "which-collection": "^1.0.1", + "which-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/deep-equal/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true + }, "node_modules/deep-extend": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", @@ -12395,9 +12771,9 @@ } }, "node_modules/define-data-property": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.0.tgz", - "integrity": "sha512-UzGwzcjyv3OtAvolTj1GoyNYzfFR+iqbGjcnBEENZVCpM4/Ng1yhGNvS3lR/xDS74Tb2wGG9WzNSNIOS9UVb2g==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.1.tgz", + "integrity": "sha512-E7uGkTzkk1d0ByLeSc6ZsFS79Axg+m1P/VsgYsxHgiuc3tFSj+MjMIwe90FC4lOAZzNBdY7kkO2P2wKdsQ1vgQ==", "dependencies": { "get-intrinsic": "^1.2.1", "gopd": "^1.0.1", @@ -12597,11 +12973,6 @@ "node": ">=8" } }, - "node_modules/discontinuous-range": { - "version": "1.0.0", - "dev": true, - "license": "MIT" - }, "node_modules/dns-equal": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/dns-equal/-/dns-equal-1.0.0.tgz", @@ -12856,66 +13227,6 @@ "node": ">=4" } }, - "node_modules/enzyme": { - "version": "3.11.0", - "dev": true, - "license": "MIT", - "dependencies": { - "array.prototype.flat": "^1.2.3", - "cheerio": "^1.0.0-rc.3", - "enzyme-shallow-equal": "^1.0.1", - "function.prototype.name": "^1.1.2", - "has": "^1.0.3", - "html-element-map": "^1.2.0", - "is-boolean-object": "^1.0.1", - "is-callable": "^1.1.5", - "is-number-object": "^1.0.4", - "is-regex": "^1.0.5", - "is-string": "^1.0.5", - "is-subset": "^0.1.1", - "lodash.escape": "^4.0.1", - "lodash.isequal": "^4.5.0", - "object-inspect": "^1.7.0", - "object-is": "^1.0.2", - "object.assign": "^4.1.0", - "object.entries": "^1.1.1", - "object.values": "^1.1.1", - "raf": "^3.4.1", - "rst-selector-parser": "^2.2.3", - "string.prototype.trim": "^1.2.1" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/enzyme-shallow-equal": { - "version": "1.0.4", - "dev": true, - "license": "MIT", - "dependencies": { - "has": "^1.0.3", - "object-is": "^1.1.2" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/enzyme-to-json": { - "version": "3.6.2", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/cheerio": "^0.22.22", - "lodash": "^4.17.21", - "react-is": "^16.12.0" - }, - "engines": { - "node": ">=6.0.0" - }, - "peerDependencies": { - "enzyme": "^3.4.0" - } - }, "node_modules/error-ex": { "version": "1.3.2", "license": "MIT", @@ -12983,10 +13294,31 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/es-array-method-boxes-properly": { - "version": "1.0.0", + "node_modules/es-get-iterator": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", + "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==", "dev": true, - "license": "MIT" + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.3", + "has-symbols": "^1.0.3", + "is-arguments": "^1.1.1", + "is-map": "^2.0.2", + "is-set": "^2.0.2", + "is-string": "^1.0.7", + "isarray": "^2.0.5", + "stop-iteration-iterator": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-get-iterator/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true }, "node_modules/es-module-lexer": { "version": "1.3.1", @@ -14521,14 +14853,15 @@ } }, "node_modules/follow-redirects": { - "version": "1.15.1", + "version": "1.15.5", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.5.tgz", + "integrity": "sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==", "funding": [ { "type": "individual", "url": "https://github.com/sponsors/RubenVerborgh" } ], - "license": "MIT", "engines": { "node": ">=4.0" }, @@ -16531,8 +16864,12 @@ } }, "node_modules/function-bind": { - "version": "1.1.1", - "license": "MIT" + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, "node_modules/function.prototype.name": { "version": "1.1.5", @@ -16572,14 +16909,14 @@ } }, "node_modules/get-intrinsic": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.1.tgz", - "integrity": "sha512-2DcsyfABl+gVHEfCOaTrWgyt+tb6MSEGmKq+kI5HwLbIYgjgmMcV8KQ41uaKz1xxUcn9tJtgFbQUEVcEbd0FYw==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.2.tgz", + "integrity": "sha512-0gSo4ml/0j98Y3lngkFEot/zhiCeWsbYIlZ+uZOVgzLyLaUw7wxUL+nCTP0XJvJg1AXulJRI3UJi8GsbDuxdGA==", "dependencies": { - "function-bind": "^1.1.1", - "has": "^1.0.3", + "function-bind": "^1.1.2", "has-proto": "^1.0.1", - "has-symbols": "^1.0.3" + "has-symbols": "^1.0.3", + "hasown": "^2.0.0" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -16938,6 +17275,17 @@ "node": ">=0.10.0" } }, + "node_modules/hasown": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz", + "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/he": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", @@ -16972,24 +17320,12 @@ "node_modules/hpack.js": { "version": "2.1.6", "resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz", - "integrity": "sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ==", - "dependencies": { - "inherits": "^2.0.1", - "obuf": "^1.0.0", - "readable-stream": "^2.0.1", - "wbuf": "^1.1.0" - } - }, - "node_modules/html-element-map": { - "version": "1.3.1", - "dev": true, - "license": "MIT", + "integrity": "sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ==", "dependencies": { - "array.prototype.filter": "^1.0.0", - "call-bind": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "inherits": "^2.0.1", + "obuf": "^1.0.0", + "readable-stream": "^2.0.1", + "wbuf": "^1.1.0" } }, "node_modules/html-encoding-sniffer": { @@ -17086,24 +17422,6 @@ "webpack": "^5.20.0" } }, - "node_modules/htmlparser2": { - "version": "8.0.1", - "dev": true, - "funding": [ - "https://github.com/fb55/htmlparser2?sponsor=1", - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "MIT", - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.2", - "domutils": "^3.0.1", - "entities": "^4.3.0" - } - }, "node_modules/http-deceiver": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/http-deceiver/-/http-deceiver-1.2.7.tgz", @@ -17641,6 +17959,22 @@ "node": ">=0.10.0" } }, + "node_modules/is-arguments": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz", + "integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-array-buffer": { "version": "3.0.2", "license": "MIT", @@ -17872,6 +18206,15 @@ "node": ">=0.10.0" } }, + "node_modules/is-map": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.2.tgz", + "integrity": "sha512-cOZFQQozTha1f4MxLFzlgKYPTyj26picdZTx82hbc/Xf4K/tZOOXSCkMvU4pKioRXGDLJRn0GM7Upe7kR721yg==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-negative-zero": { "version": "2.0.2", "license": "MIT", @@ -17987,6 +18330,15 @@ "node": ">=6" } }, + "node_modules/is-set": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.2.tgz", + "integrity": "sha512-+2cnTEZeY5z/iXGbLhPrOAaK/Mau5k5eXq9j14CpRTftq0pAJu2MwVRSZhyZWBzx3o6X795Lz6Bpb6R0GKf37g==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-shared-array-buffer": { "version": "1.0.2", "license": "MIT", @@ -18017,11 +18369,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-subset": { - "version": "0.1.1", - "dev": true, - "license": "MIT" - }, "node_modules/is-symbol": { "version": "1.0.4", "license": "MIT", @@ -18074,6 +18421,15 @@ "node": ">=0.10.0" } }, + "node_modules/is-weakmap": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.1.tgz", + "integrity": "sha512-NSBR4kH5oVj1Uwvv970ruUkCV7O1mzgVFO4/rev2cLRda9Tm9HrL70ZPut4rOHgY0FNrUu9BCbXA2sdQ+x0chA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-weakref": { "version": "1.0.2", "license": "MIT", @@ -18084,6 +18440,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-weakset": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.2.tgz", + "integrity": "sha512-t2yVvttHkQktwnNNmBQ98AhENLdPUTDTE21uPqAQ0ARwQfGeQKRVS0NNurH7bTf7RrvcVn1OOge45CnBeHCSmg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-windows": { "version": "1.0.2", "license": "MIT", @@ -18767,23 +19136,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-circus/node_modules/jest-each": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", - "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", - "optional": true, - "peer": true, - "dependencies": { - "@jest/types": "^29.6.3", - "chalk": "^4.0.0", - "jest-get-type": "^29.6.3", - "jest-util": "^29.7.0", - "pretty-format": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, "node_modules/jest-circus/node_modules/jest-get-type": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", @@ -19514,10 +19866,210 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/jest-diff/node_modules/color-convert": { + "node_modules/jest-diff/node_modules/color-convert": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/jest-diff/node_modules/color-name": { + "version": "1.1.4", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-diff/node_modules/has-flag": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-diff/node_modules/jest-get-type": { + "version": "28.0.2", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/jest-diff/node_modules/pretty-format": { + "version": "28.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^28.0.2", + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/jest-diff/node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-diff/node_modules/react-is": { + "version": "18.2.0", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-diff/node_modules/supports-color": { + "version": "7.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-docblock": { + "version": "26.0.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-26.0.0.tgz", + "integrity": "sha512-RDZ4Iz3QbtRWycd8bUEPxQsTlYazfYn/h5R65Fc6gOfwozFhoImx+affzky/FFBuqISPTqjXomoIGJVKBWoo0w==", + "dependencies": { + "detect-newline": "^3.0.0" + }, + "engines": { + "node": ">= 10.14.2" + } + }, + "node_modules/jest-each": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", + "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", + "optional": true, + "peer": true, + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "jest-util": "^29.7.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-each/node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "optional": true, + "peer": true, + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-each/node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "optional": true, + "peer": true, + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-each/node_modules/@sinclair/typebox": { + "version": "0.27.8", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", + "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", + "optional": true, + "peer": true + }, + "node_modules/jest-each/node_modules/@types/yargs": { + "version": "17.0.32", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", + "integrity": "sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==", + "optional": true, + "peer": true, + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/jest-each/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "optional": true, + "peer": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-each/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "optional": true, + "peer": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-each/node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "optional": true, + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-each/node_modules/color-convert": { "version": "2.0.1", - "dev": true, - "license": "MIT", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "optional": true, + "peer": true, "dependencies": { "color-name": "~1.1.4" }, @@ -19525,45 +20077,72 @@ "node": ">=7.0.0" } }, - "node_modules/jest-diff/node_modules/color-name": { + "node_modules/jest-each/node_modules/color-name": { "version": "1.1.4", - "dev": true, - "license": "MIT" + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "optional": true, + "peer": true }, - "node_modules/jest-diff/node_modules/has-flag": { + "node_modules/jest-each/node_modules/has-flag": { "version": "4.0.0", - "dev": true, - "license": "MIT", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "optional": true, + "peer": true, "engines": { "node": ">=8" } }, - "node_modules/jest-diff/node_modules/jest-get-type": { - "version": "28.0.2", - "dev": true, - "license": "MIT", + "node_modules/jest-each/node_modules/jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "optional": true, + "peer": true, "engines": { - "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-diff/node_modules/pretty-format": { - "version": "28.1.1", - "dev": true, - "license": "MIT", + "node_modules/jest-each/node_modules/jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "optional": true, + "peer": true, "dependencies": { - "@jest/schemas": "^28.0.2", - "ansi-regex": "^5.0.1", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-each/node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "optional": true, + "peer": true, + "dependencies": { + "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" }, "engines": { - "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-diff/node_modules/pretty-format/node_modules/ansi-styles": { + "node_modules/jest-each/node_modules/pretty-format/node_modules/ansi-styles": { "version": "5.2.0", - "dev": true, - "license": "MIT", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "optional": true, + "peer": true, "engines": { "node": ">=10" }, @@ -19571,15 +20150,19 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/jest-diff/node_modules/react-is": { + "node_modules/jest-each/node_modules/react-is": { "version": "18.2.0", - "dev": true, - "license": "MIT" + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", + "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", + "optional": true, + "peer": true }, - "node_modules/jest-diff/node_modules/supports-color": { + "node_modules/jest-each/node_modules/supports-color": { "version": "7.2.0", - "dev": true, - "license": "MIT", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "optional": true, + "peer": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -19587,17 +20170,6 @@ "node": ">=8" } }, - "node_modules/jest-docblock": { - "version": "26.0.0", - "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-26.0.0.tgz", - "integrity": "sha512-RDZ4Iz3QbtRWycd8bUEPxQsTlYazfYn/h5R65Fc6gOfwozFhoImx+affzky/FFBuqISPTqjXomoIGJVKBWoo0w==", - "dependencies": { - "detect-newline": "^3.0.0" - }, - "engines": { - "node": ">= 10.14.2" - } - }, "node_modules/jest-environment-jsdom": { "version": "26.6.2", "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-26.6.2.tgz", @@ -19631,6 +20203,12 @@ "node": ">= 10.14.2" } }, + "node_modules/jest-expect-message": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/jest-expect-message/-/jest-expect-message-1.1.3.tgz", + "integrity": "sha512-bTK77T4P+zto+XepAX3low8XVQxDgaEqh3jSTQOG8qvPpD69LsIdyJTa+RmnJh3HNSzJng62/44RPPc7OIlFxg==", + "dev": true + }, "node_modules/jest-get-type": { "version": "26.3.0", "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-26.3.0.tgz", @@ -21408,22 +21986,12 @@ "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", "peer": true }, - "node_modules/lodash.flattendeep": { - "version": "4.4.0", - "dev": true, - "license": "MIT" - }, "node_modules/lodash.invokemap": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/lodash.invokemap/-/lodash.invokemap-4.6.0.tgz", "integrity": "sha512-CfkycNtMqgUlfjfdh2BhKO/ZXrP8ePOX5lEU/g0R3ItJcnuxWDwokMGKx1hWcfOikmyOVx6X9IwWnDGlgKl61w==", "peer": true }, - "node_modules/lodash.isequal": { - "version": "4.5.0", - "dev": true, - "license": "MIT" - }, "node_modules/lodash.isplainobject": { "version": "4.0.6", "license": "MIT" @@ -22004,11 +22572,6 @@ "moment": "^2.4.0" } }, - "node_modules/moo": { - "version": "0.5.1", - "dev": true, - "license": "BSD-3-Clause" - }, "node_modules/moo-color": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/moo-color/-/moo-color-1.0.3.tgz", @@ -22135,32 +22698,6 @@ "version": "1.4.0", "license": "MIT" }, - "node_modules/nearley": { - "version": "2.20.1", - "dev": true, - "license": "MIT", - "dependencies": { - "commander": "^2.19.0", - "moo": "^0.5.0", - "railroad-diagrams": "^1.0.0", - "randexp": "0.4.6" - }, - "bin": { - "nearley-railroad": "bin/nearley-railroad.js", - "nearley-test": "bin/nearley-test.js", - "nearley-unparse": "bin/nearley-unparse.js", - "nearleyc": "bin/nearleyc.js" - }, - "funding": { - "type": "individual", - "url": "https://nearley.js.org/#give-to-nearley" - } - }, - "node_modules/nearley/node_modules/commander": { - "version": "2.20.3", - "dev": true, - "license": "MIT" - }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -22588,6 +23125,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/oblivious-set": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/oblivious-set/-/oblivious-set-1.4.0.tgz", + "integrity": "sha512-szyd0ou0T8nsAqHtprRcP3WidfsN1TnAR5yWXf2mFCEr5ek3LEOkT6EZ/92Xfs74HIdyhG5WkGxIssMU0jBaeg==", + "engines": { + "node": ">=16" + } + }, "node_modules/obuf": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", @@ -22817,6 +23362,21 @@ "node": ">=6" } }, + "node_modules/p-queue": { + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz", + "integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==", + "dependencies": { + "eventemitter3": "^4.0.4", + "p-timeout": "^3.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/p-retry": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", @@ -22829,6 +23389,17 @@ "node": ">=8" } }, + "node_modules/p-timeout": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz", + "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==", + "dependencies": { + "p-finally": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/p-try": { "version": "2.2.0", "license": "MIT", @@ -22876,29 +23447,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/parse5": { - "version": "7.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "entities": "^4.3.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/parse5-htmlparser2-tree-adapter": { - "version": "7.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "domhandler": "^5.0.2", - "parse5": "^7.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -22953,6 +23501,16 @@ "version": "1.0.7", "license": "MIT" }, + "node_modules/path-to-regexp": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz", + "integrity": "sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==", + "dev": true, + "peer": true, + "dependencies": { + "isarray": "0.0.1" + } + }, "node_modules/path-type": { "version": "4.0.0", "license": "MIT", @@ -22960,11 +23518,6 @@ "node": ">=8" } }, - "node_modules/performance-now": { - "version": "2.1.0", - "dev": true, - "license": "MIT" - }, "node_modules/picocolors": { "version": "1.0.0", "license": "ISC" @@ -24172,31 +24725,6 @@ "node": ">=8" } }, - "node_modules/raf": { - "version": "3.4.1", - "dev": true, - "license": "MIT", - "dependencies": { - "performance-now": "^2.1.0" - } - }, - "node_modules/railroad-diagrams": { - "version": "1.0.0", - "dev": true, - "license": "CC0-1.0" - }, - "node_modules/randexp": { - "version": "0.4.6", - "dev": true, - "license": "MIT", - "dependencies": { - "discontinuous-range": "1.0.0", - "ret": "~0.1.10" - }, - "engines": { - "node": ">=0.12" - } - }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -25204,13 +25732,13 @@ "license": "MIT" }, "node_modules/regexp.prototype.flags": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.0.tgz", - "integrity": "sha512-0SutC3pNudRKgquxGoRGIz946MZVHqbNfPjBdxeOhBrdgDKlRoXmYLQN9xRbrR09ZXWeGAdPuif7egofn6v5LA==", + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.1.tgz", + "integrity": "sha512-sy6TXMN+hnP/wMy+ISxg3krXx7BAtWVO4UouuCN/ziM9UEne0euamVNafDfvC83bRNr95y0V5iijeDQFUNpvrg==", "dependencies": { "call-bind": "^1.0.2", "define-properties": "^1.2.0", - "functions-have-names": "^1.2.3" + "set-function-name": "^2.0.0" }, "engines": { "node": ">= 0.4" @@ -25518,15 +26046,6 @@ "rimraf": "bin.js" } }, - "node_modules/rst-selector-parser": { - "version": "2.2.3", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "lodash.flattendeep": "^4.4.0", - "nearley": "^2.7.10" - } - }, "node_modules/rsvp": { "version": "4.8.5", "license": "MIT", @@ -26056,6 +26575,33 @@ "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==" }, + "node_modules/set-function-length": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.1.1.tgz", + "integrity": "sha512-VoaqjbBJKiWtg4yRcKBQ7g7wnGnLV3M8oLvVWwOk2PdYY6PEFegR1vezXR0tw6fZGF9csVakIRjrJiy2veSBFQ==", + "dependencies": { + "define-data-property": "^1.1.1", + "get-intrinsic": "^1.2.1", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.1.tgz", + "integrity": "sha512-tMNCiqYVkXIZgc2Hnoy2IvC/f8ezc5koaRFkCjrpWzGpCd3qbZXPzVy9MAZzK1ch/X0jvSkojys3oqJN0qCmdA==", + "dependencies": { + "define-data-property": "^1.0.1", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/set-value": { "version": "2.0.1", "license": "MIT", @@ -26816,6 +27362,18 @@ "node": ">= 0.8" } }, + "node_modules/stop-iteration-iterator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz", + "integrity": "sha512-iCGQj+0l0HOdZ2AEeBADlsRC+vsnDsZsbdSiH1yNSjcfKM7fdpCMfqAL/dwF5BLiw/XhRft/Wax6zQbhq2BcjQ==", + "dev": true, + "dependencies": { + "internal-slot": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/streamx": { "version": "2.15.1", "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.15.1.tgz", @@ -28039,6 +28597,14 @@ "node": ">= 10.0.0" } }, + "node_modules/unload": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/unload/-/unload-2.4.1.tgz", + "integrity": "sha512-IViSAm8Z3sRBYA+9wc0fLQmU9Nrxb16rcDmIiR6Y9LJSZzI7QY5QsDhqPpKOjAn0O9/kfK1TfNEMMAGPTIraPw==", + "funding": { + "url": "https://github.com/sponsors/pubkey" + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -29018,18 +29584,33 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/which-collection": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.1.tgz", + "integrity": "sha512-W8xeTUwaln8i3K/cY1nGXzdnVZlidBcagyNFtBdD5kxnb4TvGKR7FfSIS3mYpwWS1QUCutfKz8IY8RjftB0+1A==", + "dev": true, + "dependencies": { + "is-map": "^2.0.1", + "is-set": "^2.0.1", + "is-weakmap": "^2.0.1", + "is-weakset": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/which-module": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==" }, "node_modules/which-typed-array": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.11.tgz", - "integrity": "sha512-qe9UWWpkeG5yzZ0tNYxDmd7vo58HDBc39mZ0xWWpolAGADdFOzkfamWLDxkOWcvHQKVmdTyQdLD4NOfjLWTKew==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.13.tgz", + "integrity": "sha512-P5Nra0qjSncduVPEAr7xhoF5guty49ArDTwzJ/yNuPIbZppyRxFQsRCWrocxIY+CnMVG+qfbU2FmDKyvSGClow==", "dependencies": { "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.2", + "call-bind": "^1.0.4", "for-each": "^0.3.3", "gopd": "^1.0.1", "has-tostringtag": "^1.0.0" diff --git a/package.json b/package.json index a16424fe6b..ffeddec949 100644 --- a/package.json +++ b/package.json @@ -15,10 +15,10 @@ "stylelint": "stylelint \"src/**/*.scss\" \"scss/**/*.scss\" --config .stylelintrc.json", "lint": "npm run stylelint && fedx-scripts eslint --ext .js --ext .jsx .", "lint:fix": "npm run stylelint && fedx-scripts eslint --ext .js --ext .jsx . --fix", - "snapshot": "fedx-scripts jest --updateSnapshot", + "snapshot": "TZ=UTC fedx-scripts jest --updateSnapshot", "start": "fedx-scripts webpack-dev-server --progress", "start:with-theme": "paragon install-theme && npm start && npm install", - "test": "fedx-scripts jest --coverage --passWithNoTests", + "test": "TZ=UTC fedx-scripts jest --coverage --passWithNoTests", "types": "tsc --noEmit" }, "husky": { @@ -36,13 +36,15 @@ "url": "https://github.com/openedx/frontend-app-course-authoring/issues" }, "dependencies": { + "@dnd-kit/sortable": "^8.0.0", "@edx/brand": "npm:@openedx/brand-openedx@^1.2.2", - "@edx/frontend-component-ai-translations-edx": "^1.4.2", + "@edx/frontend-component-ai-translations": "^1.4.0", "@edx/frontend-component-footer": "^12.3.0", "@edx/frontend-component-header": "^4.7.0", "@edx/frontend-enterprise-hotjar": "^2.0.0", - "@edx/frontend-lib-content-components": "^1.177.8", + "@edx/frontend-lib-content-components": "^1.178.2", "@edx/frontend-platform": "5.6.1", + "@edx/openedx-atlas": "^0.6.0", "@openedx/paragon": "^21.11.3", "@fortawesome/fontawesome-svg-core": "1.2.36", "@fortawesome/free-brands-svg-icons": "5.15.4", @@ -51,6 +53,7 @@ "@fortawesome/react-fontawesome": "0.2.0", "@reduxjs/toolkit": "1.9.7", "@tanstack/react-query": "4.36.1", + "broadcast-channel": "^7.0.0", "classnames": "2.2.6", "core-js": "3.8.1", "email-validator": "2.0.4", @@ -78,7 +81,8 @@ }, "devDependencies": { "@edx/browserslist-config": "1.2.0", - "@openedx/frontend-build": "13.0.19", + "@edx/frontend-build": "13.0.5", + "@edx/react-unit-test-utils": "^1.7.0", "@edx/reactifex": "^1.0.3", "@edx/stylelint-config-edx": "^2.3.0", "@edx/typescript-config": "^1.0.1", @@ -86,13 +90,11 @@ "@testing-library/react": "12.1.5", "@testing-library/react-hooks": "^8.0.1", "@testing-library/user-event": "^13.2.1", - "@wojtekmaj/enzyme-adapter-react-17": "0.8.0", "axios-mock-adapter": "1.22.0", - "enzyme": "3.11.0", - "enzyme-to-json": "^3.6.2", "glob": "7.2.3", "husky": "7.0.4", "jest-canvas-mock": "^2.5.2", + "jest-expect-message": "^1.1.3", "react-test-renderer": "17.0.2", "reactifex": "1.1.1", "ts-loader": "^9.5.0" diff --git a/src/CourseAuthoringPage.jsx b/src/CourseAuthoringPage.jsx index b11dcc7948..c442f94065 100644 --- a/src/CourseAuthoringPage.jsx +++ b/src/CourseAuthoringPage.jsx @@ -9,6 +9,7 @@ import { StudioFooter } from '@edx/frontend-component-footer'; import Header from './header'; import { fetchCourseDetail } from './data/thunks'; import { useModel } from './generic/model-store'; +import NotFoundAlert from './generic/NotFoundAlert'; import PermissionDeniedAlert from './generic/PermissionDeniedAlert'; import { getCourseAppsApiStatus } from './pages-and-resources/data/selectors'; import { RequestStatus } from './data/constants'; @@ -50,10 +51,16 @@ const CourseAuthoringPage = ({ courseId, children }) => { const courseOrg = courseDetail ? courseDetail.org : null; const courseTitle = courseDetail ? courseDetail.name : courseId; const courseAppsApiStatus = useSelector(getCourseAppsApiStatus); - const inProgress = useSelector(state => state.courseDetail.status) === RequestStatus.IN_PROGRESS; + const courseDetailStatus = useSelector(state => state.courseDetail.status); + const inProgress = courseDetailStatus === RequestStatus.IN_PROGRESS; const { pathname } = useLocation(); - const showHeader = !pathname.includes('/editor'); + const isEditor = pathname.includes('/editor'); + if (courseDetailStatus === RequestStatus.NOT_FOUND && !isEditor) { + return ( + + ); + } if (courseAppsApiStatus === RequestStatus.DENIED) { return ( @@ -65,8 +72,8 @@ const CourseAuthoringPage = ({ courseId, children }) => { using url pattern containing /editor/, we shouldn't have the header and footer on these pages. This functionality will be removed in TNL-9591 */} - {inProgress ? showHeader && - : (showHeader && ( + {inProgress ? !isEditor && + : (!isEditor && ( { ) )} {children} - {!inProgress && showHeader && } + {!inProgress && !isEditor && } ); }; diff --git a/src/CourseAuthoringPage.test.jsx b/src/CourseAuthoringPage.test.jsx index 3e982c5929..c7eeeb9be8 100644 --- a/src/CourseAuthoringPage.test.jsx +++ b/src/CourseAuthoringPage.test.jsx @@ -12,6 +12,7 @@ import CourseAuthoringPage from './CourseAuthoringPage'; import PagesAndResources from './pages-and-resources/PagesAndResources'; import { executeThunk } from './utils'; import { fetchCourseApps } from './pages-and-resources/data/thunks'; +import { fetchCourseDetail } from './data/thunks'; const courseId = 'course-v1:edX+TestX+Test_Course'; let mockPathname = '/evilguy/'; @@ -24,6 +25,19 @@ jest.mock('react-router-dom', () => ({ let axiosMock; let store; +beforeEach(() => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + store = initializeStore(); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); +}); + describe('Editor Pages Load no header', () => { const mockStoreSuccess = async () => { const apiBaseUrl = getConfig().STUDIO_BASE_URL; @@ -33,18 +47,6 @@ describe('Editor Pages Load no header', () => { }); await executeThunk(fetchCourseApps(courseId), store.dispatch); }; - beforeEach(() => { - initializeMockApp({ - authenticatedUser: { - userId: 3, - username: 'abc123', - administrator: true, - roles: [], - }, - }); - store = initializeStore(); - axiosMock = new MockAdapter(getAuthenticatedHttpClient()); - }); test('renders no loading wheel on editor pages', async () => { mockPathname = '/editor/'; await mockStoreSuccess(); @@ -76,3 +78,56 @@ describe('Editor Pages Load no header', () => { expect(wrapper.queryByRole('status')).toBeInTheDocument(); }); }); + +describe('Course authoring page', () => { + const lmsApiBaseUrl = getConfig().LMS_BASE_URL; + const courseDetailApiUrl = `${lmsApiBaseUrl}/api/courses/v1/courses`; + const mockStoreNotFound = async () => { + axiosMock.onGet( + `${courseDetailApiUrl}/${courseId}?username=abc123`, + ).reply(404, { + response: { status: 404 }, + }); + await executeThunk(fetchCourseDetail(courseId), store.dispatch); + }; + const mockStoreError = async () => { + axiosMock.onGet( + `${courseDetailApiUrl}/${courseId}?username=abc123`, + ).reply(500, { + response: { status: 500 }, + }); + await executeThunk(fetchCourseDetail(courseId), store.dispatch); + }; + test('renders not found page on non-existent course key', async () => { + await mockStoreNotFound(); + const wrapper = render( + + + + + + , + ); + expect(await wrapper.findByTestId('notFoundAlert')).toBeInTheDocument(); + }); + test('does not render not found page on other kinds of error', async () => { + await mockStoreError(); + // Currently, loading errors are not handled, so we wait for the child + // content to be rendered -which happens when request status is no longer + // IN_PROGRESS but also not NOT_FOUND or DENIED- then check that the not + // found alert is not present. + const contentTestId = 'courseAuthoringPageContent'; + const wrapper = render( + + + +
+ + + + , + ); + expect(await wrapper.findByTestId(contentTestId)).toBeInTheDocument(); + expect(wrapper.queryByTestId('notFoundAlert')).not.toBeInTheDocument(); + }); +}); diff --git a/src/CourseAuthoringRoutes.jsx b/src/CourseAuthoringRoutes.jsx index 6ff7f475bd..bdeffa1108 100644 --- a/src/CourseAuthoringRoutes.jsx +++ b/src/CourseAuthoringRoutes.jsx @@ -2,8 +2,8 @@ import React from 'react'; import { Navigate, Routes, Route, useParams, } from 'react-router-dom'; +import { getConfig } from '@edx/frontend-platform'; import { PageWrap } from '@edx/frontend-platform/react'; -import Placeholder from '@edx/frontend-lib-content-components'; import CourseAuthoringPage from './CourseAuthoringPage'; import { PagesAndResources } from './pages-and-resources'; import EditorContainer from './editors/EditorContainer'; @@ -16,8 +16,10 @@ import ScheduleAndDetails from './schedule-and-details'; import { GradingSettings } from './grading-settings'; import CourseTeam from './course-team/CourseTeam'; import { CourseUpdates } from './course-updates'; +import { CourseUnit } from './course-unit'; import CourseExportPage from './export-page/CourseExportPage'; import CourseImportPage from './import-page/CourseImportPage'; +import { DECODED_ROUTES } from './constants'; /** * As of this writing, these routes are mounted at a path prefixed with the following: @@ -55,7 +57,7 @@ const CourseAuthoringRoutes = () => { /> : null} + element={getConfig().ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN === 'true' ? : null} /> { path="custom-pages/*" element={} /> - : null} - /> + {DECODED_ROUTES.COURSE_UNIT.map((path) => ( + } + /> + ))} : null} + element={getConfig().ENABLE_NEW_EDITOR_PAGES === 'true' ? : null} /> : null} + element={getConfig().ENABLE_NEW_EDITOR_PAGES === 'true' ? : null} /> { +const ContentTagsCollapsible = ({ contentId, taxonomyAndTagsData }) => { const intl = useIntl(); - const { id, name } = taxonomyAndTagsData; + const { id, name, canTagObject } = taxonomyAndTagsData; const { tagChangeHandler, tagsTree, contentTagsCount, checkedTags, @@ -124,7 +123,8 @@ const ContentTagsCollapsible = ({ contentId, taxonomyAndTagsData, editable }) => const handleSearchChange = React.useCallback((value) => { if (value === '') { - // No need to debounce when search term cleared + // No need to debounce when search term cleared. Clear debounce function + handleSearch.cancel(); setSearchTerm(''); } else { handleSearch(value); @@ -141,12 +141,12 @@ const ContentTagsCollapsible = ({ contentId, taxonomyAndTagsData, editable }) =>
- +
- {editable && ( + {canTagObject && (
); }; diff --git a/src/content-tags-drawer/ContentTagsDropDownSelector.test.jsx b/src/content-tags-drawer/ContentTagsDropDownSelector.test.jsx index ac24a39e9a..7800e99e77 100644 --- a/src/content-tags-drawer/ContentTagsDropDownSelector.test.jsx +++ b/src/content-tags-drawer/ContentTagsDropDownSelector.test.jsx @@ -6,7 +6,6 @@ import { waitFor, fireEvent, } from '@testing-library/react'; -import PropTypes from 'prop-types'; import ContentTagsDropDownSelector from './ContentTagsDropDownSelector'; import { useTaxonomyTagsData } from './data/apiHooks'; @@ -14,11 +13,11 @@ import { useTaxonomyTagsData } from './data/apiHooks'; jest.mock('./data/apiHooks', () => ({ useTaxonomyTagsData: jest.fn(() => ({ hasMorePages: false, - tagPages: [{ + tagPages: { isLoading: true, isError: false, data: [], - }], + }, })), })); @@ -47,18 +46,7 @@ ContentTagsDropDownSelectorComponent.defaultProps = { searchTerm: '', }; -ContentTagsDropDownSelectorComponent.propTypes = { - taxonomyId: PropTypes.number.isRequired, - level: PropTypes.number.isRequired, - lineage: PropTypes.arrayOf(PropTypes.string), - tagsTree: PropTypes.objectOf( - PropTypes.shape({ - explicit: PropTypes.bool.isRequired, - children: PropTypes.shape({}).isRequired, - }).isRequired, - ).isRequired, - searchTerm: PropTypes.string, -}; +ContentTagsDropDownSelectorComponent.propTypes = ContentTagsDropDownSelector.propTypes; describe('', () => { afterEach(() => { @@ -82,7 +70,7 @@ describe('', () => { it('should render taxonomy tags drop down selector with no sub tags', async () => { useTaxonomyTagsData.mockReturnValue({ hasMorePages: false, - tagPages: [{ + tagPages: { isLoading: false, isError: false, data: [{ @@ -94,7 +82,7 @@ describe('', () => { id: 12345, subTagsUrl: null, }], - }], + }, }); await act(async () => { @@ -116,7 +104,7 @@ describe('', () => { it('should render taxonomy tags drop down selector with sub tags', async () => { useTaxonomyTagsData.mockReturnValue({ hasMorePages: false, - tagPages: [{ + tagPages: { isLoading: false, isError: false, data: [{ @@ -128,7 +116,7 @@ describe('', () => { id: 12345, subTagsUrl: 'http://localhost:18010/api/content_tagging/v1/taxonomies/4/tags/?parent_tag=Tag%202', }], - }], + }, }); await act(async () => { @@ -149,7 +137,7 @@ describe('', () => { it('should expand on click taxonomy tags drop down selector with sub tags', async () => { useTaxonomyTagsData.mockReturnValueOnce({ hasMorePages: false, - tagPages: [{ + tagPages: { isLoading: false, isError: false, data: [{ @@ -161,7 +149,7 @@ describe('', () => { id: 12345, subTagsUrl: 'http://localhost:18010/api/content_tagging/v1/taxonomies/4/tags/?parent_tag=Tag%202', }], - }], + }, }); await act(async () => { @@ -189,7 +177,7 @@ describe('', () => { // Mock useTaxonomyTagsData again since it gets called in the recursive call useTaxonomyTagsData.mockReturnValueOnce({ hasMorePages: false, - tagPages: [{ + tagPages: { isLoading: false, isError: false, data: [{ @@ -201,7 +189,7 @@ describe('', () => { id: 12346, subTagsUrl: null, }], - }], + }, }); // Expand the dropdown to see the subtags selectors @@ -217,7 +205,7 @@ describe('', () => { it('should expand on enter key taxonomy tags drop down selector with sub tags', async () => { useTaxonomyTagsData.mockReturnValueOnce({ hasMorePages: false, - tagPages: [{ + tagPages: { isLoading: false, isError: false, data: [{ @@ -229,7 +217,7 @@ describe('', () => { id: 12345, subTagsUrl: 'http://localhost:18010/api/content_tagging/v1/taxonomies/4/tags/?parent_tag=Tag%202', }], - }], + }, }); await act(async () => { @@ -257,7 +245,7 @@ describe('', () => { // Mock useTaxonomyTagsData again since it gets called in the recursive call useTaxonomyTagsData.mockReturnValueOnce({ hasMorePages: false, - tagPages: [{ + tagPages: { isLoading: false, isError: false, data: [{ @@ -269,7 +257,7 @@ describe('', () => { id: 12346, subTagsUrl: null, }], - }], + }, }); // Expand the dropdown to see the subtags selectors @@ -285,9 +273,10 @@ describe('', () => { it('should render taxonomy tags drop down selector and change search term', async () => { useTaxonomyTagsData.mockReturnValueOnce({ hasMorePages: false, - tagPages: [{ + tagPages: { isLoading: false, isError: false, + isSuccess: true, data: [{ value: 'Tag 1', externalId: null, @@ -297,7 +286,7 @@ describe('', () => { id: 12345, subTagsUrl: null, }], - }], + }, }); const initalSearchTerm = 'test 1'; @@ -328,6 +317,52 @@ describe('', () => { await waitFor(() => { expect(useTaxonomyTagsData).toBeCalledWith(data.taxonomyId, null, 1, updatedSearchTerm); }); + + // Clean search term + const cleanSearchTerm = ''; + rerender(); + + await waitFor(() => { + expect(useTaxonomyTagsData).toBeCalledWith(data.taxonomyId, null, 1, cleanSearchTerm); + }); + }); + }); + + it('should render "noTag" message if search doesnt return taxonomies', async () => { + useTaxonomyTagsData.mockReturnValueOnce({ + hasMorePages: false, + tagPages: { + isLoading: false, + isError: false, + isSuccess: true, + data: [], + }, + }); + + const searchTerm = 'uncommon search term'; + await act(async () => { + const { getByText } = render( + , + ); + + await waitFor(() => { + expect(useTaxonomyTagsData).toBeCalledWith(data.taxonomyId, null, 1, searchTerm); + }); + + const message = `No tags found with the search term "${searchTerm}"`; + expect(getByText(message)).toBeInTheDocument(); }); }); }); diff --git a/src/content-tags-drawer/ContentTagsTree.jsx b/src/content-tags-drawer/ContentTagsTree.jsx index 7066ca8693..40695a2f96 100644 --- a/src/content-tags-drawer/ContentTagsTree.jsx +++ b/src/content-tags-drawer/ContentTagsTree.jsx @@ -41,9 +41,8 @@ import TagBubble from './TagBubble'; * tagSelectableBoxValue: string, * checked: boolean * ) => void} props.removeTagHandler - Function that is called when removing tags from the tree. - * @param {boolean} props.editable - Whether the tags appear with an 'x' allowing the user to remove them. */ -const ContentTagsTree = ({ tagsTree, removeTagHandler, editable }) => { +const ContentTagsTree = ({ tagsTree, removeTagHandler }) => { const renderTagsTree = (tag, level, lineage) => Object.keys(tag).map((key) => { const updatedLineage = [...lineage, encodeURIComponent(key)]; if (tag[key] !== undefined) { @@ -56,7 +55,7 @@ const ContentTagsTree = ({ tagsTree, removeTagHandler, editable }) => { level={level} lineage={updatedLineage} removeTagHandler={removeTagHandler} - editable={editable} + canRemove={tag[key].canDeleteObjecttag} /> { renderTagsTree(tag[key].children, level + 1, updatedLineage) }
@@ -73,10 +72,10 @@ ContentTagsTree.propTypes = { PropTypes.shape({ explicit: PropTypes.bool.isRequired, children: PropTypes.shape({}).isRequired, + canDeleteObjecttag: PropTypes.bool.isRequired, }).isRequired, ).isRequired, removeTagHandler: PropTypes.func.isRequired, - editable: PropTypes.bool.isRequired, }; export default ContentTagsTree; diff --git a/src/content-tags-drawer/ContentTagsTree.test.jsx b/src/content-tags-drawer/ContentTagsTree.test.jsx index ac41f3c1f1..23941393ab 100644 --- a/src/content-tags-drawer/ContentTagsTree.test.jsx +++ b/src/content-tags-drawer/ContentTagsTree.test.jsx @@ -1,13 +1,13 @@ import React from 'react'; import { IntlProvider } from '@edx/frontend-platform/i18n'; import { act, render } from '@testing-library/react'; -import PropTypes from 'prop-types'; import ContentTagsTree from './ContentTagsTree'; const data = { 'Science and Research': { explicit: false, + canDeleteObjecttag: false, children: { 'Genetics Subcategory': { explicit: false, @@ -15,8 +15,10 @@ const data = { 'DNA Sequencing': { explicit: true, children: {}, + canDeleteObjecttag: true, }, }, + canDeleteObjecttag: false, }, 'Molecular, Cellular, and Microbiology': { explicit: false, @@ -24,34 +26,27 @@ const data = { Virology: { explicit: true, children: {}, + canDeleteObjecttag: true, }, }, + canDeleteObjecttag: false, }, }, }, }; -const ContentTagsTreeComponent = ({ tagsTree, removeTagHandler, editable }) => ( +const ContentTagsTreeComponent = ({ tagsTree, removeTagHandler }) => ( - + ); -ContentTagsTreeComponent.propTypes = { - tagsTree: PropTypes.objectOf( - PropTypes.shape({ - explicit: PropTypes.bool.isRequired, - children: PropTypes.shape({}).isRequired, - }).isRequired, - ).isRequired, - removeTagHandler: PropTypes.func.isRequired, - editable: PropTypes.bool.isRequired, -}; +ContentTagsTreeComponent.propTypes = ContentTagsTree.propTypes; describe('', () => { it('should render taxonomy tags data along content tags number badge', async () => { await act(async () => { - const { getByText } = render( {}} editable />); + const { getByText } = render( {}} />); expect(getByText('Science and Research')).toBeInTheDocument(); expect(getByText('Genetics Subcategory')).toBeInTheDocument(); expect(getByText('Molecular, Cellular, and Microbiology')).toBeInTheDocument(); diff --git a/src/content-tags-drawer/TagBubble.jsx b/src/content-tags-drawer/TagBubble.jsx index d385e3a015..50c2b3560e 100644 --- a/src/content-tags-drawer/TagBubble.jsx +++ b/src/content-tags-drawer/TagBubble.jsx @@ -8,15 +8,15 @@ import PropTypes from 'prop-types'; import TagOutlineIcon from './TagOutlineIcon'; const TagBubble = ({ - value, implicit, level, lineage, removeTagHandler, editable, + value, implicit, level, lineage, removeTagHandler, canRemove, }) => { const className = `tag-bubble mb-2 border-light-300 ${implicit ? 'implicit' : ''}`; const handleClick = React.useCallback(() => { - if (!implicit && editable) { + if (!implicit && canRemove) { removeTagHandler(lineage.join(','), false); } - }, [implicit, lineage, editable, removeTagHandler]); + }, [implicit, lineage, canRemove, removeTagHandler]); return (
@@ -24,7 +24,7 @@ const TagBubble = ({ className={className} variant="light" iconBefore={!implicit ? Tag : TagOutlineIcon} - iconAfter={!implicit && editable ? Close : null} + iconAfter={!implicit && canRemove ? Close : null} onIconAfterClick={handleClick} > {value} @@ -36,6 +36,7 @@ const TagBubble = ({ TagBubble.defaultProps = { implicit: true, level: 0, + canRemove: false, }; TagBubble.propTypes = { @@ -44,7 +45,7 @@ TagBubble.propTypes = { level: PropTypes.number, lineage: PropTypes.arrayOf(PropTypes.string).isRequired, removeTagHandler: PropTypes.func.isRequired, - editable: PropTypes.bool.isRequired, + canRemove: PropTypes.bool, }; export default TagBubble; diff --git a/src/content-tags-drawer/TagBubble.test.jsx b/src/content-tags-drawer/TagBubble.test.jsx index e03fe18726..df73586426 100644 --- a/src/content-tags-drawer/TagBubble.test.jsx +++ b/src/content-tags-drawer/TagBubble.test.jsx @@ -1,7 +1,6 @@ import React from 'react'; import { IntlProvider } from '@edx/frontend-platform/i18n'; import { render, fireEvent } from '@testing-library/react'; -import PropTypes from 'prop-types'; import TagBubble from './TagBubble'; @@ -12,7 +11,7 @@ const data = { }; const TagBubbleComponent = ({ - value, implicit, level, lineage, removeTagHandler, editable, + value, implicit, level, lineage, removeTagHandler, canRemove, }) => ( ); @@ -29,23 +28,16 @@ const TagBubbleComponent = ({ TagBubbleComponent.defaultProps = { implicit: true, level: 0, + canRemove: false, }; -TagBubbleComponent.propTypes = { - value: PropTypes.string.isRequired, - implicit: PropTypes.bool, - level: PropTypes.number, - lineage: PropTypes.arrayOf(PropTypes.string).isRequired, - removeTagHandler: PropTypes.func.isRequired, - editable: PropTypes.bool.isRequired, -}; +TagBubbleComponent.propTypes = TagBubble.propTypes; describe('', () => { it('should render implicit tag', () => { const { container, getByText } = render( , @@ -58,12 +50,13 @@ describe('', () => { it('should render explicit tag', () => { const tagBubbleData = { implicit: false, + canRemove: true, ...data, }; const { container, getByText } = render( ', () => { it('should call removeTagHandler when "x" clicked on explicit tag', async () => { const tagBubbleData = { implicit: false, + canRemove: true, ...data, }; const { container } = render( ', () => { fireEvent.click(xButton); expect(data.removeTagHandler).toHaveBeenCalled(); }); + + it('should not show "x" when canRemove is not allowed', async () => { + const tagBubbleData = { + implicit: false, + canRemove: false, + ...data, + }; + const { container } = render( + , + ); + + expect(container.getElementsByClassName('pgn__chip__icon-after')[0]).toBeUndefined(); + }); }); diff --git a/src/content-tags-drawer/__mocks__/contentTaxonomyTagsMock.js b/src/content-tags-drawer/__mocks__/contentTaxonomyTagsMock.js index 2e8aa0bea7..fd8214f5cd 100644 --- a/src/content-tags-drawer/__mocks__/contentTaxonomyTagsMock.js +++ b/src/content-tags-drawer/__mocks__/contentTaxonomyTagsMock.js @@ -4,7 +4,7 @@ module.exports = { { name: 'FlatTaxonomy', taxonomyId: 3, - editable: true, + canTagObject: true, tags: [ { value: 'flat taxonomy tag 3856', @@ -17,7 +17,7 @@ module.exports = { { name: 'HierarchicalTaxonomy', taxonomyId: 4, - editable: true, + canTagObject: true, tags: [ { value: 'hierarchical taxonomy tag 1.7.59', diff --git a/src/content-tags-drawer/__mocks__/updateContentTaxonomyTagsMock.js b/src/content-tags-drawer/__mocks__/updateContentTaxonomyTagsMock.js index cc319f1197..0fb6ffc6f1 100644 --- a/src/content-tags-drawer/__mocks__/updateContentTaxonomyTagsMock.js +++ b/src/content-tags-drawer/__mocks__/updateContentTaxonomyTagsMock.js @@ -4,7 +4,7 @@ module.exports = { { name: 'FlatTaxonomy', taxonomyId: 3, - editable: true, + canTagObject: true, tags: [ { value: 'flat taxonomy tag 100', diff --git a/src/content-tags-drawer/data/api.js b/src/content-tags-drawer/data/api.js index 5bd30772be..28bd7a36c8 100644 --- a/src/content-tags-drawer/data/api.js +++ b/src/content-tags-drawer/data/api.js @@ -29,13 +29,14 @@ export const getTaxonomyTagsApiUrl = (taxonomyId, options = {}) => { return url.href; }; export const getContentTaxonomyTagsApiUrl = (contentId) => new URL(`api/content_tagging/v1/object_tags/${contentId}/`, getApiBaseUrl()).href; -export const getContentDataApiUrl = (contentId) => new URL(`/xblock/outline/${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; /** * Get all tags that belong to taxonomy. * @param {number} taxonomyId The id of the taxonomy to fetch tags for * @param {{page?: number, searchTerm?: string, parentTag?: string}} options - * @returns {Promise} + * @returns {Promise} */ export async function getTaxonomyTagsData(taxonomyId, options = {}) { const url = getTaxonomyTagsApiUrl(taxonomyId, options); @@ -59,7 +60,10 @@ export async function getContentTaxonomyTagsData(contentId) { * @returns {Promise} */ export async function getContentData(contentId) { - const { data } = await getAuthenticatedHttpClient().get(getContentDataApiUrl(contentId)); + const url = contentId.startsWith('lb:') + ? getLibraryContentDataApiUrl(contentId) + : getXBlockContentDataApiURL(contentId); + const { data } = await getAuthenticatedHttpClient().get(url); return camelCaseObject(data); } @@ -71,8 +75,8 @@ export async function getContentData(contentId) { * @returns {Promise} */ export async function updateContentTaxonomyTags(contentId, taxonomyId, tags) { - let url = getContentTaxonomyTagsApiUrl(contentId); - url = `${url}?taxonomy=${taxonomyId}`; - const { data } = await getAuthenticatedHttpClient().put(url, { tags }); + const url = getContentTaxonomyTagsApiUrl(contentId); + const params = { taxonomy: taxonomyId }; + const { data } = await getAuthenticatedHttpClient().put(url, { tags }, { params }); return camelCaseObject(data[contentId]); } diff --git a/src/content-tags-drawer/data/api.test.js b/src/content-tags-drawer/data/api.test.js index b007e12c96..9fa88dcb79 100644 --- a/src/content-tags-drawer/data/api.test.js +++ b/src/content-tags-drawer/data/api.test.js @@ -13,7 +13,8 @@ import { import { getTaxonomyTagsApiUrl, getContentTaxonomyTagsApiUrl, - getContentDataApiUrl, + getXBlockContentDataApiURL, + getLibraryContentDataApiUrl, getTaxonomyTagsData, getContentTaxonomyTagsData, getContentData, @@ -87,12 +88,21 @@ describe('content tags drawer api calls', () => { expect(result).toEqual(contentTaxonomyTagsMock[contentId]); }); - it('should get content data', async () => { + it('should get content data for course component', async () => { const contentId = 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b'; - axiosMock.onGet(getContentDataApiUrl(contentId)).reply(200, contentDataMock); + axiosMock.onGet(getXBlockContentDataApiURL(contentId)).reply(200, contentDataMock); const result = await getContentData(contentId); - expect(axiosMock.history.get[0].url).toEqual(getContentDataApiUrl(contentId)); + expect(axiosMock.history.get[0].url).toEqual(getXBlockContentDataApiURL(contentId)); + expect(result).toEqual(contentDataMock); + }); + + it('should get content data for V2 library component', async () => { + const contentId = 'lb:SampleTaxonomyOrg1:NTL1:html:a3eded6b-2106-429a-98be-63533d563d79'; + axiosMock.onGet(getLibraryContentDataApiUrl(contentId)).reply(200, contentDataMock); + const result = await getContentData(contentId); + + expect(axiosMock.history.get[0].url).toEqual(getLibraryContentDataApiUrl(contentId)); expect(result).toEqual(contentDataMock); }); @@ -100,10 +110,10 @@ describe('content tags drawer api calls', () => { const contentId = 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b'; const taxonomyId = 3; const tags = ['flat taxonomy tag 100', 'flat taxonomy tag 3856']; - axiosMock.onPut(`${getContentTaxonomyTagsApiUrl(contentId)}?taxonomy=${taxonomyId}`).reply(200, updateContentTaxonomyTagsMock); + axiosMock.onPut(`${getContentTaxonomyTagsApiUrl(contentId)}`).reply(200, updateContentTaxonomyTagsMock); const result = await updateContentTaxonomyTags(contentId, taxonomyId, tags); - expect(axiosMock.history.put[0].url).toEqual(`${getContentTaxonomyTagsApiUrl(contentId)}?taxonomy=${taxonomyId}`); + expect(axiosMock.history.put[0].url).toEqual(`${getContentTaxonomyTagsApiUrl(contentId)}`); expect(result).toEqual(updateContentTaxonomyTagsMock[contentId]); }); }); diff --git a/src/content-tags-drawer/data/apiHooks.jsx b/src/content-tags-drawer/data/apiHooks.jsx index 44151727a5..1b95f60936 100644 --- a/src/content-tags-drawer/data/apiHooks.jsx +++ b/src/content-tags-drawer/data/apiHooks.jsx @@ -22,14 +22,6 @@ import { * @param {string|null} parentTag The tag whose children we're loading, if any * @param {string} searchTerm The term passed in to perform search on tags * @param {number} numPages How many pages of tags to load at this level - * @returns {{ - * hasMorePages: boolean, - * tagPages: { - * isLoading: boolean, - * isError: boolean, - * data: TagListData[], - * }[], - * }} */ export const useTaxonomyTagsData = (taxonomyId, parentTag = null, numPages = 1, searchTerm = '') => { const queryClient = useQueryClient(); @@ -53,14 +45,11 @@ export const useTaxonomyTagsData = (taxonomyId, parentTag = null, numPages = 1, const hasMorePages = numPages < totalPages; const tagPages = useMemo(() => { - /** @type { { isLoading: boolean, isError: boolean, data: TagListData[] }[] } */ - const newTags = []; - // Pre-load desendants if possible const preLoadedData = new Map(); - dataPages.forEach(result => { - /** @type {TagListData[]} */ + const newTags = dataPages.map(result => { + /** @type {TagData[]} */ const simplifiedTagsList = []; result.data?.results?.forEach((tag) => { @@ -73,13 +62,13 @@ export const useTaxonomyTagsData = (taxonomyId, parentTag = null, numPages = 1, } }); - newTags.push({ ...result, data: simplifiedTagsList }); + return { ...result, data: simplifiedTagsList }; }); // Store the pre-loaded descendants into the query cache: preLoadedData.forEach((tags, parentValue) => { const queryKey = ['taxonomyTags', taxonomyId, parentValue, 1, searchTerm]; - /** @type {TagData} */ + /** @type {TagListData} */ const cachedData = { next: '', previous: '', @@ -95,7 +84,14 @@ export const useTaxonomyTagsData = (taxonomyId, parentTag = null, numPages = 1, return newTags; }, [dataPages]); - return { hasMorePages, tagPages }; + const flatTagPages = { + isLoading: tagPages.some(page => page.isLoading), + isError: tagPages.some(page => page.isError), + isSuccess: tagPages.every(page => page.isSuccess), + data: tagPages.flatMap(page => page.data), + }; + + return { hasMorePages, tagPages: flatTagPages }; }; /** diff --git a/src/content-tags-drawer/data/apiHooks.test.jsx b/src/content-tags-drawer/data/apiHooks.test.jsx index 8000a424a2..4e12ef5ea5 100644 --- a/src/content-tags-drawer/data/apiHooks.test.jsx +++ b/src/content-tags-drawer/data/apiHooks.test.jsx @@ -67,9 +67,12 @@ describe('useTaxonomyTagsData', () => { ], }; - useQueries.mockReturnValue([ - { data: mockData, isLoading: false, isError: false }, - ]); + useQueries.mockReturnValue([{ + data: mockData, + isLoading: false, + isError: false, + isSuccess: true, + }]); const { result } = renderHook(() => useTaxonomyTagsData(taxonomyId)); @@ -83,10 +86,11 @@ describe('useTaxonomyTagsData', () => { expect(result.current.hasMorePages).toEqual(false); // Only includes the first 2 tags because the other 2 would be // in the nested dropdown - expect(result.current.tagPages).toEqual([ + expect(result.current.tagPages).toEqual( { isLoading: false, isError: false, + isSuccess: true, data: [ { value: 'tag 1', @@ -108,7 +112,7 @@ describe('useTaxonomyTagsData', () => { }, ], }, - ]); + ); }); }); diff --git a/src/content-tags-drawer/data/types.mjs b/src/content-tags-drawer/data/types.mjs index 2145f41b95..3fad73ccf1 100644 --- a/src/content-tags-drawer/data/types.mjs +++ b/src/content-tags-drawer/data/types.mjs @@ -4,13 +4,15 @@ * @typedef {Object} Tag A tag that has been applied to some content. * @property {string} value The value of the tag, also its ID. e.g. "Biology" * @property {string[]} lineage The values of the tag and its parent(s) in the hierarchy + * @property {boolean} canChangeObjecttag + * @property {boolean} canDeleteObjecttag */ /** * @typedef {Object} ContentTaxonomyTagData A list of the tags from one taxonomy that are applied to a content object. * @property {string} name * @property {number} taxonomyId - * @property {boolean} editable + * @property {boolean} canTagObject * @property {Tag[]} tags */ diff --git a/src/content-tags-drawer/messages.js b/src/content-tags-drawer/messages.js index 62d9e15499..c54e6b7bcc 100644 --- a/src/content-tags-drawer/messages.js +++ b/src/content-tags-drawer/messages.js @@ -21,6 +21,10 @@ const messages = defineMessages({ id: 'course-authoring.content-tags-drawer.tags-dropdown-selector.load-more-tags.button', defaultMessage: 'Load more', }, + noTagsFoundMessage: { + id: 'course-authoring.content-tags-drawer.tags-dropdown-selector.no-tags-found', + defaultMessage: 'No tags found with the search term "{searchTerm}"', + }, taxonomyTagsCheckboxAriaLabel: { id: 'course-authoring.content-tags-drawer.tags-dropdown-selector.selectable-box.aria.label', defaultMessage: '{tag} checkbox', diff --git a/src/course-outline/CourseOutline.jsx b/src/course-outline/CourseOutline.jsx index c3a6d9760f..986969ee56 100644 --- a/src/course-outline/CourseOutline.jsx +++ b/src/course-outline/CourseOutline.jsx @@ -1,12 +1,11 @@ -import { - React, useState, useEffect, -} from 'react'; +import { useState, useEffect } from 'react'; import PropTypes from 'prop-types'; import { useIntl } from '@edx/frontend-platform/i18n'; import { Button, Container, Layout, + Row, TransitionReplace, } from '@openedx/paragon'; import { Helmet } from 'react-helmet'; @@ -16,12 +15,10 @@ import { Warning as WarningIcon, } from '@openedx/paragon/icons'; import { useSelector } from 'react-redux'; -import { - DraggableList, - SortableItem, - ErrorAlert, -} from '@edx/frontend-lib-content-components'; +import { DraggableList } from '@edx/frontend-lib-content-components'; +import { arrayMove } from '@dnd-kit/sortable'; +import { LoadingSpinner } from '../generic/Loading'; import { getProcessingNotification } from '../generic/processing-notification/data/selectors'; import { RequestStatus } from '../data/constants'; import SubHeader from '../generic/sub-header/SubHeader'; @@ -35,11 +32,13 @@ import StatusBar from './status-bar/StatusBar'; import EnableHighlightsModal from './enable-highlights-modal/EnableHighlightsModal'; import SectionCard from './section-card/SectionCard'; import SubsectionCard from './subsection-card/SubsectionCard'; +import UnitCard from './unit-card/UnitCard'; 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 { useCourseOutline } from './hooks'; import messages from './messages'; @@ -50,7 +49,9 @@ const CourseOutline = ({ courseId }) => { courseName, savingStatus, statusBarData, + courseActions, sectionsList, + isCustomRelativeDatesActive, isLoading, isReIndexShow, showErrorAlert, @@ -65,7 +66,7 @@ const CourseOutline = ({ courseId }) => { isDeleteModalOpen, closeHighlightsModal, closePublishModal, - closeConfigureModal, + handleConfigureModalClose, closeDeleteModal, openPublishModal, openConfigureModal, @@ -77,20 +78,37 @@ const CourseOutline = ({ courseId }) => { handleInternetConnectionFailed, handleOpenHighlightsModal, handleHighlightsFormSubmit, - handleConfigureSectionSubmit, + handleConfigureItemSubmit, handlePublishItemSubmit, handleEditSubmit, handleDeleteItemSubmit, handleDuplicateSectionSubmit, handleDuplicateSubsectionSubmit, + handleDuplicateUnitSubmit, handleNewSectionSubmit, handleNewSubsectionSubmit, - handleDragNDrop, + handleNewUnitSubmit, + getUnitUrl, + handleSectionDragAndDrop, + handleSubsectionDragAndDrop, + handleVideoSharingOptionChange, + handleUnitDragAndDrop, + handleCopyToClipboardClick, + handlePasteClipboardClick, + notificationDismissUrl, + discussionsSettings, + discussionsIncontextFeedbackUrl, + discussionsIncontextLearnmoreUrl, + deprecatedBlocksInfo, + proctoringErrors, + mfeProctoredExamSettingsUrl, + handleDismissNotification, + advanceSettingsUrl, } = useCourseOutline({ courseId }); const [sections, setSections] = useState(sectionsList); - const initialSections = [...sectionsList]; + let initialSections = [...sectionsList]; const { isShow: isShowProcessingNotification, @@ -98,18 +116,138 @@ const CourseOutline = ({ courseId }) => { } = useSelector(getProcessingNotification); const finalizeSectionOrder = () => (newSections) => { - handleDragNDrop(newSections.map((section) => section.id), () => { + initialSections = [...sectionsList]; + handleSectionDragAndDrop(newSections.map(section => section.id), () => { + setSections(() => initialSections); + }); + }; + + 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; + }; + + /** + * Move section to new index + * @param {any} currentIndex + * @param {any} newIndex + */ + const updateSectionOrderByIndex = (currentIndex, newIndex) => { + if (currentIndex === newIndex) { + return; + } + setSections((prevSections) => { + const newSections = arrayMove(prevSections, currentIndex, newIndex); + finalizeSectionOrder()(newSections); + return newSections; + }); + }; + + /** + * Returns a function for given section which can move a subsection inside it + * to a new position + * @param {any} sectionIndex + * @param {any} section + * @param {any} subsections + * @returns {(currentIndex, newIndex) => void} + */ + const updateSubsectionOrderByIndex = (sectionIndex, section, subsections) => (currentIndex, newIndex) => { + if (currentIndex === newIndex) { + return; + } + setSubsection(sectionIndex)(() => { + const newSubsections = arrayMove(subsections, currentIndex, newIndex); + finalizeSubsectionOrder(section)()(newSubsections); + return newSubsections; + }); + }; + + /** + * Returns a function for given section & subsection which can move a unit + * inside it to a new position + * @param {any} sectionIndex + * @param {any} section + * @param {any} subsection + * @param {any} units + * @returns {(currentIndex, newIndex) => void} + */ + const updateUnitOrderByIndex = ( + sectionIndex, + subsectionIndex, + section, + subsection, + units, + ) => (currentIndex, newIndex) => { + if (currentIndex === newIndex) { + return; + } + setUnit(sectionIndex, subsectionIndex)(() => { + const newUnits = arrayMove(units, currentIndex, newIndex); + finalizeUnitOrder(section, subsection)()(newUnits); + return newUnits; + }); + }; + useEffect(() => { setSections(sectionsList); }, [sectionsList]); if (isLoading) { // eslint-disable-next-line react/jsx-no-useless-fragment - return <>; + return ( + + + + ); } return ( @@ -119,9 +257,18 @@ const CourseOutline = ({ courseId }) => {
- - {intl.formatMessage(messages.alertFailedGeneric, { actionName: 'save', type: 'changes' })} - + {showSuccessAlert ? ( { headerNavigationsActions={headerNavigationsActions} isDisabledReindexButton={isDisabledReindexButton} hasSections={Boolean(sectionsList.length)} + courseActions={courseActions} /> )} /> @@ -167,65 +315,118 @@ const CourseOutline = ({ courseId }) => { isLoading={isLoading} statusBarData={statusBarData} openEnableHighlightsModal={openEnableHighlightsModal} + handleVideoSharingOptionChange={handleVideoSharingOptionChange} />
{sections.length ? ( <> - {sections.map((section) => ( - ( + - - {section.childInfo.children.map((subsection) => ( + {section.childInfo.children.map((subsection, subsectionIndex) => ( + onOpenConfigureModal={openConfigureModal} + onNewUnitSubmit={handleNewUnitSubmit} + onOrderChange={updateSubsectionOrderByIndex( + sectionIndex, + section, + section.childInfo.children, + )} + onPasteClick={handlePasteClipboardClick} + > + + {subsection.childInfo.children.map((unit, unitIndex) => ( + + ))} + + ))} - - + + ))} - + {courseActions.childAddable && ( + + )} ) : ( - + )}
@@ -254,8 +455,8 @@ const CourseOutline = ({ courseId }) => { /> ({ }), })); +jest.mock('@edx/frontend-platform/i18n', () => ({ + ...jest.requireActual('@edx/frontend-platform/i18n'), + useIntl: () => ({ + formatMessage: (message) => message.defaultMessage, + }), +})); + const RootWrapper = () => ( @@ -113,6 +126,57 @@ describe('', () => { expect(await findByText(messages.alertSuccessDescription.defaultMessage)).toBeInTheDocument(); }); + it('check video sharing option udpates correctly', async () => { + const { findByLabelText } = render(); + + axiosMock + .onPost(getCourseBlockApiUrl(courseId), { + metadata: { + video_sharing_options: VIDEO_SHARING_OPTIONS.allOff, + }, + }) + .reply(200); + const optionDropdown = await findByLabelText(statusBarMessages.videoSharingTitle.defaultMessage); + await act( + async () => fireEvent.change(optionDropdown, { target: { value: VIDEO_SHARING_OPTIONS.allOff } }), + ); + + expect(axiosMock.history.post.length).toBe(1); + expect(axiosMock.history.post[0].data).toBe(JSON.stringify({ + metadata: { + video_sharing_options: VIDEO_SHARING_OPTIONS.allOff, + }, + })); + }); + + it('check video sharing option shows error on failure', async () => { + const { findByLabelText, queryByRole } = render(); + + axiosMock + .onPost(getCourseBlockApiUrl(courseId), { + metadata: { + video_sharing_options: VIDEO_SHARING_OPTIONS.allOff, + }, + }) + .reply(500); + const optionDropdown = await findByLabelText(statusBarMessages.videoSharingTitle.defaultMessage); + await act( + async () => fireEvent.change(optionDropdown, { target: { value: VIDEO_SHARING_OPTIONS.allOff } }), + ); + + expect(axiosMock.history.post.length).toBe(1); + expect(axiosMock.history.post[0].data).toBe(JSON.stringify({ + metadata: { + video_sharing_options: VIDEO_SHARING_OPTIONS.allOff, + }, + })); + + const alertElement = queryByRole('alert'); + expect(alertElement).toHaveTextContent( + pageAlertMessages.alertFailedGeneric.defaultMessage, + ); + }); + it('render error alert after failed reindex correctly', async () => { const { findByText, findByTestId } = render(); @@ -120,9 +184,7 @@ describe('', () => { .onGet(getCourseReindexApiUrl(courseOutlineIndexMock.reindexLink)) .reply(500); const reindexButton = await findByTestId('course-reindex'); - await act(async () => { - fireEvent.click(reindexButton); - }); + await act(async () => fireEvent.click(reindexButton)); expect(await findByText(messages.alertErrorTitle.defaultMessage)).toBeInTheDocument(); }); @@ -145,9 +207,7 @@ describe('', () => { .onGet(getXBlockApiUrl(courseSectionMock.id)) .reply(200, courseSectionMock); const newSectionButton = await findByTestId('new-section-button'); - await act(async () => { - fireEvent.click(newSectionButton); - }); + await act(async () => fireEvent.click(newSectionButton)); elements = await findAllByTestId('section-card'); expect(elements.length).toBe(5); @@ -158,7 +218,7 @@ describe('', () => { const { findAllByTestId } = render(); const [section] = await findAllByTestId('section-card'); let subsections = await within(section).findAllByTestId('subsection-card'); - expect(subsections.length).toBe(1); + expect(subsections.length).toBe(2); window.HTMLElement.prototype.getBoundingClientRect = jest.fn(() => ({ top: 0, bottom: 4000, @@ -178,10 +238,36 @@ describe('', () => { }); subsections = await within(section).findAllByTestId('subsection-card'); - expect(subsections.length).toBe(2); + expect(subsections.length).toBe(3); expect(window.HTMLElement.prototype.scrollIntoView).toBeCalled(); }); + it('adds new unit correctly', async () => { + const { findAllByTestId } = render(); + const [sectionElement] = await findAllByTestId('section-card'); + const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); + const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn'); + fireEvent.click(expandBtn); + const units = await within(subsectionElement).findAllByTestId('unit-card'); + expect(units.length).toBe(1); + + axiosMock + .onPost(getXBlockBaseApiUrl()) + .reply(200, { + locator: 'some', + }); + const newUnitButton = await within(subsectionElement).findByTestId('new-unit-button'); + await act(async () => fireEvent.click(newUnitButton)); + expect(axiosMock.history.post.length).toBe(1); + const [section] = courseOutlineIndexMock.courseStructure.childInfo.children; + const [subsection] = section.childInfo.children; + expect(axiosMock.history.post[0].data).toBe(JSON.stringify({ + parent_locator: subsection.id, + category: COURSE_BLOCK_NAMES.vertical.id, + display_name: COURSE_BLOCK_NAMES.vertical.name, + })); + }); + it('render checklist value correctly', async () => { const { getByText } = render(); @@ -212,7 +298,7 @@ describe('', () => { axiosMock.reset(); axiosMock - .onPost(getEnableHighlightsEmailsApiUrl(courseId), { + .onPost(getCourseBlockApiUrl(courseId), { publish: 'republish', metadata: { highlights_enabled_for_messaging: true, @@ -232,9 +318,7 @@ describe('', () => { const enableButton = await findByTestId('highlights-enable-button'); fireEvent.click(enableButton); const saveButton = await findByText(enableHighlightsModalMessages.submitButton.defaultMessage); - await act(async () => { - fireEvent.click(saveButton); - }); + await act(async () => fireEvent.click(saveButton)); expect(await findByTestId('highlights-enabled-span')).toBeInTheDocument(); }); @@ -259,7 +343,6 @@ describe('', () => { }); it('render CourseOutline component without sections correctly', async () => { - cleanup(); axiosMock .onGet(getCourseOutlineIndexApiUrl(courseId)) .reply(200, courseOutlineIndexWithoutSections); @@ -271,189 +354,911 @@ describe('', () => { }); }); - it('check edit section when edit query is successfully', async () => { - const { findAllByTestId, findByText } = render(); - const newDisplayName = 'New section name'; - - const [section] = courseOutlineIndexMock.courseStructure.childInfo.children; - - axiosMock - .onPost(getCourseItemApiUrl(section.id, { - metadata: { - display_name: newDisplayName, - }, - })) - .reply(200, { dummy: 'value' }); + it('render configuration alerts and check dismiss query', async () => { axiosMock - .onGet(getXBlockApiUrl(section.id)) + .onGet(getCourseOutlineIndexApiUrl(courseId)) .reply(200, { - ...section, - display_name: newDisplayName, + ...courseOutlineIndexMock, + notificationDismissUrl: '/some/url', }); - const [sectionElement] = await findAllByTestId('section-card'); - const editButton = await within(sectionElement).findByTestId('section-edit-button'); - fireEvent.click(editButton); - const editField = await within(sectionElement).findByTestId('section-edit-field'); - fireEvent.change(editField, { target: { value: newDisplayName } }); - await act(async () => { - fireEvent.blur(editField); - }); + const { findByRole } = render(); + expect(await findByRole('alert')).toBeInTheDocument(); + const dismissBtn = await findByRole('button', { name: 'Dismiss' }); + axiosMock + .onDelete('/some/url') + .reply(204); + fireEvent.click(dismissBtn); - expect(await findByText(newDisplayName)).toBeInTheDocument(); + expect(axiosMock.history.delete.length).toBe(1); }); - it('check whether section is deleted when delete button is clicked', async () => { - const { findAllByTestId, findByTestId, queryByText } = render(); - const [section] = courseOutlineIndexMock.courseStructure.childInfo.children; - await waitFor(() => { - expect(queryByText(section.displayName)).toBeInTheDocument(); - }); - - axiosMock.onDelete(getCourseItemApiUrl(section.id)).reply(200); + it('check edit title works for section, subsection and unit', async () => { + const { findAllByTestId } = render(); + const checkEditTitle = async (section, element, item, newName, elementName) => { + axiosMock.reset(); + axiosMock + .onPost(getCourseItemApiUrl(item.id, { + metadata: { + display_name: newName, + }, + })) + .reply(200, { dummy: 'value' }); + // mock section, subsection and unit name and check within the elements. + // this is done to avoid adding conditions to this mock. + axiosMock + .onGet(getXBlockApiUrl(section.id)) + .reply(200, { + ...section, + display_name: newName, + childInfo: { + children: [ + { + ...section.childInfo.children[0], + display_name: newName, + childInfo: { + children: [ + { + ...section.childInfo.children[0].childInfo.children[0], + display_name: newName, + }, + ], + }, + }, + ], + }, + }); + + const editButton = await within(element).findByTestId(`${elementName}-edit-button`); + fireEvent.click(editButton); + const editField = await within(element).findByTestId(`${elementName}-edit-field`); + fireEvent.change(editField, { target: { value: newName } }); + await act(async () => fireEvent.blur(editField)); + expect( + axiosMock.history.post[axiosMock.history.post.length - 1].data, + `Failed for ${elementName}!`, + ).toBe(JSON.stringify({ + metadata: { + display_name: newName, + }, + })); + const results = await within(element).findAllByText(newName); + expect(results.length, `Failed for ${elementName}!`).toBeGreaterThan(0); + }; + // check section + const [section] = courseOutlineIndexMock.courseStructure.childInfo.children; const [sectionElement] = await findAllByTestId('section-card'); - const menu = await within(sectionElement).findByTestId('section-card-header__menu-button'); - fireEvent.click(menu); - const deleteButton = await within(sectionElement).findByTestId('section-card-header__menu-delete-button'); - fireEvent.click(deleteButton); - const confirmButton = await findByTestId('delete-confirm-button'); - await act(async () => { - fireEvent.click(confirmButton); - }); + await checkEditTitle(section, sectionElement, section, 'New section name', 'section'); - await waitFor(() => { - expect(queryByText(section.displayName)).not.toBeInTheDocument(); - }); + // check subsection + const [subsection] = section.childInfo.children; + const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); + await checkEditTitle(section, subsectionElement, subsection, 'New subsection name', 'subsection'); + + // check unit + const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn'); + fireEvent.click(expandBtn); + const [unit] = subsection.childInfo.children; + const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card'); + await checkEditTitle(section, unitElement, unit, 'New unit name', 'unit'); }); - it('check whether subsection is deleted when delete button is clicked', async () => { + it('check whether section, subsection and unit is deleted when corresponding delete button is clicked', async () => { const { findAllByTestId, findByTestId, queryByText } = render(); + // get section, subsection and unit const [section] = courseOutlineIndexMock.courseStructure.childInfo.children; + const [sectionElement] = await findAllByTestId('section-card'); const [subsection] = section.childInfo.children; - await waitFor(() => { - expect(queryByText(subsection.displayName)).toBeInTheDocument(); - }); + const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); + const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn'); + fireEvent.click(expandBtn); + const [unit] = subsection.childInfo.children; + const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card'); + + const checkDeleteBtn = async (item, element, elementName) => { + await waitFor(() => { + expect(queryByText(item.displayName), `Failed for ${elementName}!`).toBeInTheDocument(); + }); + + axiosMock.onDelete(getCourseItemApiUrl(item.id)).reply(200); - axiosMock.onDelete(getCourseItemApiUrl(subsection.id)).reply(200); + const menu = await within(element).findByTestId(`${elementName}-card-header__menu-button`); + fireEvent.click(menu); + const deleteButton = await within(element).findByTestId(`${elementName}-card-header__menu-delete-button`); + fireEvent.click(deleteButton); + const confirmButton = await findByTestId('delete-confirm-button'); + await act(async () => fireEvent.click(confirmButton)); + await waitFor(() => { + expect(queryByText(item.displayName), `Failed for ${elementName}!`).not.toBeInTheDocument(); + }); + }; + + // delete unit, subsection and then section in order. + // check unit + await checkDeleteBtn(unit, unitElement, 'unit'); + // check subsection + await checkDeleteBtn(subsection, subsectionElement, 'subsection'); + // check section + await checkDeleteBtn(section, sectionElement, 'section'); + }); + + it('check whether section, subsection and unit is duplicated successfully', async () => { + const { findAllByTestId } = render(); + // get section, subsection and unit + const [section] = courseOutlineIndexMock.courseStructure.childInfo.children; const [sectionElement] = await findAllByTestId('section-card'); + const [subsection] = section.childInfo.children; const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); - const menu = await within(subsectionElement).findByTestId('subsection-card-header__menu-button'); - fireEvent.click(menu); - const deleteButton = await within(subsectionElement).findByTestId('subsection-card-header__menu-delete-button'); - fireEvent.click(deleteButton); - const confirmButton = await findByTestId('delete-confirm-button'); - await act(async () => { - fireEvent.click(confirmButton); - }); + const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn'); + fireEvent.click(expandBtn); + const [unit] = subsection.childInfo.children; + const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card'); + + const checkDuplicateBtn = async (item, parentElement, element, elementName, expectedLength) => { + // baseline + if (parentElement) { + expect( + await within(parentElement).findAllByTestId(`${elementName}-card`), + `Failed for ${elementName}!`, + ).toHaveLength(expectedLength - 1); + } else { + expect( + await findAllByTestId(`${elementName}-card`), + `Failed for ${elementName}!`, + ).toHaveLength(expectedLength - 1); + } - await waitFor(() => { - expect(queryByText(subsection.displayName)).not.toBeInTheDocument(); - }); + const duplicatedItemId = item.id + elementName; + axiosMock + .onPost(getXBlockBaseApiUrl()) + .reply(200, { + locator: duplicatedItemId, + }); + if (elementName === 'section') { + section.id = duplicatedItemId; + } else if (elementName === 'subsection') { + section.childInfo.children = [...section.childInfo.children, { ...subsection, id: duplicatedItemId }]; + } else if (elementName === 'unit') { + subsection.childInfo.children = [...subsection.childInfo.children, { ...unit, id: duplicatedItemId }]; + section.childInfo.children = [subsection, ...section.childInfo.children.slice(1)]; + } + axiosMock + .onGet(getXBlockApiUrl(section.id)) + .reply(200, { + ...section, + }); + + const menu = await within(element).findByTestId(`${elementName}-card-header__menu-button`); + fireEvent.click(menu); + const duplicateButton = await within(element).findByTestId(`${elementName}-card-header__menu-duplicate-button`); + await act(async () => fireEvent.click(duplicateButton)); + if (parentElement) { + expect( + await within(parentElement).findAllByTestId(`${elementName}-card`), + `Failed for ${elementName}!`, + ).toHaveLength(expectedLength); + } else { + expect( + await findAllByTestId(`${elementName}-card`), + `Failed for ${elementName}!`, + ).toHaveLength(expectedLength); + } + }; + + // duplicate unit, subsection and then section in order. + // check unit + await checkDuplicateBtn(unit, subsectionElement, unitElement, 'unit', 2); + // check subsection + await checkDuplicateBtn(subsection, sectionElement, subsectionElement, 'subsection', 3); + // check section + await checkDuplicateBtn(section, null, sectionElement, 'section', 5); }); - it('check whether section is duplicated successfully', async () => { - const { findAllByTestId } = render(); + it('check section, subsection & unit is published when publish button is clicked', async () => { + const { findAllByTestId, findByTestId } = render(); const [section] = courseOutlineIndexMock.courseStructure.childInfo.children; - expect(await findAllByTestId('section-card')).toHaveLength(4); + const [sectionElement] = await findAllByTestId('section-card'); + const [subsection] = section.childInfo.children; + const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); + const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn'); + fireEvent.click(expandBtn); + const [unit] = subsection.childInfo.children; + const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card'); + + const checkPublishBtn = async (item, element, elementName) => { + expect( + (await within(element).getAllByRole('status'))[0], + `Failed for ${elementName}!`, + ).toHaveTextContent(cardHeaderMessages.statusBadgeDraft.defaultMessage); + + axiosMock + .onPost(getCourseItemApiUrl(item.id), { + publish: 'make_public', + }) + .reply(200, { dummy: 'value' }); + + let mockReturnValue = { + ...section, + childInfo: { + children: [ + { + ...section.childInfo.children[0], + published: true, + }, + ...section.childInfo.children.slice(1), + ], + }, + }; + if (elementName === 'unit') { + mockReturnValue = { + ...section, + childInfo: { + children: [ + { + ...section.childInfo.children[0], + childInfo: { + children: [ + { + ...section.childInfo.children[0].childInfo.children[0], + published: true, + }, + ...section.childInfo.children[0].childInfo.children.slice(1), + ], + }, + }, + ...section.childInfo.children.slice(1), + ], + }, + }; + } + axiosMock + .onGet(getXBlockApiUrl(section.id)) + .reply(200, mockReturnValue); + + const menu = await within(element).findByTestId(`${elementName}-card-header__menu-button`); + fireEvent.click(menu); + const publishButton = await within(element).findByTestId(`${elementName}-card-header__menu-publish-button`); + await act(async () => fireEvent.click(publishButton)); + const confirmButton = await findByTestId('publish-confirm-button'); + await act(async () => fireEvent.click(confirmButton)); + + expect( + (await within(element).getAllByRole('status'))[0], + `Failed for ${elementName}!`, + ).toHaveTextContent(cardHeaderMessages.statusBadgePublishedNotLive.defaultMessage); + }; + + // publish unit, subsection and then section in order. + // check unit + await checkPublishBtn(unit, unitElement, 'unit'); + // check subsection + await checkPublishBtn(subsection, subsectionElement, 'subsection'); + // section doesn't display badges + }); + it('check configure modal for section', async () => { + const { findByTestId, findAllByTestId } = render(); + const section = courseOutlineIndexMock.courseStructure.childInfo.children[0]; + const newReleaseDateIso = '2025-09-10T22:00:00Z'; + const newReleaseDate = '09/10/2025'; axiosMock - .onPost(getXBlockBaseApiUrl()) - .reply(200, { - locator: courseSectionMock.id, - }); - section.id = courseSectionMock.id; + .onPost(getCourseItemApiUrl(section.id), { + publish: 'republish', + metadata: { + visible_to_staff_only: true, + start: newReleaseDateIso, + }, + }) + .reply(200, { dummy: 'value' }); + axiosMock .onGet(getXBlockApiUrl(section.id)) .reply(200, { ...section, + start: newReleaseDateIso, }); - const [sectionElement] = await findAllByTestId('section-card'); - const menu = await within(sectionElement).findByTestId('section-card-header__menu-button'); - fireEvent.click(menu); - const duplicateButton = await within(sectionElement).findByTestId('section-card-header__menu-duplicate-button'); - await act(async () => { - fireEvent.click(duplicateButton); - }); - expect(await findAllByTestId('section-card')).toHaveLength(5); + const [firstSection] = await findAllByTestId('section-card'); + + const sectionDropdownButton = await within(firstSection).findByTestId('section-card-header__menu-button'); + await act(async () => fireEvent.click(sectionDropdownButton)); + const configureBtn = await within(firstSection).findByTestId('section-card-header__menu-configure-button'); + await act(async () => fireEvent.click(configureBtn)); + let releaseDateStack = await findByTestId('release-date-stack'); + let releaseDatePicker = await within(releaseDateStack).findByPlaceholderText('MM/DD/YYYY'); + expect(releaseDatePicker).toHaveValue('08/10/2023'); + + await act(async () => fireEvent.change(releaseDatePicker, { target: { value: newReleaseDate } })); + expect(releaseDatePicker).toHaveValue(newReleaseDate); + const saveButton = await findByTestId('configure-save-button'); + await act(async () => fireEvent.click(saveButton)); + + expect(axiosMock.history.post.length).toBe(1); + expect(axiosMock.history.post[0].data).toBe(JSON.stringify({ + publish: 'republish', + metadata: { + visible_to_staff_only: true, + start: newReleaseDateIso, + }, + })); + + await act(async () => fireEvent.click(sectionDropdownButton)); + await act(async () => fireEvent.click(configureBtn)); + releaseDateStack = await findByTestId('release-date-stack'); + releaseDatePicker = await within(releaseDateStack).findByPlaceholderText('MM/DD/YYYY'); + expect(releaseDatePicker).toHaveValue(newReleaseDate); }); - it('check whether subsection is duplicated successfully', async () => { - const { findAllByTestId } = render(); - const section = courseOutlineIndexMock.courseStructure.childInfo.children[0]; - let [sectionElement] = await findAllByTestId('section-card'); + it('check configure modal for subsection', async () => { + const { + findAllByTestId, + findByTestId, + } = render(); + const section = cloneDeep(courseOutlineIndexMock.courseStructure.childInfo.children[0]); const [subsection] = section.childInfo.children; - let subsections = await within(sectionElement).findAllByTestId('subsection-card'); - expect(subsections.length).toBe(1); + const expectedRequestData = { + publish: 'republish', + graderType: 'Homework', + isPrereq: false, + prereqMinScore: 100, + prereqMinCompletion: 100, + metadata: { + visible_to_staff_only: null, + due: '2025-09-10T05:00:00Z', + hide_after_due: true, + show_correctness: 'always', + is_practice_exam: false, + is_time_limited: true, + is_proctored_enabled: false, + exam_review_rules: '', + default_time_limit_minutes: 3270, + is_onboarding_exam: false, + start: '2025-08-10T00:00:00Z', + }, + }; axiosMock - .onPost(getXBlockBaseApiUrl()) - .reply(200, { - locator: courseSubsectionMock.id, - }); - subsection.id = courseSubsectionMock.id; - section.childInfo.children = [...section.childInfo.children, subsection]; + .onPost(getCourseItemApiUrl(subsection.id), expectedRequestData) + .reply(200, { dummy: 'value' }); + + const [currentSection] = await findAllByTestId('section-card'); + const [firstSubsection] = await within(currentSection).findAllByTestId('subsection-card'); + const subsectionDropdownButton = await within(firstSubsection).findByTestId('subsection-card-header__menu-button'); + + subsection.start = expectedRequestData.metadata.start; + subsection.due = expectedRequestData.metadata.due; + subsection.format = expectedRequestData.graderType; + subsection.isTimeLimited = expectedRequestData.metadata.is_time_limited; + subsection.defaultTimeLimitMinutes = expectedRequestData.metadata.default_time_limit_minutes; + subsection.hideAfterDue = expectedRequestData.metadata.hideAfterDue; + section.childInfo.children[0] = subsection; axiosMock .onGet(getXBlockApiUrl(section.id)) - .reply(200, { - ...section, - }); + .reply(200, section); - const menu = await within(subsections[0]).findByTestId('subsection-card-header__menu-button'); - fireEvent.click(menu); - const duplicateButton = await within(subsections[0]).findByTestId('subsection-card-header__menu-duplicate-button'); - await act(async () => { - fireEvent.click(duplicateButton); + fireEvent.click(subsectionDropdownButton); + const configureBtn = await within(firstSubsection).findByTestId('subsection-card-header__menu-configure-button'); + fireEvent.click(configureBtn); + + // update fields + let configureModal = await findByTestId('configure-modal'); + expect(await within(configureModal).findByText(expectedRequestData.graderType)).toBeInTheDocument(); + let releaseDateStack = await within(configureModal).findByTestId('release-date-stack'); + let releaseDatePicker = await within(releaseDateStack).findByPlaceholderText('MM/DD/YYYY'); + fireEvent.change(releaseDatePicker, { target: { value: '08/10/2025' } }); + let releaseDateTimePicker = await within(releaseDateStack).findByPlaceholderText('HH:MM'); + fireEvent.change(releaseDateTimePicker, { target: { value: '00:00' } }); + let dueDateStack = await within(configureModal).findByTestId('due-date-stack'); + let dueDatePicker = await within(dueDateStack).findByPlaceholderText('MM/DD/YYYY'); + fireEvent.change(dueDatePicker, { target: { value: '09/10/2025' } }); + let dueDateTimePicker = await within(dueDateStack).findByPlaceholderText('HH:MM'); + fireEvent.change(dueDateTimePicker, { target: { value: '05:00' } }); + let graderTypeDropdown = await within(configureModal).findByTestId('grader-type-select'); + fireEvent.change(graderTypeDropdown, { target: { value: expectedRequestData.graderType } }); + + // visibility tab + const visibilityTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.visibilityTabTitle.defaultMessage }); + fireEvent.click(visibilityTab); + const visibilityRadioButtons = await within(configureModal).findAllByRole('radio'); + fireEvent.click(visibilityRadioButtons[1]); + + let advancedTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.advancedTabTitle.defaultMessage }); + fireEvent.click(advancedTab); + let radioButtons = await within(configureModal).findAllByRole('radio'); + fireEvent.click(radioButtons[1]); + let hoursWrapper = await within(configureModal).findByTestId('advanced-tab-hours-picker-wrapper'); + let hours = await within(hoursWrapper).findByPlaceholderText('HH:MM'); + fireEvent.change(hours, { target: { value: '54:30' } }); + const saveButton = await within(configureModal).findByTestId('configure-save-button'); + await act(async () => fireEvent.click(saveButton)); + + // verify request + expect(axiosMock.history.post.length).toBe(1); + expect(axiosMock.history.post[0].data).toBe(JSON.stringify(expectedRequestData)); + + // reopen modal and check values + await act(async () => fireEvent.click(subsectionDropdownButton)); + await act(async () => fireEvent.click(configureBtn)); + + configureModal = await findByTestId('configure-modal'); + releaseDateStack = await within(configureModal).findByTestId('release-date-stack'); + releaseDatePicker = await within(releaseDateStack).findByPlaceholderText('MM/DD/YYYY'); + expect(releaseDatePicker).toHaveValue('08/10/2025'); + releaseDateTimePicker = await within(releaseDateStack).findByPlaceholderText('HH:MM'); + expect(releaseDateTimePicker).toHaveValue('00:00'); + dueDateStack = await await within(configureModal).findByTestId('due-date-stack'); + dueDatePicker = await within(dueDateStack).findByPlaceholderText('MM/DD/YYYY'); + expect(dueDatePicker).toHaveValue('09/10/2025'); + dueDateTimePicker = await within(dueDateStack).findByPlaceholderText('HH:MM'); + expect(dueDateTimePicker).toHaveValue('05:00'); + graderTypeDropdown = await within(configureModal).findByTestId('grader-type-select'); + expect(graderTypeDropdown).toHaveValue(expectedRequestData.graderType); + + advancedTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.advancedTabTitle.defaultMessage }); + fireEvent.click(advancedTab); + radioButtons = await within(configureModal).findAllByRole('radio'); + expect(radioButtons[0]).toHaveProperty('checked', false); + expect(radioButtons[1]).toHaveProperty('checked', true); + hoursWrapper = await within(configureModal).findByTestId('advanced-tab-hours-picker-wrapper'); + hours = await within(hoursWrapper).findByPlaceholderText('HH:MM'); + expect(hours).toHaveValue('54:30'); + }); + + it('check prereq and proctoring settings in configure modal for subsection', async () => { + const { + findAllByTestId, + findByTestId, + } = render(); + const section = cloneDeep(courseOutlineIndexMock.courseStructure.childInfo.children[0]); + const [subsection, secondSubsection] = section.childInfo.children; + const expectedRequestData = { + publish: 'republish', + graderType: 'notgraded', + isPrereq: true, + prereqUsageKey: secondSubsection.id, + prereqMinScore: 80, + prereqMinCompletion: 90, + metadata: { + visible_to_staff_only: true, + due: '', + hide_after_due: false, + show_correctness: 'always', + is_practice_exam: false, + is_time_limited: true, + is_proctored_enabled: true, + exam_review_rules: 'some rules for proctored exams', + default_time_limit_minutes: 30, + is_onboarding_exam: false, + start: '1970-01-01T05:00:00Z', + }, + }; + + axiosMock + .onPost(getCourseItemApiUrl(subsection.id), expectedRequestData) + .reply(200, { dummy: 'value' }); + + const [currentSection] = await findAllByTestId('section-card'); + const [firstSubsection] = await within(currentSection).findAllByTestId('subsection-card'); + const subsectionDropdownButton = await within(firstSubsection).findByTestId('subsection-card-header__menu-button'); + + subsection.isTimeLimited = expectedRequestData.metadata.is_time_limited; + subsection.defaultTimeLimitMinutes = expectedRequestData.metadata.default_time_limit_minutes; + subsection.isProctoredExam = expectedRequestData.metadata.is_proctored_enabled; + subsection.isPracticeExam = expectedRequestData.metadata.is_practice_exam; + subsection.isOnboardingExam = expectedRequestData.metadata.is_onboarding_exam; + subsection.examReviewRules = expectedRequestData.metadata.exam_review_rules; + subsection.isPrereq = expectedRequestData.isPrereq; + subsection.prereq = expectedRequestData.prereqUsageKey; + subsection.prereqMinScore = expectedRequestData.prereqMinScore; + subsection.prereqMinCompletion = expectedRequestData.prereqMinCompletion; + section.childInfo.children[0] = subsection; + axiosMock + .onGet(getXBlockApiUrl(section.id)) + .reply(200, section); + + fireEvent.click(subsectionDropdownButton); + const configureBtn = await within(firstSubsection).findByTestId('subsection-card-header__menu-configure-button'); + fireEvent.click(configureBtn); + + // update fields + let configureModal = await findByTestId('configure-modal'); + let advancedTab = await within(configureModal).findByRole( + 'tab', + { name: configureModalMessages.advancedTabTitle.defaultMessage }, + ); + + // visibility tab + const visibilityTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.visibilityTabTitle.defaultMessage }); + fireEvent.click(visibilityTab); + const visibilityRadioButtons = await within(configureModal).findAllByRole('radio'); + fireEvent.click(visibilityRadioButtons[2]); + + fireEvent.click(advancedTab); + let radioButtons = await within(configureModal).findAllByRole('radio'); + fireEvent.click(radioButtons[2]); + let hoursWrapper = await within(configureModal).findByTestId('advanced-tab-hours-picker-wrapper'); + let hours = await within(hoursWrapper).findByPlaceholderText('HH:MM'); + fireEvent.change(hours, { target: { value: '00:30' } }); + // select a prerequisite + const prereqSelect = await within(configureModal).findByRole('combobox'); + fireEvent.change(prereqSelect, { target: { value: expectedRequestData.prereqUsageKey } }); + + // update minimum score and completion percentage + let prereqMinScoreInput = await within(configureModal).findByLabelText( + configureModalMessages.minScoreLabel.defaultMessage, + ); + fireEvent.change(prereqMinScoreInput, { target: { value: expectedRequestData.prereqMinScore } }); + let prereqMinCompletionInput = await within(configureModal).findByLabelText( + configureModalMessages.minCompletionLabel.defaultMessage, + ); + fireEvent.change(prereqMinCompletionInput, { target: { value: expectedRequestData.prereqMinCompletion } }); + + // enable this subsection to be used as prerequisite by other subsections + let prereqCheckbox = await within(configureModal).findByLabelText( + configureModalMessages.prereqCheckboxLabel.defaultMessage, + ); + fireEvent.click(prereqCheckbox); + + // fill some rules for proctored exams + let examsRulesInput = await within(configureModal).findByLabelText( + configureModalMessages.reviewRulesLabel.defaultMessage, + ); + fireEvent.change(examsRulesInput, { target: { value: expectedRequestData.metadata.exam_review_rules } }); + + const saveButton = await within(configureModal).findByTestId('configure-save-button'); + await act(async () => fireEvent.click(saveButton)); + + // verify request + expect(axiosMock.history.post.length).toBe(1); + expect(axiosMock.history.post[0].data).toBe(JSON.stringify(expectedRequestData)); + + // reopen modal and check values + await act(async () => fireEvent.click(subsectionDropdownButton)); + await act(async () => fireEvent.click(configureBtn)); + + configureModal = await findByTestId('configure-modal'); + advancedTab = await within(configureModal).findByRole('tab', { + name: configureModalMessages.advancedTabTitle.defaultMessage, }); + fireEvent.click(advancedTab); + radioButtons = await within(configureModal).findAllByRole('radio'); + expect(radioButtons[0]).toHaveProperty('checked', false); + expect(radioButtons[1]).toHaveProperty('checked', false); + expect(radioButtons[2]).toHaveProperty('checked', true); + hoursWrapper = await within(configureModal).findByTestId('advanced-tab-hours-picker-wrapper'); + hours = await within(hoursWrapper).findByPlaceholderText('HH:MM'); + expect(hours).toHaveValue('00:30'); + prereqCheckbox = await within(configureModal).findByLabelText( + configureModalMessages.prereqCheckboxLabel.defaultMessage, + ); + expect(prereqCheckbox).toBeChecked(); + const prereqSelectOption = await within(configureModal).findByRole('option', { selected: true }); + expect(prereqSelectOption).toHaveAttribute('value', expectedRequestData.prereqUsageKey); + examsRulesInput = await within(configureModal).findByLabelText( + configureModalMessages.reviewRulesLabel.defaultMessage, + ); + expect(examsRulesInput).toHaveTextContent(expectedRequestData.metadata.exam_review_rules); + + prereqMinScoreInput = await within(configureModal).findByLabelText( + configureModalMessages.minScoreLabel.defaultMessage, + ); + expect(prereqMinScoreInput).toHaveAttribute('value', `${expectedRequestData.prereqMinScore}`); + prereqMinCompletionInput = await within(configureModal).findByLabelText( + configureModalMessages.minCompletionLabel.defaultMessage, + ); + expect(prereqMinCompletionInput).toHaveAttribute('value', `${expectedRequestData.prereqMinCompletion}`); + }); - [sectionElement] = await findAllByTestId('section-card'); - subsections = await within(sectionElement).findAllByTestId('subsection-card'); - expect(subsections.length).toBe(2); + it('check practice proctoring settings in configure modal', async () => { + const { + findAllByTestId, + findByTestId, + } = render(); + const section = cloneDeep(courseOutlineIndexMock.courseStructure.childInfo.children[0]); + const [subsection] = section.childInfo.children; + const expectedRequestData = { + publish: 'republish', + graderType: 'notgraded', + isPrereq: false, + prereqMinScore: 100, + prereqMinCompletion: 100, + metadata: { + visible_to_staff_only: null, + due: '', + hide_after_due: false, + show_correctness: 'never', + is_practice_exam: true, + is_time_limited: true, + is_proctored_enabled: true, + exam_review_rules: '', + default_time_limit_minutes: 30, + is_onboarding_exam: false, + start: '1970-01-01T05:00:00Z', + }, + }; + + axiosMock + .onPost(getCourseItemApiUrl(subsection.id), expectedRequestData) + .reply(200, { dummy: 'value' }); + + const [currentSection] = await findAllByTestId('section-card'); + const [firstSubsection] = await within(currentSection).findAllByTestId('subsection-card'); + const subsectionDropdownButton = await within(firstSubsection).findByTestId('subsection-card-header__menu-button'); + + subsection.isTimeLimited = expectedRequestData.metadata.is_time_limited; + subsection.defaultTimeLimitMinutes = expectedRequestData.metadata.default_time_limit_minutes; + subsection.isProctoredExam = expectedRequestData.metadata.is_proctored_enabled; + subsection.isPracticeExam = expectedRequestData.metadata.is_practice_exam; + subsection.isOnboardingExam = expectedRequestData.metadata.is_onboarding_exam; + subsection.examReviewRules = expectedRequestData.metadata.exam_review_rules; + section.childInfo.children[0] = subsection; + axiosMock + .onGet(getXBlockApiUrl(section.id)) + .reply(200, section); + + fireEvent.click(subsectionDropdownButton); + const configureBtn = await within(firstSubsection).findByTestId('subsection-card-header__menu-configure-button'); + fireEvent.click(configureBtn); + + // update fields + let configureModal = await findByTestId('configure-modal'); + let advancedTab = await within(configureModal).findByRole( + 'tab', + { name: configureModalMessages.advancedTabTitle.defaultMessage }, + ); + // visibility tab + const visibilityTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.visibilityTabTitle.defaultMessage }); + fireEvent.click(visibilityTab); + const visibilityRadioButtons = await within(configureModal).findAllByRole('radio'); + fireEvent.click(visibilityRadioButtons[4]); + + // advancedTab + fireEvent.click(advancedTab); + let radioButtons = await within(configureModal).findAllByRole('radio'); + fireEvent.click(radioButtons[3]); + let hoursWrapper = await within(configureModal).findByTestId('advanced-tab-hours-picker-wrapper'); + let hours = await within(hoursWrapper).findByPlaceholderText('HH:MM'); + fireEvent.change(hours, { target: { value: '00:30' } }); + + // rules box should not be visible + expect(within(configureModal).queryByLabelText( + configureModalMessages.reviewRulesLabel.defaultMessage, + )).not.toBeInTheDocument(); + + const saveButton = await within(configureModal).findByTestId('configure-save-button'); + await act(async () => fireEvent.click(saveButton)); + + // verify request + expect(axiosMock.history.post.length).toBe(1); + expect(axiosMock.history.post[0].data).toBe(JSON.stringify(expectedRequestData)); + + // reopen modal and check values + await act(async () => fireEvent.click(subsectionDropdownButton)); + await act(async () => fireEvent.click(configureBtn)); + + configureModal = await findByTestId('configure-modal'); + advancedTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.advancedTabTitle.defaultMessage }); + fireEvent.click(advancedTab); + radioButtons = await within(configureModal).findAllByRole('radio'); + expect(radioButtons[0]).toHaveProperty('checked', false); + expect(radioButtons[1]).toHaveProperty('checked', false); + expect(radioButtons[2]).toHaveProperty('checked', false); + expect(radioButtons[3]).toHaveProperty('checked', true); + hoursWrapper = await within(configureModal).findByTestId('advanced-tab-hours-picker-wrapper'); + hours = await within(hoursWrapper).findByPlaceholderText('HH:MM'); + expect(hours).toHaveValue('00:30'); }); - it('check section is published when publish button is clicked', async () => { - const [section] = courseOutlineIndexMock.courseStructure.childInfo.children; - const { findAllByTestId, findByTestId } = render(); + it('check onboarding proctoring settings in configure modal', async () => { + const { + findAllByTestId, + findByTestId, + } = render(); + const section = cloneDeep(courseOutlineIndexMock.courseStructure.childInfo.children[0]); + const [, subsection] = section.childInfo.children; + const expectedRequestData = { + publish: 'republish', + graderType: 'notgraded', + isPrereq: true, + prereqMinScore: 100, + prereqMinCompletion: 100, + metadata: { + visible_to_staff_only: null, + due: '', + hide_after_due: false, + show_correctness: 'past_due', + is_practice_exam: false, + is_time_limited: true, + is_proctored_enabled: true, + exam_review_rules: '', + default_time_limit_minutes: 30, + is_onboarding_exam: true, + start: '2013-02-05T05:00:00Z', + }, + }; axiosMock - .onPost(getCourseItemApiUrl(section.id), { - publish: 'make_public', - }) + .onPost(getCourseItemApiUrl(subsection.id), expectedRequestData) .reply(200, { dummy: 'value' }); + const [currentSection] = await findAllByTestId('section-card'); + const [, secondSubsection] = await within(currentSection).findAllByTestId('subsection-card'); + const subsectionDropdownButton = await within(secondSubsection).findByTestId('subsection-card-header__menu-button'); + + subsection.isTimeLimited = expectedRequestData.metadata.is_time_limited; + subsection.defaultTimeLimitMinutes = expectedRequestData.metadata.default_time_limit_minutes; + subsection.isProctoredExam = expectedRequestData.metadata.is_proctored_enabled; + subsection.isPracticeExam = expectedRequestData.metadata.is_practice_exam; + subsection.isOnboardingExam = expectedRequestData.metadata.is_onboarding_exam; + subsection.examReviewRules = expectedRequestData.metadata.exam_review_rules; + section.childInfo.children[1] = subsection; axiosMock .onGet(getXBlockApiUrl(section.id)) - .reply(200, { - ...section, - published: true, - releasedToStudents: false, - }); + .reply(200, section); - const [sectionElement] = await findAllByTestId('section-card'); - const menu = await within(sectionElement).findByTestId('section-card-header__menu-button'); - fireEvent.click(menu); - const publishButton = await within(sectionElement).findByTestId('section-card-header__menu-publish-button'); - await act(async () => fireEvent.click(publishButton)); - const confirmButton = await findByTestId('publish-confirm-button'); - await act(async () => fireEvent.click(confirmButton)); + fireEvent.click(subsectionDropdownButton); + const configureBtn = await within(secondSubsection).findByTestId('subsection-card-header__menu-configure-button'); + fireEvent.click(configureBtn); - expect( - sectionElement.querySelector('.item-card-header__badge-status'), - ).toHaveTextContent(cardHeaderMessages.statusBadgePublishedNotLive.defaultMessage); + // update fields + let configureModal = await findByTestId('configure-modal'); + // visibility tab + const visibilityTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.visibilityTabTitle.defaultMessage }); + fireEvent.click(visibilityTab); + const visibilityRadioButtons = await within(configureModal).findAllByRole('radio'); + fireEvent.click(visibilityRadioButtons[5]); + + // advancedTab + let advancedTab = await within(configureModal).findByRole( + 'tab', + { name: configureModalMessages.advancedTabTitle.defaultMessage }, + ); + fireEvent.click(advancedTab); + let radioButtons = await within(configureModal).findAllByRole('radio'); + fireEvent.click(radioButtons[3]); + let hoursWrapper = await within(configureModal).findByTestId('advanced-tab-hours-picker-wrapper'); + let hours = await within(hoursWrapper).findByPlaceholderText('HH:MM'); + fireEvent.change(hours, { target: { value: '00:30' } }); + + // rules box should not be visible + expect(within(configureModal).queryByLabelText( + configureModalMessages.reviewRulesLabel.defaultMessage, + )).not.toBeInTheDocument(); + + const saveButton = await within(configureModal).findByTestId('configure-save-button'); + await act(async () => fireEvent.click(saveButton)); + + // verify request + expect(axiosMock.history.post.length).toBe(1); + expect(axiosMock.history.post[0].data).toBe(JSON.stringify(expectedRequestData)); + + // reopen modal and check values + await act(async () => fireEvent.click(subsectionDropdownButton)); + await act(async () => fireEvent.click(configureBtn)); + + configureModal = await findByTestId('configure-modal'); + advancedTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.advancedTabTitle.defaultMessage }); + fireEvent.click(advancedTab); + radioButtons = await within(configureModal).findAllByRole('radio'); + expect(radioButtons[0]).toHaveProperty('checked', false); + expect(radioButtons[1]).toHaveProperty('checked', false); + expect(radioButtons[2]).toHaveProperty('checked', false); + expect(radioButtons[3]).toHaveProperty('checked', true); + hoursWrapper = await within(configureModal).findByTestId('advanced-tab-hours-picker-wrapper'); + hours = await within(hoursWrapper).findByPlaceholderText('HH:MM'); + expect(hours).toHaveValue('00:30'); + }); + + it('check no special exam setting in configure modal', async () => { + const { + findAllByTestId, + findByTestId, + } = render(); + const section = cloneDeep(courseOutlineIndexMock.courseStructure.childInfo.children[1]); + const [subsection] = section.childInfo.children; + const expectedRequestData = { + publish: 'republish', + graderType: 'notgraded', + prereqMinScore: 100, + prereqMinCompletion: 100, + metadata: { + visible_to_staff_only: null, + due: '', + hide_after_due: false, + show_correctness: 'always', + is_practice_exam: false, + is_time_limited: false, + is_proctored_enabled: false, + exam_review_rules: '', + default_time_limit_minutes: 0, + is_onboarding_exam: false, + start: '1970-01-01T05:00:00Z', + }, + }; + + axiosMock + .onPost(getCourseItemApiUrl(subsection.id), expectedRequestData) + .reply(200, { dummy: 'value' }); + + const [, currentSection] = await findAllByTestId('section-card'); + const [subsectionElement] = await within(currentSection).findAllByTestId('subsection-card'); + const subsectionDropdownButton = await within(subsectionElement).findByTestId('subsection-card-header__menu-button'); + + subsection.isTimeLimited = expectedRequestData.metadata.is_time_limited; + subsection.defaultTimeLimitMinutes = expectedRequestData.metadata.default_time_limit_minutes; + subsection.isProctoredExam = expectedRequestData.metadata.is_proctored_enabled; + subsection.isPracticeExam = expectedRequestData.metadata.is_practice_exam; + subsection.isOnboardingExam = expectedRequestData.metadata.is_onboarding_exam; + subsection.examReviewRules = expectedRequestData.metadata.exam_review_rules; + section.childInfo.children[0] = subsection; + axiosMock + .onGet(getXBlockApiUrl(section.id)) + .reply(200, section); + + fireEvent.click(subsectionDropdownButton); + const configureBtn = await within(subsectionElement).findByTestId('subsection-card-header__menu-configure-button'); + fireEvent.click(configureBtn); + + // update fields + let configureModal = await findByTestId('configure-modal'); + + // advancedTab + let advancedTab = await within(configureModal).findByRole( + 'tab', + { name: configureModalMessages.advancedTabTitle.defaultMessage }, + ); + fireEvent.click(advancedTab); + let radioButtons = await within(configureModal).findAllByRole('radio'); + fireEvent.click(radioButtons[0]); + + // time box should not be visible + expect(within(configureModal).queryByLabelText( + configureModalMessages.timeAllotted.defaultMessage, + )).not.toBeInTheDocument(); + + // rules box should not be visible + expect(within(configureModal).queryByLabelText( + configureModalMessages.reviewRulesLabel.defaultMessage, + )).not.toBeInTheDocument(); + + const saveButton = await within(configureModal).findByTestId('configure-save-button'); + await act(async () => fireEvent.click(saveButton)); + + // verify request + expect(axiosMock.history.post.length).toBe(1); + expect(axiosMock.history.post[0].data).toBe(JSON.stringify(expectedRequestData)); + + // reopen modal and check values + await act(async () => fireEvent.click(subsectionDropdownButton)); + await act(async () => fireEvent.click(configureBtn)); + + configureModal = await findByTestId('configure-modal'); + advancedTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.advancedTabTitle.defaultMessage }); + fireEvent.click(advancedTab); + radioButtons = await within(configureModal).findAllByRole('radio'); + expect(radioButtons[0]).toHaveProperty('checked', true); + expect(radioButtons[1]).toHaveProperty('checked', false); + expect(radioButtons[2]).toHaveProperty('checked', false); + expect(radioButtons[3]).toHaveProperty('checked', false); }); - it('check configure section when configure query is successful', async () => { - const { findAllByTestId, findByPlaceholderText } = render(); + it('check configure modal for unit', async () => { + const { findAllByTestId, findByTestId } = render(); const section = courseOutlineIndexMock.courseStructure.childInfo.children[0]; - const newReleaseDate = '2025-08-10T10:00:00Z'; + const [subsection] = section.childInfo.children; + const [unit] = subsection.childInfo.children; + // Enrollment Track Groups : Audit + const newGroupAccess = { 50: [1] }; + const isVisibleToStaffOnly = true; + axiosMock - .onPost(getCourseItemApiUrl(section.id), { + .onPost(getCourseItemApiUrl(unit.id), { publish: 'republish', metadata: { - visible_to_staff_only: true, - start: newReleaseDate, + visible_to_staff_only: isVisibleToStaffOnly, + group_access: newGroupAccess, }, }) .reply(200, { dummy: 'value' }); @@ -463,24 +1268,80 @@ describe('', () => { .reply(200, section); const [firstSection] = await findAllByTestId('section-card'); - - const sectionDropdownButton = await within(firstSection).findByTestId('section-card-header__menu-button'); - fireEvent.click(sectionDropdownButton); + const [firstSubsection] = await within(firstSection).findAllByTestId('subsection-card'); + const subsectionExpandButton = await within(firstSubsection).getByTestId('subsection-card-header__expanded-btn'); + fireEvent.click(subsectionExpandButton); + const [firstUnit] = await within(firstSubsection).findAllByTestId('unit-card'); + const unitDropdownButton = await within(firstUnit).findByTestId('unit-card-header__menu-button'); + + // after configuraiton response + unit.visibilityState = 'staff_only'; + unit.userPartitionInfo = { + selectablePartitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: true, + deleted: false, + }, + ], + }, + ], + selectedPartitionIndex: 0, + selectedGroupsLabel: '', + }; + subsection.childInfo.children[0] = unit; + section.childInfo.children[0] = subsection; axiosMock .onGet(getXBlockApiUrl(section.id)) - .reply(200, { - ...section, - start: newReleaseDate, - }); + .reply(200, section); - await executeThunk(configureCourseSectionQuery(section.id, true, newReleaseDate), store.dispatch); - fireEvent.click(sectionDropdownButton); - const configureBtn = await within(firstSection).findByTestId('section-card-header__menu-configure-button'); + fireEvent.click(unitDropdownButton); + const configureBtn = await within(firstUnit).getByTestId('unit-card-header__menu-configure-button'); fireEvent.click(configureBtn); - const datePicker = await findByPlaceholderText('MM/DD/YYYY'); - expect(datePicker).toHaveValue('08/10/2025'); + let configureModal = await findByTestId('configure-modal'); + expect(await within(configureModal).findByText( + configureModalMessages.unitVisibility.defaultMessage, + )).toBeInTheDocument(); + let visibilityCheckbox = await within(configureModal).findByTestId('unit-visibility-checkbox'); + await act(async () => fireEvent.click(visibilityCheckbox)); + + let groupeType = await within(configureModal).findByTestId('group-type-select'); + fireEvent.change(groupeType, { target: { value: '0' } }); + + let checkboxes = await within(await within(configureModal).findByTestId('group-checkboxes')).findAllByRole('checkbox'); + fireEvent.click(checkboxes[1]); + const saveButton = await within(configureModal).findByTestId('configure-save-button'); + await act(async () => fireEvent.click(saveButton)); + + // reopen modal and check values + await act(async () => fireEvent.click(unitDropdownButton)); + await act(async () => fireEvent.click(configureBtn)); + + configureModal = await findByTestId('configure-modal'); + visibilityCheckbox = await within(configureModal).findByTestId('unit-visibility-checkbox'); + expect(visibilityCheckbox).toBeChecked(); + + groupeType = await within(configureModal).findByTestId('group-type-select'); + expect(groupeType).toHaveValue('0'); + + checkboxes = await within(await within(configureModal).findByTestId('group-checkboxes')).findAllByRole('checkbox'); + + expect(checkboxes[0]).not.toBeChecked(); + expect(checkboxes[1]).toBeChecked(); }); it('check update highlights when update highlights query is successfully', async () => { @@ -518,46 +1379,500 @@ describe('', () => { }); }); - it('check section list is ordered successfully', async () => { - const { getAllByTestId } = render(); + it('check whether section move up and down options work correctly', async () => { + const { findAllByTestId } = render(); + // get second section element const courseBlockId = courseOutlineIndexMock.courseStructure.id; - let { children } = courseOutlineIndexMock.courseStructure.childInfo; - children = children.splice(2, 0, children.splice(0, 1)[0]); + const [, secondSection] = courseOutlineIndexMock.courseStructure.childInfo.children; + const [, sectionElement] = await findAllByTestId('section-card'); + + // mock api call + axiosMock + .onPut(getCourseBlockApiUrl(courseBlockId)) + .reply(200, { dummy: 'value' }); + + // find menu button and click on it to open menu + const menu = await within(sectionElement).findByTestId('section-card-header__menu-button'); + fireEvent.click(menu); + + // move second section to first position to test move up option + const moveUpButton = await within(sectionElement).findByTestId('section-card-header__menu-move-up-button'); + await act(async () => fireEvent.click(moveUpButton)); + const firstSectionId = store.getState().courseOutline.sectionsList[0].id; + expect(secondSection.id).toBe(firstSectionId); + + // move first section back to second position to test move down option + const moveDownButton = await within(sectionElement).findByTestId('section-card-header__menu-move-down-button'); + await act(async () => fireEvent.click(moveDownButton)); + const newSecondSectionId = store.getState().courseOutline.sectionsList[1].id; + expect(secondSection.id).toBe(newSecondSectionId); + }); + + it('check whether section move up & down option is rendered correctly based on index', async () => { + const { findAllByTestId } = render(); + // get first, second and last section element + const { + 0: firstSection, 1: secondSection, length, [length - 1]: lastSection, + } = await findAllByTestId('section-card'); + + // find menu button and click on it to open menu in first section + const firstMenu = await within(firstSection).findByTestId('section-card-header__menu-button'); + await act(async () => fireEvent.click(firstMenu)); + // move down option should be enabled in first element + expect( + await within(firstSection).findByTestId('section-card-header__menu-move-down-button'), + ).not.toHaveAttribute('aria-disabled'); + // move up option should not be enabled in first element + expect( + await within(firstSection).findByTestId('section-card-header__menu-move-up-button'), + ).toHaveAttribute('aria-disabled', 'true'); + + // find menu button and click on it to open menu in second section + const secondMenu = await within(secondSection).findByTestId('section-card-header__menu-button'); + await act(async () => fireEvent.click(secondMenu)); + // both move down & up option should be enabled in second element + expect( + await within(secondSection).findByTestId('section-card-header__menu-move-down-button'), + ).not.toHaveAttribute('aria-disabled'); + expect( + await within(secondSection).findByTestId('section-card-header__menu-move-up-button'), + ).not.toHaveAttribute('aria-disabled'); + + // find menu button and click on it to open menu in last section + const lastMenu = await within(lastSection).findByTestId('section-card-header__menu-button'); + await act(async () => fireEvent.click(lastMenu)); + // move down option should not be enabled in last element + expect( + await within(lastSection).findByTestId('section-card-header__menu-move-down-button'), + ).toHaveAttribute('aria-disabled', 'true'); + // move up option should be enabled in last element + expect( + await within(lastSection).findByTestId('section-card-header__menu-move-up-button'), + ).not.toHaveAttribute('aria-disabled'); + }); + + it('check whether subsection move up and down options work correctly', async () => { + const { findAllByTestId } = render(); + // get second section element + const [section] = courseOutlineIndexMock.courseStructure.childInfo.children; + const [sectionElement] = await findAllByTestId('section-card'); + const [, secondSubsection] = section.childInfo.children; + const [, subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); + // mock api call axiosMock - .onPut(getEnableHighlightsEmailsApiUrl(courseBlockId), { children }) + .onPut(getCourseItemApiUrl(store.getState().courseOutline.sectionsList[0].id)) .reply(200, { dummy: 'value' }); - await executeThunk(setSectionOrderListQuery(courseBlockId, children, () => {}), store.dispatch); + // find menu button and click on it to open menu + const menu = await within(subsectionElement).findByTestId('subsection-card-header__menu-button'); + await act(async () => fireEvent.click(menu)); + + // move second subsection to first position to test move up option + const moveUpButton = await within(subsectionElement).findByTestId('subsection-card-header__menu-move-up-button'); + await act(async () => fireEvent.click(moveUpButton)); + const firstSubsectionId = store.getState().courseOutline.sectionsList[0].childInfo.children[0].id; + expect(secondSubsection.id).toBe(firstSubsectionId); + + // move first section back to second position to test move down option + const moveDownButton = await within(subsectionElement).findByTestId('subsection-card-header__menu-move-down-button'); + await act(async () => fireEvent.click(moveDownButton)); + const secondSubsectionId = store.getState().courseOutline.sectionsList[0].childInfo.children[1].id; + expect(secondSubsection.id).toBe(secondSubsectionId); + }); - await waitFor(() => { - expect(getAllByTestId('section-card')).toHaveLength(4); - const newSections = getAllByTestId('section-card'); - for (let i; i < children.length; i++) { - expect(children[i].id === newSections[i].id); - } + it('check whether subsection move up & down option is rendered correctly based on index', async () => { + const { findAllByTestId } = render(); + // using second section as second section in mock has 3 subsections + const [, sectionElement] = await findAllByTestId('section-card'); + // get first, second and last subsection element + const { + 0: firstSubsection, + 1: secondSubsection, + length, + [length - 1]: lastSubsection, + } = await within(sectionElement).findAllByTestId('subsection-card'); + + // find menu button and click on it to open menu in first section + const firstMenu = await within(firstSubsection).findByTestId('subsection-card-header__menu-button'); + await act(async () => fireEvent.click(firstMenu)); + // move down option should be enabled in first element + expect( + await within(firstSubsection).findByTestId('subsection-card-header__menu-move-down-button'), + ).not.toHaveAttribute('aria-disabled'); + // move up option should not be enabled in first element + expect( + await within(firstSubsection).findByTestId('subsection-card-header__menu-move-up-button'), + ).toHaveAttribute('aria-disabled', 'true'); + + // find menu button and click on it to open menu in second section + const secondMenu = await within(secondSubsection).findByTestId('subsection-card-header__menu-button'); + await act(async () => fireEvent.click(secondMenu)); + // both move down & up option should be enabled in second element + expect( + await within(secondSubsection).findByTestId('subsection-card-header__menu-move-down-button'), + ).not.toHaveAttribute('aria-disabled'); + expect( + await within(secondSubsection).findByTestId('subsection-card-header__menu-move-up-button'), + ).not.toHaveAttribute('aria-disabled'); + + // find menu button and click on it to open menu in last section + const lastMenu = await within(lastSubsection).findByTestId('subsection-card-header__menu-button'); + await act(async () => fireEvent.click(lastMenu)); + // move down option should not be enabled in last element + expect( + await within(lastSubsection).findByTestId('subsection-card-header__menu-move-down-button'), + ).toHaveAttribute('aria-disabled', 'true'); + // move up option should be enabled in last element + expect( + await within(lastSubsection).findByTestId('subsection-card-header__menu-move-up-button'), + ).not.toHaveAttribute('aria-disabled'); + }); + + it('check whether unit move up and down options work correctly', async () => { + const { findAllByTestId } = render(); + // get second section -> second subsection -> second unit element + const [, section] = courseOutlineIndexMock.courseStructure.childInfo.children; + const [, sectionElement] = await findAllByTestId('section-card'); + const [, subsection] = section.childInfo.children; + const [, subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); + const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn'); + await act(async () => fireEvent.click(expandBtn)); + const [, secondUnit] = subsection.childInfo.children; + const [, unitElement] = await within(subsectionElement).findAllByTestId('unit-card'); + + // mock api call + axiosMock + .onPut(getCourseItemApiUrl(store.getState().courseOutline.sectionsList[1].childInfo.children[1].id)) + .reply(200, { dummy: 'value' }); + + // find menu button and click on it to open menu + const menu = await within(unitElement).findByTestId('unit-card-header__menu-button'); + await act(async () => fireEvent.click(menu)); + + // move second unit to first position to test move up option + const moveUpButton = await within(unitElement).findByTestId('unit-card-header__menu-move-up-button'); + await act(async () => fireEvent.click(moveUpButton)); + const firstUnitId = store.getState().courseOutline.sectionsList[1].childInfo.children[1].childInfo.children[0].id; + expect(secondUnit.id).toBe(firstUnitId); + + // move first unit back to second position to test move down option + const moveDownButton = await within(subsectionElement).findByTestId('unit-card-header__menu-move-down-button'); + await act(async () => fireEvent.click(moveDownButton)); + const secondUnitId = store.getState().courseOutline.sectionsList[1].childInfo.children[1].childInfo.children[1].id; + expect(secondUnit.id).toBe(secondUnitId); + }); + + it('check whether unit move up & down option is rendered correctly based on index', async () => { + const { findAllByTestId } = render(); + // using second section -> second subsection as it has 5 units in mock. + const [, sectionElement] = await findAllByTestId('section-card'); + const [, subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); + const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn'); + await act(async () => fireEvent.click(expandBtn)); + // get first, second and last unit element + const { + 0: firstUnit, + 1: secondUnit, + length, + [length - 1]: lastUnit, + } = await within(subsectionElement).findAllByTestId('unit-card'); + + // find menu button and click on it to open menu in first section + const firstMenu = await within(firstUnit).findByTestId('unit-card-header__menu-button'); + await act(async () => fireEvent.click(firstMenu)); + // move down option should be enabled in first element + expect( + await within(firstUnit).findByTestId('unit-card-header__menu-move-down-button'), + ).not.toHaveAttribute('aria-disabled'); + // move up option should not be enabled in first element + expect( + await within(firstUnit).findByTestId('unit-card-header__menu-move-up-button'), + ).toHaveAttribute('aria-disabled', 'true'); + + // find menu button and click on it to open menu in second section + const secondMenu = await within(secondUnit).findByTestId('unit-card-header__menu-button'); + await act(async () => fireEvent.click(secondMenu)); + // both move down & up option should be enabled in second element + expect( + await within(secondUnit).findByTestId('unit-card-header__menu-move-down-button'), + ).not.toHaveAttribute('aria-disabled'); + expect( + await within(secondUnit).findByTestId('unit-card-header__menu-move-up-button'), + ).not.toHaveAttribute('aria-disabled'); + + // find menu button and click on it to open menu in last section + const lastMenu = await within(lastUnit).findByTestId('unit-card-header__menu-button'); + await act(async () => fireEvent.click(lastMenu)); + // move down option should not be enabled in last element + expect( + await within(lastUnit).findByTestId('unit-card-header__menu-move-down-button'), + ).toHaveAttribute('aria-disabled', 'true'); + // move up option should be enabled in last element + expect( + await within(lastUnit).findByTestId('unit-card-header__menu-move-up-button'), + ).not.toHaveAttribute('aria-disabled'); + }); + + it('check that new section list is saved when dragged', async () => { + const { findAllByRole } = render(); + const courseBlockId = courseOutlineIndexMock.courseStructure.id; + const sectionsDraggers = await findAllByRole('button', { name: 'Drag to reorder' }); + const draggableButton = sectionsDraggers[7]; + + axiosMock + .onPut(getCourseBlockApiUrl(courseBlockId)) + .reply(200, { dummy: 'value' }); + + const section1 = store.getState().courseOutline.sectionsList[0].id; + + fireEvent.keyDown(draggableButton, { key: 'ArrowUp' }); + await waitFor(async () => { + fireEvent.keyDown(draggableButton, { code: 'Space' }); + + const saveStatus = store.getState().courseOutline.savingStatus; + expect(saveStatus).toEqual(RequestStatus.SUCCESSFUL); }); + + const section2 = store.getState().courseOutline.sectionsList[1].id; + expect(section1).toBe(section2); }); it('check section list is restored to original order when API call fails', async () => { - const { getAllByTestId } = render(); + const { findAllByRole } = render(); const courseBlockId = courseOutlineIndexMock.courseStructure.id; - const { children } = courseOutlineIndexMock.courseStructure.childInfo; - const newChildren = children.splice(2, 0, children.splice(0, 1)[0]); + const sectionsDraggers = await findAllByRole('button', { name: 'Drag to reorder' }); + const draggableButton = sectionsDraggers[6]; axiosMock - .onPut(getEnableHighlightsEmailsApiUrl(courseBlockId), { children }) + .onPut(getCourseBlockApiUrl(courseBlockId)) .reply(500); - await executeThunk(setSectionOrderListQuery(courseBlockId, undefined, () => children), store.dispatch); + const section1 = store.getState().courseOutline.sectionsList[0].id; + + fireEvent.keyDown(draggableButton, { key: 'ArrowUp' }); + await waitFor(async () => { + fireEvent.keyDown(draggableButton, { code: 'Space' }); + + const saveStatus = store.getState().courseOutline.savingStatus; + expect(saveStatus).toEqual(RequestStatus.FAILED); + }); + + const section1New = store.getState().courseOutline.sectionsList[0].id; + expect(section1).toBe(section1New); + }); + + it('check that new subsection list is saved when dragged', async () => { + const { findAllByTestId } = render(); + + const [sectionElement] = await findAllByTestId('section-card'); + const [section] = store.getState().courseOutline.sectionsList; + const subsectionsDraggers = within(sectionElement).getAllByRole('button', { name: 'Drag to reorder' }); + const draggableButton = subsectionsDraggers[1]; + + axiosMock + .onPut(getCourseItemApiUrl(section.id)) + .reply(200, { dummy: 'value' }); + + const subsection1 = section.childInfo.children[0].id; + + fireEvent.keyDown(draggableButton, { key: 'ArrowUp' }); + await waitFor(async () => { + fireEvent.keyDown(draggableButton, { code: 'Space' }); + + const saveStatus = store.getState().courseOutline.savingStatus; + expect(saveStatus).toEqual(RequestStatus.SUCCESSFUL); + }); + + const subsection2 = store.getState().courseOutline.sectionsList[0].childInfo.children[1].id; + expect(subsection1).toBe(subsection2); + }); + + it('check that new subsection list is restored to original order when API call fails', async () => { + const { findAllByTestId } = render(); + + const [sectionElement] = await findAllByTestId('section-card'); + const [section] = store.getState().courseOutline.sectionsList; + const subsectionsDraggers = within(sectionElement).getAllByRole('button', { name: 'Drag to reorder' }); + const draggableButton = subsectionsDraggers[1]; + + axiosMock + .onPut(getCourseItemApiUrl(section.id)) + .reply(500); + + const subsection1 = section.childInfo.children[0].id; + + fireEvent.keyDown(draggableButton, { key: 'ArrowUp' }); + await waitFor(async () => { + fireEvent.keyDown(draggableButton, { code: 'Space' }); + + const saveStatus = store.getState().courseOutline.savingStatus; + expect(saveStatus).toEqual(RequestStatus.FAILED); + }); + + const subsection1New = store.getState().courseOutline.sectionsList[0].childInfo.children[0].id; + expect(subsection1).toBe(subsection1New); + }); + + it('check that new unit list is saved when dragged', async () => { + const { findAllByTestId } = render(); + const subsectionElement = (await findAllByTestId('subsection-card'))[3]; + const [subsection] = store.getState().courseOutline.sectionsList[1].childInfo.children; + const expandBtn = within(subsectionElement).getByTestId('subsection-card-header__expanded-btn'); + fireEvent.click(expandBtn); + const unitDraggers = await within(subsectionElement).findAllByRole('button', { name: 'Drag to reorder' }); + const draggableButton = unitDraggers[1]; + + axiosMock + .onPut(getCourseItemApiUrl(subsection.id)) + .reply(200, { dummy: 'value' }); + + const unit1 = subsection.childInfo.children[0].id; + + fireEvent.keyDown(draggableButton, { key: 'ArrowUp' }); + await waitFor(async () => { + fireEvent.keyDown(draggableButton, { code: 'Space' }); + + const saveStatus = store.getState().courseOutline.savingStatus; + expect(saveStatus).toEqual(RequestStatus.SUCCESSFUL); + }); + + const unit2 = store.getState().courseOutline.sectionsList[1].childInfo.children[0].childInfo.children[1].id; + expect(unit1).toBe(unit2); + }); + + it('check that new unit list is restored to original order when API call fails', async () => { + const { findAllByTestId } = render(); + const subsectionElement = (await findAllByTestId('subsection-card'))[3]; + const [subsection] = store.getState().courseOutline.sectionsList[1].childInfo.children; + const expandBtn = within(subsectionElement).getByTestId('subsection-card-header__expanded-btn'); + fireEvent.click(expandBtn); + const unitDraggers = await within(subsectionElement).findAllByRole('button', { name: 'Drag to reorder' }); + const draggableButton = unitDraggers[1]; + + axiosMock + .onPut(getCourseItemApiUrl(subsection.id)) + .reply(500); + + const unit1 = subsection.childInfo.children[0].id; + + fireEvent.keyDown(draggableButton, { key: 'ArrowUp' }); + await waitFor(async () => { + fireEvent.keyDown(draggableButton, { code: 'Space' }); + + const saveStatus = store.getState().courseOutline.savingStatus; + expect(saveStatus).toEqual(RequestStatus.FAILED); + }); + + const unit1New = store.getState().courseOutline.sectionsList[1].childInfo.children[0].childInfo.children[0].id; + expect(unit1).toBe(unit1New); + }); + + it('check that drag handle is not visible for non-draggable sections', async () => { + axiosMock + .onGet(getCourseOutlineIndexApiUrl(courseId)) + .reply(200, { + ...courseOutlineIndexMock, + courseStructure: { + ...courseOutlineIndexMock.courseStructure, + childInfo: { + ...courseOutlineIndexMock.courseStructure.childInfo, + children: [ + { + ...courseOutlineIndexMock.courseStructure.childInfo.children[0], + actions: { + draggable: false, + childAddable: true, + deletable: true, + duplicable: true, + }, + }, + ...courseOutlineIndexMock.courseStructure.childInfo.children.slice(1), + ], + }, + }, + }); + const { findAllByTestId } = render(); + const section = courseOutlineIndexMock.courseStructure.childInfo.children[0]; + const [sectionElement] = await findAllByTestId('conditional-sortable-element--no-drag-handle'); await waitFor(() => { - expect(getAllByTestId('section-card')).toHaveLength(4); - const newSections = getAllByTestId('section-card'); - for (let i; i < children.length; i++) { - expect(children[i].id === newSections[i].id); - expect(newChildren[i].id !== newSections[i].id); - } + expect(within(sectionElement).queryByText(section.displayName)).toBeInTheDocument(); }); }); + + it('check whether unit copy & paste option works correctly', async () => { + const { findAllByTestId } = render(); + // get first section -> first subsection -> first unit element + const [section] = courseOutlineIndexMock.courseStructure.childInfo.children; + const [sectionElement] = await findAllByTestId('section-card'); + const [subsection] = section.childInfo.children; + let [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); + const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn'); + await act(async () => fireEvent.click(expandBtn)); + const [unit] = subsection.childInfo.children; + const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card'); + + const expectedClipboardContent = { + content: { + blockType: 'vertical', + blockTypeDisplay: 'Unit', + created: '2024-01-29T07:58:36.844249Z', + displayName: unit.displayName, + id: 15, + olxUrl: 'http://localhost:18010/api/content-staging/v1/staged-content/15/olx', + purpose: 'clipboard', + status: 'ready', + userId: 3, + }, + sourceUsageKey: unit.id, + sourceContexttitle: courseOutlineIndexMock.courseStructure.displayName, + sourceEditUrl: unit.studioUrl, + }; + // mock api call + axiosMock + .onPost(getClipboardUrl(), { + usage_key: unit.id, + }).reply(200, expectedClipboardContent); + // check that initialUserClipboard state is empty + const { initialUserClipboard } = store.getState().courseOutline; + expect(initialUserClipboard).toBeUndefined(); + + // find menu button and click on it to open menu + const menu = await within(unitElement).findByTestId('unit-card-header__menu-button'); + await act(async () => fireEvent.click(menu)); + + // move first unit back to second position to test move down option + const copyButton = await within(unitElement).findByText(cardHeaderMessages.menuCopy.defaultMessage); + await act(async () => fireEvent.click(copyButton)); + + // check that initialUserClipboard state is updated + expect(store.getState().courseOutline.initialUserClipboard).toEqual(expectedClipboardContent); + + [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); + // find clipboard content label + const clipboardLabel = await within(subsectionElement).findByText( + pasteButtonMessages.clipboardContentLabel.defaultMessage, + ); + await act(async () => fireEvent.mouseOver(clipboardLabel)); + + // find clipboard content popup link + expect( + subsectionElement.querySelector('#vertical-paste-button-overlay'), + ).toHaveAttribute('href', unit.studioUrl); + + // check paste button functionality + // mock api call + axiosMock + .onPost(getXBlockBaseApiUrl(), { + parent_locator: subsection.id, + staged_content: 'clipboard', + }).reply(200, { dummy: 'value' }); + const pasteBtn = await within(subsectionElement).findByText(subsectionMessages.pasteButton.defaultMessage); + await act(async () => fireEvent.click(pasteBtn)); + + [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); + const lastUnitElement = (await within(subsectionElement).findAllByTestId('unit-card')).slice(-1)[0]; + expect(lastUnitElement).toHaveTextContent(unit.displayName); + }); }); diff --git a/src/course-outline/__mocks__/courseOutlineIndex.js b/src/course-outline/__mocks__/courseOutlineIndex.js index d7ed4ed35f..0969e5717e 100644 --- a/src/course-outline/__mocks__/courseOutlineIndex.js +++ b/src/course-outline/__mocks__/courseOutlineIndex.js @@ -24,6 +24,8 @@ module.exports = { 'Homework', 'Exam', ], + videoSharingEnabled: true, + videoSharingOptions: 'per-video', hasChanges: false, actions: { deletable: true, @@ -59,7 +61,7 @@ module.exports = { highlightsEnabled: true, highlightsPreviewOnly: false, highlightsDocUrl: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/developing_course/course_sections.html#set-section-highlights-for-weekly-course-highlight-messages', - enableProctoredExams: false, + enableProctoredExams: true, createZendeskTickets: true, enableTimedExams: true, childInfo: { @@ -75,7 +77,7 @@ module.exports = { published: false, publishedOn: 'Aug 23, 2023 at 12:35 UTC', studioUrl: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40chapter%2Bblock%40d8a6192ade314473a78242dfeedfbf5b', - releasedToStudents: true, + releasedToStudents: false, releaseDate: 'Aug 10, 2023 at 22:00 UTC', visibilityState: 'staff_only', hasExplicitStaffLock: true, @@ -137,12 +139,12 @@ module.exports = { category: 'sequential', hasChildren: true, editedOn: 'Jul 07, 2023 at 11:14 UTC', - published: true, + published: false, publishedOn: 'Jul 07, 2023 at 11:14 UTC', studioUrl: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40sequential%2Bblock%40edx_introduction', - releasedToStudents: true, + releasedToStudents: false, releaseDate: 'Jan 01, 1970 at 05:00 UTC', - visibilityState: 'staff_only', + visibilityState: 'needs_attention', hasExplicitStaffLock: false, start: '1970-01-01T05:00:00Z', graded: false, @@ -150,6 +152,11 @@ module.exports = { due: null, relativeWeeksDue: null, format: null, + isPrereq: false, + prereqs: [{ + blockDisplayName: 'Sample Subsection', + blockUsageKey: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@7f75de8dcc261249250b71925f49810f', + }], courseGraders: [ 'Homework', 'Exam', @@ -207,12 +214,12 @@ module.exports = { category: 'vertical', hasChildren: true, editedOn: 'Jul 07, 2023 at 11:14 UTC', - published: true, + published: false, publishedOn: 'Jul 07, 2023 at 11:14 UTC', studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_0270f6de40fc', - releasedToStudents: true, + releasedToStudents: false, releaseDate: 'Jan 01, 1970 at 05:00 UTC', - visibilityState: 'staff_only', + visibilityState: 'needs_attention', hasExplicitStaffLock: false, start: '1970-01-01T05:00:00Z', graded: false, @@ -259,6 +266,7 @@ module.exports = { ancestorHasStaffLock: true, staffOnlyMessage: false, hasPartitionGroupComponents: false, + enableCopyPasteUnits: true, userPartitionInfo: { selectablePartitions: [ { @@ -290,6 +298,7 @@ module.exports = { ancestorHasStaffLock: true, staffOnlyMessage: false, hasPartitionGroupComponents: false, + enableCopyPasteUnits: true, userPartitionInfo: { selectablePartitions: [ { @@ -316,6 +325,108 @@ module.exports = { selectedGroupsLabel: '', }, }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@7f75de8dcc261249250b71925f49810f', + display_name: 'Sample Subsection', + category: 'sequential', + has_children: true, + edited_on: 'Dec 05, 2023 at 10:35 UTC', + published: true, + published_on: 'Dec 05, 2023 at 10:35 UTC', + studio_url: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40sequential%2Bblock%407f75de8dcc261249250b71925f49810f', + released_to_students: true, + release_date: 'Feb 05, 2013 at 05:00 UTC', + visibility_state: 'live', + has_explicit_staff_lock: false, + start: '2013-02-05T05:00:00Z', + graded: false, + due_date: '', + due: null, + relative_weeks_due: null, + format: null, + course_graders: [ + 'Homework', + 'Exam', + ], + has_changes: false, + actions: { + deletable: true, + draggable: true, + childAddable: true, + duplicable: true, + }, + explanatory_message: null, + group_access: {}, + user_partitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + show_correctness: 'always', + hide_after_due: false, + is_proctored_exam: false, + was_exam_ever_linked_with_external: false, + online_proctoring_rules: '', + is_practice_exam: false, + is_onboarding_exam: false, + is_time_limited: false, + isPrereq: true, + exam_review_rules: '', + default_time_limit_minutes: null, + proctoring_exam_configuration_link: null, + supports_onboarding: true, + show_review_rules: true, + child_info: { + category: 'vertical', + display_name: 'Unit', + children: [], + }, + ancestor_has_staff_lock: false, + staff_only_message: false, + enable_copy_paste_units: true, + has_partition_group_components: false, + user_partition_info: { + selectable_partitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + selected_partition_index: -1, + selected_groups_label: '', + }, + }, ], }, ancestorHasStaffLock: false, @@ -466,12 +577,12 @@ module.exports = { ], showCorrectness: 'always', hideAfterDue: false, - isProctoredExam: false, + isProctoredExam: true, wasExamEverLinkedWithExternal: false, onlineProctoringRules: '', isPracticeExam: false, isOnboardingExam: false, - isTimeLimited: false, + isTimeLimited: true, examReviewRules: '', defaultTimeLimitMinutes: null, proctoringExamConfigurationLink: null, @@ -2945,7 +3056,7 @@ module.exports = { languageCode: 'en', lmsLink: '//localhost:18000/courses/course-v1:edx+101+y76/jump_to/block-v1:edx+101+y76+type@course+block@course', mfeProctoredExamSettingsUrl: '', - notificationDismissUrl: '/course_notifications/course-v1:edx+101+y76/2', + notificationDismissUrl: '', proctoringErrors: [], reindexLink: '/course/course-v1:edx+101+y76/search_reindex', rerunNotificationId: 2, diff --git a/src/course-outline/card-header/CardHeader.jsx b/src/course-outline/card-header/CardHeader.jsx index b64c37d1ce..1524b3fb17 100644 --- a/src/course-outline/card-header/CardHeader.jsx +++ b/src/course-outline/card-header/CardHeader.jsx @@ -1,54 +1,84 @@ -import React, { useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import PropTypes from 'prop-types'; import { useIntl } from '@edx/frontend-platform/i18n'; +import { useSearchParams } from 'react-router-dom'; import { - Button, Dropdown, Form, + Hyperlink, Icon, IconButton, - OverlayTrigger, - Tooltip, - Truncate, } from '@openedx/paragon'; import { - ArrowDropDown as ArrowDownIcon, - ArrowDropUp as ArrowUpIcon, MoreVert as MoveVertIcon, EditOutline as EditIcon, } from '@openedx/paragon/icons'; -import classNames from 'classnames'; import { useEscapeClick } from '../../hooks'; import { ITEM_BADGE_STATUS } from '../constants'; -import { getItemStatusBadgeContent } from '../utils'; +import { scrollToElement } from '../utils'; +import CardStatus from './CardStatus'; import messages from './messages'; const CardHeader = ({ title, status, + cardId, hasChanges, - isExpanded, onClickPublish, onClickConfigure, onClickMenuButton, onClickEdit, - onExpand, isFormOpen, onEditSubmit, closeForm, isDisabledEditField, onClickDelete, onClickDuplicate, + onClickMoveUp, + onClickMoveDown, + onClickCopy, + titleComponent, namePrefix, + actions, + enableCopyPasteUnits, + isVertical, + isSequential, + proctoringExamConfigurationLink, + discussionEnabled, + discussionsSettings, + parentInfo, }) => { const intl = useIntl(); + const [searchParams] = useSearchParams(); const [titleValue, setTitleValue] = useState(title); + const cardHeaderRef = useRef(null); - const { badgeTitle, badgeIcon } = getItemStatusBadgeContent(status, messages, intl); const isDisabledPublish = (status === ITEM_BADGE_STATUS.live || status === ITEM_BADGE_STATUS.publishedNotLive) && !hasChanges; + useEffect(() => { + const locatorId = searchParams.get('show'); + if (!locatorId) { + return; + } + + if (cardHeaderRef.current && locatorId === cardId) { + scrollToElement(cardHeaderRef.current); + } + }, []); + + const showDiscussionsEnabledBadge = ( + isVertical + && !parentInfo?.isTimeLimited + && discussionEnabled + && discussionsSettings?.providerType === 'openedx' + && ( + discussionsSettings?.enableGradedUnits + || (!discussionsSettings?.enableGradedUnits && !parentInfo.graded) + ) + ); + useEscapeClick({ onEscape: () => { setTitleValue(title); @@ -58,9 +88,13 @@ const CardHeader = ({ }); return ( -
+
{isFormOpen ? ( - + e && e.focus()} @@ -78,48 +112,20 @@ const CardHeader = ({ /> ) : ( - - {intl.formatMessage(messages.expandTooltip)} - - )} - > - - - )} -
- {!isFormOpen && ( + <> + {titleComponent} + + )} +
+ {(isVertical || isSequential) && ( + )} + {isSequential && proctoringExamConfigurationLink && ( + + {intl.formatMessage(messages.menuProctoringLinkText)} + + )} {intl.formatMessage(messages.menuConfigure)} - - {intl.formatMessage(messages.menuDuplicate)} - - - {intl.formatMessage(messages.menuDelete)} - + {isVertical && enableCopyPasteUnits && ( + + {intl.formatMessage(messages.menuCopy)} + + )} + {actions.duplicable && ( + + {intl.formatMessage(messages.menuDuplicate)} + + )} + {actions.draggable && ( + <> + + {intl.formatMessage(messages.menuMoveUp)} + + + {intl.formatMessage(messages.menuMoveDown)} + + + )} + {actions.deletable && ( + + {intl.formatMessage(messages.menuDelete)} + + )}
@@ -164,12 +209,22 @@ const CardHeader = ({ ); }; +CardHeader.defaultProps = { + enableCopyPasteUnits: false, + isVertical: false, + isSequential: false, + onClickCopy: null, + proctoringExamConfigurationLink: null, + discussionEnabled: false, + discussionsSettings: {}, + parentInfo: {}, +}; + CardHeader.propTypes = { title: PropTypes.string.isRequired, status: PropTypes.string.isRequired, + cardId: PropTypes.string.isRequired, hasChanges: PropTypes.bool.isRequired, - isExpanded: PropTypes.bool.isRequired, - onExpand: PropTypes.func.isRequired, onClickPublish: PropTypes.func.isRequired, onClickConfigure: PropTypes.func.isRequired, onClickMenuButton: PropTypes.func.isRequired, @@ -180,7 +235,32 @@ CardHeader.propTypes = { isDisabledEditField: PropTypes.bool.isRequired, onClickDelete: PropTypes.func.isRequired, onClickDuplicate: PropTypes.func.isRequired, + onClickMoveUp: PropTypes.func.isRequired, + onClickMoveDown: PropTypes.func.isRequired, + onClickCopy: PropTypes.func, + titleComponent: PropTypes.node.isRequired, namePrefix: PropTypes.string.isRequired, + proctoringExamConfigurationLink: PropTypes.string, + actions: PropTypes.shape({ + deletable: PropTypes.bool.isRequired, + draggable: PropTypes.bool.isRequired, + childAddable: PropTypes.bool.isRequired, + duplicable: PropTypes.bool.isRequired, + allowMoveUp: PropTypes.bool, + allowMoveDown: PropTypes.bool, + }).isRequired, + enableCopyPasteUnits: PropTypes.bool, + isVertical: PropTypes.bool, + isSequential: PropTypes.bool, + discussionEnabled: PropTypes.bool, + discussionsSettings: PropTypes.shape({ + providerType: PropTypes.string, + enableGradedUnits: PropTypes.bool, + }), + parentInfo: PropTypes.shape({ + isTimeLimited: PropTypes.bool, + graded: PropTypes.bool, + }), }; export default CardHeader; diff --git a/src/course-outline/card-header/CardHeader.scss b/src/course-outline/card-header/CardHeader.scss index 12744ba9f6..7fcdae9de5 100644 --- a/src/course-outline/card-header/CardHeader.scss +++ b/src/course-outline/card-header/CardHeader.scss @@ -1,44 +1,29 @@ .item-card-header { display: flex; align-items: center; - margin-right: -.5rem; - .item-card-header__expanded-btn { + .item-card-header__title-btn { justify-content: flex-start; padding: 0; - width: 80%; + width: fit-content; height: 1.5rem; margin-right: .25rem; background: transparent; color: $black; } - .item-card-header__badge-status { - display: flex; - padding: 1px .5rem; - justify-content: center; - align-items: center; - gap: .25rem; - border-radius: .375rem; - border: 1px solid $light-300; - margin: 0 .75rem; + .item-card-edit-icon { + opacity: 0; + transition: opacity .3s linear; - & span:last-child { - color: $primary-700; + &:focus { + opacity: 1; } } - .pgn__form-group { - width: 80%; - } -} - -.item-card-header-tooltip { - .tooltip-inner { - max-width: 18.75rem; - } - - .arrow { - transform: translate(5.75rem, 0) !important; + &:hover { + .item-card-edit-icon { + opacity: 1; + } } } diff --git a/src/course-outline/card-header/CardHeader.test.jsx b/src/course-outline/card-header/CardHeader.test.jsx index 704d69baad..1a666b6614 100644 --- a/src/course-outline/card-header/CardHeader.test.jsx +++ b/src/course-outline/card-header/CardHeader.test.jsx @@ -1,9 +1,12 @@ -import React from 'react'; -import { render, fireEvent, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { + act, render, fireEvent, waitFor, +} from '@testing-library/react'; import { IntlProvider } from '@edx/frontend-platform/i18n'; import { ITEM_BADGE_STATUS } from '../constants'; import CardHeader from './CardHeader'; +import TitleButton from './TitleButton'; import messages from './messages'; const onExpandMock = jest.fn(); @@ -12,14 +15,16 @@ const onClickPublishMock = jest.fn(); const onClickEditMock = jest.fn(); const onClickDeleteMock = jest.fn(); const onClickDuplicateMock = jest.fn(); +const onClickConfigureMock = jest.fn(); +const onClickMoveUpMock = jest.fn(); +const onClickMoveDownMock = jest.fn(); const closeFormMock = jest.fn(); const cardHeaderProps = { title: 'Some title', status: ITEM_BADGE_STATUS.live, + cardId: '12345', hasChanges: false, - isExpanded: true, - onExpand: onExpandMock, onClickMenuButton: onClickMenuButtonMock, onClickPublish: onClickPublishMock, onClickEdit: onClickEditMock, @@ -29,26 +34,50 @@ const cardHeaderProps = { isDisabledEditField: false, onClickDelete: onClickDeleteMock, onClickDuplicate: onClickDuplicateMock, - namePrefix: 'section', + onClickConfigure: onClickConfigureMock, + onClickMoveUp: onClickMoveUpMock, + onClickMoveDown: onClickMoveDownMock, + isSequential: true, + namePrefix: 'subsection', + actions: { + draggable: true, + childAddable: true, + deletable: true, + duplicable: true, + }, }; -const renderComponent = (props) => render( - - { + const titleComponent = ( + - , -); + ); + + return render( + + + + , + , + ); +}; describe('', () => { it('render CardHeader component correctly', async () => { const { findByText, findByTestId, queryByTestId } = renderComponent(); expect(await findByText(cardHeaderProps.title)).toBeInTheDocument(); - expect(await findByTestId('section-card-header__expanded-btn')).toBeInTheDocument(); - expect(await findByTestId('section-card-header__badge-status')).toBeInTheDocument(); - expect(await findByTestId('section-card-header__menu')).toBeInTheDocument(); + expect(await findByTestId('subsection-card-header__expanded-btn')).toBeInTheDocument(); + expect(await findByTestId('subsection-card-header__menu')).toBeInTheDocument(); await waitFor(() => { expect(queryByTestId('edit field')).not.toBeInTheDocument(); }); @@ -86,25 +115,25 @@ describe('', () => { expect(await findByText(messages.statusBadgeDraft.defaultMessage)).toBeInTheDocument(); }); - it('check publish menu item is disabled when section status is live or published not live and it has no changes', async () => { + it('check publish menu item is disabled when subsection status is live or published not live and it has no changes', async () => { const { findByText, findByTestId } = renderComponent({ ...cardHeaderProps, status: ITEM_BADGE_STATUS.publishedNotLive, }); - const menuButton = await findByTestId('section-card-header__menu-button'); + const menuButton = await findByTestId('subsection-card-header__menu-button'); fireEvent.click(menuButton); expect(await findByText(messages.menuPublish.defaultMessage)).toHaveAttribute('aria-disabled', 'true'); }); - it('check publish menu item is enabled when section status is live or published not live and it has changes', async () => { + it('check publish menu item is enabled when subsection status is live or published not live and it has changes', async () => { const { findByText, findByTestId } = renderComponent({ ...cardHeaderProps, status: ITEM_BADGE_STATUS.publishedNotLive, hasChanges: true, }); - const menuButton = await findByTestId('section-card-header__menu-button'); + const menuButton = await findByTestId('subsection-card-header__menu-button'); fireEvent.click(menuButton); expect(await findByText(messages.menuPublish.defaultMessage)).not.toHaveAttribute('aria-disabled'); }); @@ -112,7 +141,7 @@ describe('', () => { it('calls handleExpanded when button is clicked', async () => { const { findByTestId } = renderComponent(); - const expandButton = await findByTestId('section-card-header__expanded-btn'); + const expandButton = await findByTestId('subsection-card-header__expanded-btn'); fireEvent.click(expandButton); expect(onExpandMock).toHaveBeenCalled(); }); @@ -120,11 +149,9 @@ describe('', () => { it('calls onClickMenuButton when menu is clicked', async () => { const { findByTestId } = renderComponent(); - const menuButton = await findByTestId('section-card-header__menu-button'); - fireEvent.click(menuButton); - waitFor(() => { - expect(onClickMenuButtonMock).toHaveBeenCalled(); - }); + const menuButton = await findByTestId('subsection-card-header__menu-button'); + await act(async () => fireEvent.click(menuButton)); + expect(onClickMenuButtonMock).toHaveBeenCalled(); }); it('calls onClickPublish when item is clicked', async () => { @@ -133,24 +160,20 @@ describe('', () => { status: ITEM_BADGE_STATUS.draft, }); - const menuButton = await findByTestId('section-card-header__menu-button'); + const menuButton = await findByTestId('subsection-card-header__menu-button'); fireEvent.click(menuButton); const publishMenuItem = await findByText(messages.menuPublish.defaultMessage); - fireEvent.click(publishMenuItem); - waitFor(() => { - expect(onClickPublishMock).toHaveBeenCalled(); - }); + await act(async () => fireEvent.click(publishMenuItem)); + expect(onClickPublishMock).toHaveBeenCalled(); }); it('calls onClickEdit when the button is clicked', async () => { const { findByTestId } = renderComponent(); - const editButton = await findByTestId('section-edit-button'); - fireEvent.click(editButton); - waitFor(() => { - expect(onClickEditMock).toHaveBeenCalled(); - }); + const editButton = await findByTestId('subsection-edit-button'); + await act(async () => fireEvent.click(editButton)); + expect(onClickEditMock).toHaveBeenCalled(); }); it('check is field visible when isFormOpen is true', async () => { @@ -159,9 +182,9 @@ describe('', () => { isFormOpen: true, }); - expect(await findByTestId('section-edit-field')).toBeInTheDocument(); + expect(await findByTestId('subsection-edit-field')).toBeInTheDocument(); waitFor(() => { - expect(queryByTestId('section-card-header__expanded-btn')).not.toBeInTheDocument(); + expect(queryByTestId('subsection-card-header__expanded-btn')).not.toBeInTheDocument(); expect(queryByTestId('edit-button')).not.toBeInTheDocument(); }); }); @@ -173,32 +196,59 @@ describe('', () => { isDisabledEditField: true, }); - expect(await findByTestId('section-edit-field')).toBeDisabled(); + expect(await findByTestId('subsection-edit-field')).toBeDisabled(); }); it('calls onClickDelete when item is clicked', async () => { const { findByText, findByTestId } = renderComponent(); - const menuButton = await findByTestId('section-card-header__menu-button'); - fireEvent.click(menuButton); - + const menuButton = await findByTestId('subsection-card-header__menu-button'); + await act(async () => fireEvent.click(menuButton)); const deleteMenuItem = await findByText(messages.menuDelete.defaultMessage); - fireEvent.click(deleteMenuItem); - waitFor(() => { - expect(onClickDeleteMock).toHaveBeenCalledTimes(1); - }); + await act(async () => fireEvent.click(deleteMenuItem)); + expect(onClickDeleteMock).toHaveBeenCalledTimes(1); }); it('calls onClickDuplicate when item is clicked', async () => { const { findByText, findByTestId } = renderComponent(); - const menuButton = await findByTestId('section-card-header__menu-button'); + const menuButton = await findByTestId('subsection-card-header__menu-button'); fireEvent.click(menuButton); const duplicateMenuItem = await findByText(messages.menuDuplicate.defaultMessage); fireEvent.click(duplicateMenuItem); - waitFor(() => { - expect(onClickDuplicateMock).toHaveBeenCalled(); + await act(async () => fireEvent.click(duplicateMenuItem)); + expect(onClickDuplicateMock).toHaveBeenCalled(); + }); + + it('check if proctoringExamConfigurationLink is visible', async () => { + const { findByText, findByTestId } = renderComponent({ + ...cardHeaderProps, + proctoringExamConfigurationLink: 'https://localhost:8000/', + isSequential: true, + }); + + const menuButton = await findByTestId('subsection-card-header__menu-button'); + await act(async () => fireEvent.click(menuButton)); + + expect(await findByText(messages.menuProctoringLinkText.defaultMessage)).toBeInTheDocument(); + }); + + it('check if discussion enabled badge is visible', async () => { + const { queryByText } = renderComponent({ + ...cardHeaderProps, + isVertical: true, + discussionEnabled: true, + discussionsSettings: { + providerType: 'openedx', + enableGradedUnits: true, + }, + parentInfo: { + isTimeLimited: false, + graded: false, + }, }); + + expect(queryByText(messages.discussionEnabledBadgeText.defaultMessage)).toBeInTheDocument(); }); }); diff --git a/src/course-outline/card-header/CardStatus.jsx b/src/course-outline/card-header/CardStatus.jsx new file mode 100644 index 0000000000..b5dc3560b9 --- /dev/null +++ b/src/course-outline/card-header/CardStatus.jsx @@ -0,0 +1,40 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import classNames from 'classnames'; +import { ITEM_BADGE_STATUS } from '../constants'; +import { getItemStatusBadgeContent } from '../utils'; +import messages from './messages'; +import StatusBadge from './StatusBadge'; + +const CardStatus = ({ + status, + showDiscussionsEnabledBadge, +}) => { + const intl = useIntl(); + const { badgeTitle, badgeIcon } = getItemStatusBadgeContent(status, messages, intl); + + return ( + <> + {showDiscussionsEnabledBadge && ( + + )} + {badgeTitle && ( + + )} + + ); +}; + +CardStatus.propTypes = { + status: PropTypes.string.isRequired, + showDiscussionsEnabledBadge: PropTypes.bool.isRequired, +}; + +export default CardStatus; diff --git a/src/course-outline/card-header/StatusBadge.jsx b/src/course-outline/card-header/StatusBadge.jsx new file mode 100644 index 0000000000..ead29c9909 --- /dev/null +++ b/src/course-outline/card-header/StatusBadge.jsx @@ -0,0 +1,42 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Icon } from '@edx/paragon'; + +const StatusBadge = ({ + text, + icon, + iconClassName, +}) => { + if (text) { + return ( +
+ {icon && ( + + )} + {text} +
+ ); + } + return null; +}; + +StatusBadge.defaultProps = { + text: '', + icon: '', + iconClassName: '', +}; + +StatusBadge.propTypes = { + text: PropTypes.string, + icon: PropTypes.string, + iconClassName: PropTypes.string, +}; + +export default StatusBadge; diff --git a/src/course-outline/card-header/TitleButton.jsx b/src/course-outline/card-header/TitleButton.jsx new file mode 100644 index 0000000000..8f17b0e5f1 --- /dev/null +++ b/src/course-outline/card-header/TitleButton.jsx @@ -0,0 +1,55 @@ +import PropTypes from 'prop-types'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + Button, + OverlayTrigger, + Tooltip, + Truncate, +} from '@edx/paragon'; +import { + ArrowDropDown as ArrowDownIcon, + ArrowDropUp as ArrowUpIcon, +} from '@edx/paragon/icons'; +import messages from './messages'; + +const TitleButton = ({ + title, + isExpanded, + onTitleClick, + namePrefix, +}) => { + const intl = useIntl(); + const titleTooltipMessage = intl.formatMessage(messages.expandTooltip); + + return ( + + {titleTooltipMessage} + + )} + > + + + ); +}; + +TitleButton.propTypes = { + title: PropTypes.string.isRequired, + isExpanded: PropTypes.bool.isRequired, + onTitleClick: PropTypes.func.isRequired, + namePrefix: PropTypes.string.isRequired, +}; + +export default TitleButton; diff --git a/src/course-outline/card-header/TitleLink.jsx b/src/course-outline/card-header/TitleLink.jsx new file mode 100644 index 0000000000..bc7c93d933 --- /dev/null +++ b/src/course-outline/card-header/TitleLink.jsx @@ -0,0 +1,27 @@ +import PropTypes from 'prop-types'; +import { Link } from 'react-router-dom'; +import { Button, Truncate } from '@edx/paragon'; + +const TitleLink = ({ + title, + titleLink, + namePrefix, +}) => ( + +); + +TitleLink.propTypes = { + title: PropTypes.string.isRequired, + titleLink: PropTypes.string.isRequired, + namePrefix: PropTypes.string.isRequired, +}; + +export default TitleLink; diff --git a/src/course-outline/card-header/messages.js b/src/course-outline/card-header/messages.js index 0197722dd9..d9f250970d 100644 --- a/src/course-outline/card-header/messages.js +++ b/src/course-outline/card-header/messages.js @@ -9,6 +9,10 @@ const messages = defineMessages({ id: 'course-authoring.course-outline.card.status-badge.live', defaultMessage: 'Live', }, + statusBadgeGated: { + id: 'course-authoring.course-outline.card.status-badge.gated', + defaultMessage: 'Gated', + }, statusBadgePublishedNotLive: { id: 'course-authoring.course-outline.card.status-badge.published-not-live', defaultMessage: 'Published not live', @@ -21,6 +25,10 @@ const messages = defineMessages({ id: 'course-authoring.course-outline.card.status-badge.draft', defaultMessage: 'Draft', }, + statusBadgeUnpublishedChanges: { + id: 'course-authoring.course-outline.card.status-badge.draft-unpublished-changes', + defaultMessage: 'Draft (Unpublished changes)', + }, altButtonEdit: { id: 'course-authoring.course-outline.card.button.edit.alt', defaultMessage: 'Edit', @@ -37,10 +45,34 @@ const messages = defineMessages({ id: 'course-authoring.course-outline.card.menu.duplicate', defaultMessage: 'Duplicate', }, + menuMoveUp: { + id: 'course-authoring.course-outline.card.menu.moveup', + defaultMessage: 'Move up', + }, + menuMoveDown: { + id: 'course-authoring.course-outline.card.menu.movedown', + defaultMessage: 'Move down', + }, menuDelete: { id: 'course-authoring.course-outline.card.menu.delete', defaultMessage: 'Delete', }, + menuCopy: { + id: 'course-authoring.course-outline.card.menu.delete', + defaultMessage: 'Copy to clipboard', + }, + menuProctoringLinkText: { + id: 'course-authoring.course-outline.card.menu.proctoring-settings', + defaultMessage: 'Proctoring settings', + }, + proctoringLinkTooltip: { + id: 'course-authoring.course-outline.card.menu.proctoring-settings-tooltip', + defaultMessage: 'Proctoring settings', + }, + discussionEnabledBadgeText: { + id: 'course-authoring.course-outline.card.badge.discussionEnabled', + defaultMessage: 'Discussions enabled', + }, }); export default messages; diff --git a/src/course-outline/configure-modal/AdvancedTab.jsx b/src/course-outline/configure-modal/AdvancedTab.jsx new file mode 100644 index 0000000000..c67ee88472 --- /dev/null +++ b/src/course-outline/configure-modal/AdvancedTab.jsx @@ -0,0 +1,276 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import moment from 'moment'; +import { Alert, Form, Hyperlink } from '@edx/paragon'; +import { + Warning as WarningIcon, +} from '@edx/paragon/icons'; +import { FormattedMessage, injectIntl } from '@edx/frontend-platform/i18n'; +import messages from './messages'; + +import PrereqSettings from './PrereqSettings'; + +const AdvancedTab = ({ + values, + setFieldValue, + prereqs, + releasedToStudents, + wasExamEverLinkedWithExternal, + enableProctoredExams, + supportsOnboarding, + wasProctoredExam, + showReviewRules, + onlineProctoringRules, +}) => { + const { + isTimeLimited, + isProctoredExam, + isOnboardingExam, + isPracticeExam, + defaultTimeLimitMinutes, + examReviewRules, + } = values; + let examTypeValue = 'none'; + + if (isTimeLimited && isProctoredExam) { + if (isOnboardingExam) { + examTypeValue = 'onboardingExam'; + } else if (isPracticeExam) { + examTypeValue = 'practiceExam'; + } else { + examTypeValue = 'proctoredExam'; + } + } else if (isTimeLimited) { + examTypeValue = 'timed'; + } + + const formatHour = (hour) => { + const hh = Math.floor(hour / 60); + const mm = hour % 60; + let hhs = `${hh}`; + let mms = `${mm}`; + if (hh < 10) { + hhs = `0${hh}`; + } + if (mm < 10) { + mms = `0${mm}`; + } + if (Number.isNaN(hh)) { + hhs = '00'; + } + if (Number.isNaN(mm)) { + mms = '00'; + } + return `${hhs}:${mms}`; + }; + + const [timeLimit, setTimeLimit] = useState(formatHour(defaultTimeLimitMinutes)); + const showReviewRulesDiv = showReviewRules && isProctoredExam && !isPracticeExam && !isOnboardingExam; + + const handleChange = (e) => { + if (e.target.value === 'timed') { + setFieldValue('isTimeLimited', true); + setFieldValue('isOnboardingExam', false); + setFieldValue('isPracticeExam', false); + setFieldValue('isProctoredExam', false); + } else if (e.target.value === 'onboardingExam') { + setFieldValue('isOnboardingExam', true); + setFieldValue('isProctoredExam', true); + setFieldValue('isTimeLimited', true); + setFieldValue('isPracticeExam', false); + } else if (e.target.value === 'practiceExam') { + setFieldValue('isPracticeExam', true); + setFieldValue('isProctoredExam', true); + setFieldValue('isTimeLimited', true); + setFieldValue('isOnboardingExam', false); + } else if (e.target.value === 'proctoredExam') { + setFieldValue('isProctoredExam', true); + setFieldValue('isTimeLimited', true); + setFieldValue('isOnboardingExam', false); + setFieldValue('isPracticeExam', false); + } else { + setFieldValue('isTimeLimited', false); + setFieldValue('isOnboardingExam', false); + setFieldValue('isPracticeExam', false); + setFieldValue('isProctoredExam', false); + } + }; + + const setCurrentTimeLimit = (event) => { + const { validity: { valid } } = event.target; + let { value } = event.target; + value = value.trim(); + if (value && valid) { + const minutes = moment.duration(value).asMinutes(); + setFieldValue('defaultTimeLimitMinutes', minutes); + } + setTimeLimit(value); + }; + + const renderAlerts = () => { + const proctoredExamLockedIn = releasedToStudents && wasExamEverLinkedWithExternal; + return ( + <> + {proctoredExamLockedIn && !wasProctoredExam && ( + + + + )} + {proctoredExamLockedIn && wasProctoredExam && ( + + + + )} + + ); + }; + + return ( + <> +
+
+ + {renderAlerts()} + + + + } + controlClassName="mw-1-25rem" + > + + + {enableProctoredExams && ( + <> + } + controlClassName="mw-1-25rem" + > + + + {supportsOnboarding ? ( + } + value="onboardingExam" + controlClassName="mw-1-25rem" + > + + + ) : ( + } + > + + + )} + + )} + + { isTimeLimited && ( +
+ + + + + + + +
+ )} + { showReviewRulesDiv && ( +
+ + + + + setFieldValue('examReviewRules', e.target.value)} + value={examReviewRules} + as="textarea" + rows="3" + /> + + + { onlineProctoringRules ? ( + + + + ), + }} + /> + ) : ( + + )} + +
+ )} + + + ); +}; + +AdvancedTab.defaultProps = { + prereqs: [], + wasExamEverLinkedWithExternal: false, + enableProctoredExams: false, + supportsOnboarding: false, + wasProctoredExam: false, + showReviewRules: false, + onlineProctoringRules: '', +}; + +AdvancedTab.propTypes = { + values: PropTypes.shape({ + isTimeLimited: PropTypes.bool.isRequired, + defaultTimeLimitMinutes: PropTypes.number, + isPrereq: PropTypes.bool, + prereqUsageKey: PropTypes.string, + prereqMinScore: PropTypes.number, + prereqMinCompletion: PropTypes.number, + isProctoredExam: PropTypes.bool, + isPracticeExam: PropTypes.bool, + isOnboardingExam: PropTypes.bool, + examReviewRules: PropTypes.string, + }).isRequired, + setFieldValue: PropTypes.func.isRequired, + prereqs: PropTypes.arrayOf(PropTypes.shape({ + blockUsageKey: PropTypes.string.isRequired, + blockDisplayName: PropTypes.string.isRequired, + })), + releasedToStudents: PropTypes.bool.isRequired, + wasExamEverLinkedWithExternal: PropTypes.bool, + enableProctoredExams: PropTypes.bool, + supportsOnboarding: PropTypes.bool, + wasProctoredExam: PropTypes.bool, + showReviewRules: PropTypes.bool, + onlineProctoringRules: PropTypes.string, +}; + +export default injectIntl(AdvancedTab); diff --git a/src/course-outline/configure-modal/BasicTab.jsx b/src/course-outline/configure-modal/BasicTab.jsx index fed316e826..173bc34939 100644 --- a/src/course-outline/configure-modal/BasicTab.jsx +++ b/src/course-outline/configure-modal/BasicTab.jsx @@ -1,43 +1,106 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { Stack } from '@openedx/paragon'; +import { Stack, Form } from '@openedx/paragon'; import { FormattedMessage, injectIntl, useIntl } from '@edx/frontend-platform/i18n'; import messages from './messages'; import { DatepickerControl, DATEPICKER_TYPES } from '../../generic/datepicker-control'; -const BasicTab = ({ releaseDate, setReleaseDate }) => { +const BasicTab = ({ + values, + setFieldValue, + courseGraders, + isSubsection, +}) => { const intl = useIntl(); - const onChange = (value) => { - setReleaseDate(value); - }; + + const { + releaseDate, + graderType, + dueDate, + } = values; + + const onChangeGraderType = (e) => setFieldValue('graderType', e.target.value); + + const createOptions = () => courseGraders.map((option) => ( + + )); return ( <> -

+

- - onChange(date)} - /> - onChange(date)} - /> - +
+ + setFieldValue('releaseDate', val)} + /> + setFieldValue('releaseDate', val)} + /> + +
+ { + isSubsection && ( +
+
+
+ + + + + {createOptions()} + + +
+ + setFieldValue('dueDate', val)} + data-testid="due-date-picker" + /> + setFieldValue('dueDate', val)} + /> + +
+
+ ) + } ); }; BasicTab.propTypes = { - releaseDate: PropTypes.string.isRequired, - setReleaseDate: PropTypes.func.isRequired, + isSubsection: PropTypes.bool.isRequired, + values: PropTypes.shape({ + releaseDate: PropTypes.string.isRequired, + graderType: PropTypes.string.isRequired, + dueDate: PropTypes.string, + }).isRequired, + courseGraders: PropTypes.arrayOf(PropTypes.string).isRequired, + setFieldValue: PropTypes.func.isRequired, }; export default injectIntl(BasicTab); diff --git a/src/course-outline/configure-modal/ConfigureModal.jsx b/src/course-outline/configure-modal/ConfigureModal.jsx index 32dddb2ada..3e7556b466 100644 --- a/src/course-outline/configure-modal/ConfigureModal.jsx +++ b/src/course-outline/configure-modal/ConfigureModal.jsx @@ -1,21 +1,27 @@ /* eslint-disable import/named */ -import React, { useEffect, useState } from 'react'; +import React from 'react'; +import * as Yup from 'yup'; import PropTypes from 'prop-types'; import { useIntl } from '@edx/frontend-platform/i18n'; import { ModalDialog, Button, ActionRow, + Form, Tab, Tabs, } from '@openedx/paragon'; import { useSelector } from 'react-redux'; +import { Formik } from 'formik'; import { VisibilityTypes } from '../../data/constants'; -import { getCurrentItem } from '../data/selectors'; +import { COURSE_BLOCK_NAMES } from '../constants'; +import { getCurrentItem, getProctoredExamsFlag } from '../data/selectors'; import messages from './messages'; import BasicTab from './BasicTab'; import VisibilityTab from './VisibilityTab'; +import AdvancedTab from './AdvancedTab'; +import UnitTab from './UnitTab'; const ConfigureModal = ({ isOpen, @@ -23,65 +29,267 @@ const ConfigureModal = ({ onConfigureSubmit, }) => { const intl = useIntl(); - const { displayName, start: sectionStartDate, visibilityState } = useSelector(getCurrentItem); - const [releaseDate, setReleaseDate] = useState(sectionStartDate); - const [isVisibleToStaffOnly, setIsVisibleToStaffOnly] = useState(visibilityState === VisibilityTypes.STAFF_ONLY); - const [saveButtonDisabled, setSaveButtonDisabled] = useState(true); + const { + displayName, + start: sectionStartDate, + visibilityState, + due, + isTimeLimited, + defaultTimeLimitMinutes, + hideAfterDue, + showCorrectness, + courseGraders, + category, + format, + userPartitionInfo, + ancestorHasStaffLock, + isPrereq, + prereqs, + prereq, + prereqMinScore, + prereqMinCompletion, + releasedToStudents, + wasExamEverLinkedWithExternal, + isProctoredExam, + isOnboardingExam, + isPracticeExam, + examReviewRules, + supportsOnboarding, + showReviewRules, + onlineProctoringRules, + } = useSelector(getCurrentItem); + const enableProctoredExams = useSelector(getProctoredExamsFlag); - useEffect(() => { - setReleaseDate(sectionStartDate); - }, [sectionStartDate]); + const getSelectedGroups = () => { + if (userPartitionInfo?.selectedPartitionIndex >= 0) { + return userPartitionInfo?.selectablePartitions[userPartitionInfo?.selectedPartitionIndex] + ?.groups + .filter(({ selected }) => selected) + .map(({ id }) => `${id}`) + || []; + } + return []; + }; + + const defaultPrereqScore = (val) => { + if (val === null || val === undefined) { + return 100; + } + return parseFloat(val); + }; + + const initialValues = { + releaseDate: sectionStartDate, + isVisibleToStaffOnly: visibilityState === VisibilityTypes.STAFF_ONLY, + saveButtonDisabled: true, + graderType: format == null ? 'notgraded' : format, + dueDate: due == null ? '' : due, + isTimeLimited, + isProctoredExam, + isOnboardingExam, + isPracticeExam, + examReviewRules, + defaultTimeLimitMinutes, + hideAfterDue: hideAfterDue === undefined ? false : hideAfterDue, + showCorrectness, + isPrereq, + prereqUsageKey: prereq, + prereqMinScore: defaultPrereqScore(prereqMinScore), + prereqMinCompletion: defaultPrereqScore(prereqMinCompletion), + // by default it is -1 i.e. accessible to all learners & staff + selectedPartitionIndex: userPartitionInfo?.selectedPartitionIndex, + selectedGroups: getSelectedGroups(), + }; - useEffect(() => { - setIsVisibleToStaffOnly(visibilityState === VisibilityTypes.STAFF_ONLY); - }, [visibilityState]); + const validationSchema = Yup.object().shape({ + isTimeLimited: Yup.boolean(), + isProctoredExam: Yup.boolean(), + isPracticeExam: Yup.boolean(), + isOnboardingExam: Yup.boolean(), + examReviewRules: Yup.string(), + defaultTimeLimitMinutes: Yup.number().nullable(true), + hideAfterDueState: Yup.boolean(), + showCorrectness: Yup.string().required(), + isPrereq: Yup.boolean(), + prereqUsageKey: Yup.string().nullable(true), + prereqMinScore: Yup.number().min( + 0, + intl.formatMessage(messages.minScoreError), + ).max( + 100, + intl.formatMessage(messages.minScoreError), + ).nullable(true), + prereqMinCompletion: Yup.number().min( + 0, + intl.formatMessage(messages.minScoreError), + ).max( + 100, + intl.formatMessage(messages.minScoreError), + ).nullable(true), + selectedPartitionIndex: Yup.number().integer(), + selectedGroups: Yup.array().of(Yup.string()), + }); - useEffect(() => { - const visibilityUnchanged = isVisibleToStaffOnly === (visibilityState === VisibilityTypes.STAFF_ONLY); - setSaveButtonDisabled(visibilityUnchanged && releaseDate === sectionStartDate); - }, [releaseDate, isVisibleToStaffOnly]); + const isSubsection = category === COURSE_BLOCK_NAMES.sequential.id; - const handleSave = () => { - onConfigureSubmit(isVisibleToStaffOnly, releaseDate); + const handleSave = (data) => { + const groupAccess = {}; + switch (category) { + case COURSE_BLOCK_NAMES.chapter.id: + onConfigureSubmit(data.isVisibleToStaffOnly, data.releaseDate); + break; + case COURSE_BLOCK_NAMES.sequential.id: + onConfigureSubmit( + data.isVisibleToStaffOnly, + data.releaseDate, + data.graderType, + data.dueDate, + data.isTimeLimited, + data.isProctoredExam, + data.isOnboardingExam, + data.isPracticeExam, + data.examReviewRules, + data.isTimeLimited ? data.defaultTimeLimitMinutes : 0, + data.hideAfterDue, + data.showCorrectness, + data.isPrereq, + data.prereqUsageKey, + data.prereqMinScore, + data.prereqMinCompletion, + ); + break; + case COURSE_BLOCK_NAMES.vertical.id: + // groupAccess should be {partitionId: [group1, group2]} or {} if selectedPartitionIndex === -1 + if (data.selectedPartitionIndex >= 0) { + const partitionId = userPartitionInfo.selectablePartitions[data.selectedPartitionIndex].id; + groupAccess[partitionId] = data.selectedGroups.map(g => parseInt(g, 10)); + } + onConfigureSubmit(data.isVisibleToStaffOnly, groupAccess); + break; + default: + break; + } }; - return ( - - - - {intl.formatMessage(messages.title, { title: displayName })} - - - + const renderModalBody = (values, setFieldValue) => { + switch (category) { + case COURSE_BLOCK_NAMES.chapter.id: + return ( + + + + + + + + + ); + case COURSE_BLOCK_NAMES.sequential.id: + return ( - + + + + - - - - - {intl.formatMessage(messages.cancelButton)} - - - - + ); + case COURSE_BLOCK_NAMES.vertical.id: + return ( + + ); + default: + return null; + } + }; + + return ( + +
+ + + {intl.formatMessage(messages.title, { title: displayName })} + + + + {({ + values, handleSubmit, dirty, isValid, setFieldValue, + }) => ( + <> + + + {renderModalBody(values, setFieldValue)} + + + + + + {intl.formatMessage(messages.cancelButton)} + + + + + + )} + +
); }; diff --git a/src/course-outline/configure-modal/ConfigureModal.scss b/src/course-outline/configure-modal/ConfigureModal.scss index 1fad13926a..9a0eb64744 100644 --- a/src/course-outline/configure-modal/ConfigureModal.scss +++ b/src/course-outline/configure-modal/ConfigureModal.scss @@ -1,12 +1,14 @@ .configure-modal { - max-width: 33.6875rem; - overflow: visible; - .configure-modal__header { padding-top: 1.5rem; + position: static; + } + + .w-7rem { + width: 7.2rem; } - .configure-modal__body { - overflow: visible; + .mw-1-25rem { + min-width: 1.25rem; } } diff --git a/src/course-outline/configure-modal/ConfigureModal.test.jsx b/src/course-outline/configure-modal/ConfigureModal.test.jsx index b9a331a4eb..9756f32467 100644 --- a/src/course-outline/configure-modal/ConfigureModal.test.jsx +++ b/src/course-outline/configure-modal/ConfigureModal.test.jsx @@ -30,12 +30,25 @@ jest.mock('react-router-dom', () => ({ const currentSectionMock = { displayName: 'Section1', + category: 'chapter', + start: '2025-08-10T10:00:00Z', + visibilityState: true, + format: 'Not Graded', childInfo: { displayName: 'Subsection', children: [ { displayName: 'Subsection 1', id: 1, + category: 'sequential', + due: '', + start: '2025-08-10T10:00:00Z', + visibilityState: true, + defaultTimeLimitMinutes: null, + hideAfterDue: false, + showCorrectness: false, + format: 'Homework', + courseGraders: ['Homework', 'Exam'], childInfo: { displayName: 'Unit', children: [ @@ -49,6 +62,15 @@ const currentSectionMock = { { displayName: 'Subsection 2', id: 2, + category: 'sequential', + due: '', + start: '2025-08-10T10:00:00Z', + visibilityState: true, + defaultTimeLimitMinutes: null, + hideAfterDue: false, + showCorrectness: false, + format: 'Homework', + courseGraders: ['Homework', 'Exam'], childInfo: { displayName: 'Unit', children: [ @@ -62,6 +84,15 @@ const currentSectionMock = { { displayName: 'Subsection 3', id: 3, + category: 'sequential', + due: '', + start: '2025-08-10T10:00:00Z', + visibilityState: true, + defaultTimeLimitMinutes: null, + hideAfterDue: false, + showCorrectness: false, + format: 'Homework', + courseGraders: ['Homework', 'Exam'], childInfo: { children: [], }, @@ -85,7 +116,7 @@ const renderComponent = () => render( , ); -describe('', () => { +describe(' for Section', () => { beforeEach(() => { initializeMockApp({ authenticatedUser: { @@ -117,7 +148,7 @@ describe('', () => { const visibilityTab = getByRole('tab', { name: messages.visibilityTabTitle.defaultMessage }); fireEvent.click(visibilityTab); - expect(getByText(messages.sectionVisibility.defaultMessage)).toBeInTheDocument(); + expect(getByText('Section Visibility')).toBeInTheDocument(); expect(getByText(messages.hideFromLearners.defaultMessage)).toBeInTheDocument(); }); @@ -137,3 +168,252 @@ describe('', () => { expect(saveButton).not.toBeDisabled(); }); }); + +const currentSubsectionMock = { + displayName: 'Subsection 1', + id: 1, + category: 'sequential', + due: '', + start: '2025-08-10T10:00:00Z', + visibilityState: true, + defaultTimeLimitMinutes: null, + hideAfterDue: false, + showCorrectness: false, + format: 'Homework', + courseGraders: ['Homework', 'Exam'], + childInfo: { + displayName: 'Unit', + children: [ + { + id: 11, + displayName: 'Subsection_1 Unit 1', + }, + { + id: 12, + displayName: 'Subsection_1 Unit 2', + }, + ], + }, +}; + +const renderSubsectionComponent = () => render( + + + + , + , +); + +describe(' for Subsection', () => { + beforeEach(() => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + + store = initializeStore(); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + useSelector.mockReturnValue(currentSubsectionMock); + }); + + it('renders subsection ConfigureModal component correctly', () => { + const { getByText, getByRole } = renderSubsectionComponent(); + expect(getByText(`${currentSubsectionMock.displayName} Settings`)).toBeInTheDocument(); + expect(getByText(messages.basicTabTitle.defaultMessage)).toBeInTheDocument(); + expect(getByText(messages.visibilityTabTitle.defaultMessage)).toBeInTheDocument(); + expect(getByText(messages.advancedTabTitle.defaultMessage)).toBeInTheDocument(); + expect(getByText(messages.releaseDate.defaultMessage)).toBeInTheDocument(); + expect(getByText(messages.releaseTimeUTC.defaultMessage)).toBeInTheDocument(); + expect(getByText(messages.grading.defaultMessage)).toBeInTheDocument(); + expect(getByText(messages.gradeAs.defaultMessage)).toBeInTheDocument(); + expect(getByText(messages.dueDate.defaultMessage)).toBeInTheDocument(); + expect(getByText(messages.dueTimeUTC.defaultMessage)).toBeInTheDocument(); + expect(getByRole('button', { name: messages.cancelButton.defaultMessage })).toBeInTheDocument(); + expect(getByRole('button', { name: messages.saveButton.defaultMessage })).toBeInTheDocument(); + }); + + it('switches to the subsection Visibility tab and renders correctly', () => { + const { getByRole, getByText } = renderSubsectionComponent(); + + const visibilityTab = getByRole('tab', { name: messages.visibilityTabTitle.defaultMessage }); + fireEvent.click(visibilityTab); + expect(getByText('Subsection Visibility')).toBeInTheDocument(); + expect(getByText(messages.showEntireSubsection.defaultMessage)).toBeInTheDocument(); + expect(getByText(messages.showEntireSubsectionDescription.defaultMessage)).toBeInTheDocument(); + expect(getByText(messages.hideContentAfterDue.defaultMessage)).toBeInTheDocument(); + expect(getByText(messages.hideContentAfterDueDescription.defaultMessage)).toBeInTheDocument(); + expect(getByText(messages.hideEntireSubsection.defaultMessage)).toBeInTheDocument(); + expect(getByText(messages.hideEntireSubsectionDescription.defaultMessage)).toBeInTheDocument(); + expect(getByText(messages.assessmentResultsVisibility.defaultMessage)).toBeInTheDocument(); + expect(getByText(messages.alwaysShowAssessmentResults.defaultMessage)).toBeInTheDocument(); + expect(getByText(messages.alwaysShowAssessmentResultsDescription.defaultMessage)).toBeInTheDocument(); + expect(getByText(messages.neverShowAssessmentResults.defaultMessage)).toBeInTheDocument(); + expect(getByText(messages.neverShowAssessmentResultsDescription.defaultMessage)).toBeInTheDocument(); + expect(getByText(messages.showAssessmentResultsPastDue.defaultMessage)).toBeInTheDocument(); + expect(getByText(messages.showAssessmentResultsPastDueDescription.defaultMessage)).toBeInTheDocument(); + }); + + it('switches to the subsection Advanced tab and renders correctly', () => { + const { getByRole, getByText } = renderSubsectionComponent(); + + const advancedTab = getByRole('tab', { name: messages.advancedTabTitle.defaultMessage }); + fireEvent.click(advancedTab); + expect(getByText(messages.setSpecialExam.defaultMessage)).toBeInTheDocument(); + expect(getByText(messages.none.defaultMessage)).toBeInTheDocument(); + expect(getByText(messages.timed.defaultMessage)).toBeInTheDocument(); + expect(getByText(messages.timedDescription.defaultMessage)).toBeInTheDocument(); + }); + + it('disables the Save button and enables it if there is a change', () => { + const { getByRole, getByTestId } = renderSubsectionComponent(); + + const saveButton = getByRole('button', { name: messages.saveButton.defaultMessage }); + expect(saveButton).toBeDisabled(); + + const input = getByTestId('grader-type-select'); + fireEvent.change(input, { target: { value: 'Exam' } }); + expect(saveButton).not.toBeDisabled(); + }); +}); + +const currentUnitMock = { + displayName: 'Unit 1', + id: 1, + category: 'vertical', + due: '', + start: '2025-08-10T10:00:00Z', + visibilityState: true, + defaultTimeLimitMinutes: null, + hideAfterDue: false, + showCorrectness: false, + userPartitionInfo: { + selectablePartitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 6, + name: 'Honor', + selected: false, + deleted: false, + }, + { + id: 2, + name: 'Verified', + selected: false, + deleted: false, + }, + ], + }, + { + id: 1508065533, + name: 'Content Groups', + scheme: 'cohort', + groups: [ + { + id: 1224170703, + name: 'Content Group 1', + selected: false, + deleted: false, + }, + ], + }, + ], + selectedPartitionIndex: -1, + selectedGroupsLabel: '', + }, +}; + +const renderUnitComponent = () => render( + + + + , + , +); + +describe(' for Unit', () => { + beforeEach(() => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + + store = initializeStore(); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + useSelector.mockReturnValue(currentUnitMock); + }); + + it('renders unit ConfigureModal component correctly', () => { + const { + getByText, queryByText, getByRole, getByTestId, + } = renderUnitComponent(); + expect(getByText(`${currentUnitMock.displayName} Settings`)).toBeInTheDocument(); + expect(getByText(messages.unitVisibility.defaultMessage)).toBeInTheDocument(); + expect(getByText(messages.hideFromLearners.defaultMessage)).toBeInTheDocument(); + expect(getByText(messages.restrictAccessTo.defaultMessage)).toBeInTheDocument(); + expect(getByText(messages.unitSelectGroupType.defaultMessage)).toBeInTheDocument(); + + expect(queryByText(messages.unitSelectGroup.defaultMessage)).not.toBeInTheDocument(); + const input = getByTestId('group-type-select'); + + [0, 1].forEach(groupeTypeIndex => { + fireEvent.change(input, { target: { value: groupeTypeIndex } }); + + expect(getByText(messages.unitSelectGroup.defaultMessage)).toBeInTheDocument(); + currentUnitMock + .userPartitionInfo + .selectablePartitions[groupeTypeIndex].groups + .forEach(g => expect(getByText(g.name)).toBeInTheDocument()); + }); + + expect(getByRole('button', { name: messages.cancelButton.defaultMessage })).toBeInTheDocument(); + expect(getByRole('button', { name: messages.saveButton.defaultMessage })).toBeInTheDocument(); + }); + + it('disables the Save button and enables it if there is a change', () => { + useSelector.mockReturnValue( + { + ...currentUnitMock, + userPartitionInfo: { + ...currentUnitMock.userPartitionInfo, + selectedPartitionIndex: 0, + }, + }, + ); + const { getByRole, getByTestId } = renderUnitComponent(); + + const saveButton = getByRole('button', { name: messages.saveButton.defaultMessage }); + expect(saveButton).toBeDisabled(); + + const input = getByTestId('group-type-select'); + // unrestrict access + fireEvent.change(input, { target: { value: -1 } }); + expect(saveButton).not.toBeDisabled(); + + fireEvent.change(input, { target: { value: 0 } }); + expect(saveButton).toBeDisabled(); + + const checkbox = getByTestId('unit-visibility-checkbox'); + fireEvent.click(checkbox); + expect(saveButton).not.toBeDisabled(); + }); +}); diff --git a/src/course-outline/configure-modal/PrereqSettings.jsx b/src/course-outline/configure-modal/PrereqSettings.jsx new file mode 100644 index 0000000000..e7170e5037 --- /dev/null +++ b/src/course-outline/configure-modal/PrereqSettings.jsx @@ -0,0 +1,117 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Form } from '@edx/paragon'; +import { FormattedMessage, injectIntl, useIntl } from '@edx/frontend-platform/i18n'; +import messages from './messages'; + +import FormikControl from '../../generic/FormikControl'; + +const PrereqSettings = ({ + values, + setFieldValue, + prereqs, +}) => { + const intl = useIntl(); + const { + isPrereq, + prereqUsageKey, + prereqMinScore, + prereqMinCompletion, + } = values; + + if (isPrereq === null || isPrereq === undefined) { + return null; + } + + const handleSelectChange = (e) => { + setFieldValue('prereqUsageKey', e.target.value); + }; + + const prereqSelectionForm = () => ( + <> +
+
+
+ + + + {intl.formatMessage(messages.prerequisiteSelectLabel)} + + + + {prereqs.map((prereqOption) => ( + + ))} + + + {prereqUsageKey && ( + <> + {intl.formatMessage(messages.minScoreLabel)}} + controlClassName="text-right" + controlClasses="w-7rem" + type="number" + trailingElement="%" + /> + {intl.formatMessage(messages.minCompletionLabel)}} + controlClassName="text-right" + controlClasses="w-7rem" + type="number" + trailingElement="%" + /> + + )} + + + ); + + const handleCheckboxChange = e => setFieldValue('isPrereq', e.target.checked); + + return ( + <> + {prereqs.length > 0 && prereqSelectionForm()} +
+
+ + + + + ); +}; + +PrereqSettings.defaultProps = { + prereqs: [], +}; + +PrereqSettings.propTypes = { + values: PropTypes.shape({ + isPrereq: PropTypes.bool, + prereqUsageKey: PropTypes.string, + prereqMinScore: PropTypes.number, + prereqMinCompletion: PropTypes.number, + }).isRequired, + prereqs: PropTypes.arrayOf(PropTypes.shape({ + blockUsageKey: PropTypes.string.isRequired, + blockDisplayName: PropTypes.string.isRequired, + })), + setFieldValue: PropTypes.func.isRequired, +}; + +export default injectIntl(PrereqSettings); diff --git a/src/course-outline/configure-modal/UnitTab.jsx b/src/course-outline/configure-modal/UnitTab.jsx new file mode 100644 index 0000000000..83b85e68ed --- /dev/null +++ b/src/course-outline/configure-modal/UnitTab.jsx @@ -0,0 +1,133 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Alert, Form } from '@edx/paragon'; +import { + FormattedMessage, injectIntl, useIntl, +} from '@edx/frontend-platform/i18n'; +import { Field } from 'formik'; + +import messages from './messages'; + +const UnitTab = ({ + values, + setFieldValue, + showWarning, + userPartitionInfo, +}) => { + const intl = useIntl(); + const { + isVisibleToStaffOnly, + selectedPartitionIndex, + } = values; + + const handleChange = (e) => { + setFieldValue('isVisibleToStaffOnly', e.target.checked); + }; + + const handleSelect = (e) => { + setFieldValue('selectedPartitionIndex', parseInt(e.target.value, 10)); + }; + + return ( + <> +

+
+ + + + {showWarning && ( + + + + )} +
+ + + + + + + {userPartitionInfo.selectablePartitions.map((partition, index) => ( + + ))} + + + {selectedPartitionIndex >= 0 && userPartitionInfo.selectablePartitions.length && ( + + +
+ {userPartitionInfo.selectablePartitions[selectedPartitionIndex].groups.map((group) => ( + + + + {group.name} + + + ))} +
+
+ )} +
+ + + ); +}; + +UnitTab.propTypes = { + values: PropTypes.shape({ + isVisibleToStaffOnly: PropTypes.bool.isRequired, + selectedPartitionIndex: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.number, + ]).isRequired, + }).isRequired, + setFieldValue: PropTypes.func.isRequired, + showWarning: PropTypes.bool.isRequired, + userPartitionInfo: PropTypes.shape({ + selectablePartitions: PropTypes.arrayOf(PropTypes.shape({ + groups: PropTypes.arrayOf(PropTypes.shape({ + deleted: PropTypes.bool.isRequired, + id: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + selected: PropTypes.bool.isRequired, + }).isRequired).isRequired, + id: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + scheme: PropTypes.string.isRequired, + }).isRequired).isRequired, + selectedGroupsLabel: PropTypes.string, + selectedPartitionIndex: PropTypes.number.isRequired, + }).isRequired, +}; + +export default injectIntl(UnitTab); diff --git a/src/course-outline/configure-modal/VisibilityTab.jsx b/src/course-outline/configure-modal/VisibilityTab.jsx index 654b23fd20..44ee964619 100644 --- a/src/course-outline/configure-modal/VisibilityTab.jsx +++ b/src/course-outline/configure-modal/VisibilityTab.jsx @@ -1,38 +1,135 @@ import React from 'react'; import PropTypes from 'prop-types'; import { Alert, Form } from '@openedx/paragon'; -import { FormattedMessage, injectIntl } from '@edx/frontend-platform/i18n'; +import { FormattedMessage, injectIntl, useIntl } from '@edx/frontend-platform/i18n'; import messages from './messages'; +import { COURSE_BLOCK_NAMES } from '../constants'; + +const VisibilityTab = ({ + values, + setFieldValue, + category, + showWarning, + isSubsection, +}) => { + const intl = useIntl(); + const visibilityTitle = COURSE_BLOCK_NAMES[category]?.name; + + const { + isVisibleToStaffOnly, + hideAfterDue, + showCorrectness, + } = values; -const VisibilityTab = ({ isVisibleToStaffOnly, setIsVisibleToStaffOnly, showWarning }) => { const handleChange = (e) => { - setIsVisibleToStaffOnly(e.target.checked); + setFieldValue('isVisibleToStaffOnly', e.target.checked); + }; + + const getVisibilityValue = () => { + if (isVisibleToStaffOnly) { + return 'hide'; + } + if (hideAfterDue) { + return 'hideDue'; + } + return 'show'; + }; + + const visibilityChanged = (e) => { + const selected = e.target.value; + if (selected === 'hide') { + setFieldValue('isVisibleToStaffOnly', true); + setFieldValue('hideAfterDue', false); + } else if (selected === 'hideDue') { + setFieldValue('isVisibleToStaffOnly', false); + setFieldValue('hideAfterDue', true); + } else { + setFieldValue('isVisibleToStaffOnly', false); + setFieldValue('hideAfterDue', false); + } + }; + + const correctnessChanged = (e) => { + setFieldValue('showCorrectness', e.target.value); }; return ( <> -

+
+ {intl.formatMessage(messages.visibilitySectionTitle, { visibilityTitle })} +

- - - - {showWarning && ( - <> -
- - - - - + { + isSubsection ? ( + <> + + + + + + + + + + + + + + + {showWarning && ( + + + + )} +
+ + + + + + + + + + + + + + + + ) : ( + + + + ) + } + {showWarning && !isSubsection && ( + + + )} ); }; VisibilityTab.propTypes = { - isVisibleToStaffOnly: PropTypes.bool.isRequired, + values: PropTypes.shape({ + isVisibleToStaffOnly: PropTypes.bool.isRequired, + hideAfterDue: PropTypes.bool.isRequired, + showCorrectness: PropTypes.string.isRequired, + }).isRequired, + setFieldValue: PropTypes.func.isRequired, + category: PropTypes.string.isRequired, showWarning: PropTypes.bool.isRequired, - setIsVisibleToStaffOnly: PropTypes.func.isRequired, + isSubsection: PropTypes.bool.isRequired, }; export default injectIntl(VisibilityTab); diff --git a/src/course-outline/configure-modal/messages.js b/src/course-outline/configure-modal/messages.js index 3fd9f50bc8..316cbc0fb0 100644 --- a/src/course-outline/configure-modal/messages.js +++ b/src/course-outline/configure-modal/messages.js @@ -9,6 +9,10 @@ const messages = defineMessages({ id: 'course-authoring.course-outline.configure-modal.basic-tab.title', defaultMessage: 'Basic', }, + notGradedTypeOption: { + id: 'course-authoring.course-outline.configure-modal.basic-tab.notGradedTypeOption', + defaultMessage: 'Not Graded', + }, releaseDateAndTime: { id: 'course-authoring.course-outline.configure-modal.basic-tab.release-date-and-time', defaultMessage: 'Release Date and Time', @@ -25,17 +29,45 @@ const messages = defineMessages({ id: 'course-authoring.course-outline.configure-modal.visibility-tab.title', defaultMessage: 'Visibility', }, - sectionVisibility: { + visibilitySectionTitle: { id: 'course-authoring.course-outline.configure-modal.visibility-tab.section-visibility', - defaultMessage: 'Section Visibility', + defaultMessage: '{visibilityTitle} Visibility', + }, + unitVisibility: { + id: 'course-authoring.course-outline.configure-modal.visibility-tab.unit-visibility', + defaultMessage: 'Unit Visibility', }, hideFromLearners: { - id: 'course-authoring.course-outline.configure-modal.visibility-tab.hide-from-learners', + id: 'course-authoring.course-outline.configure-modal.visibility.hide-from-learners', defaultMessage: 'Hide from learners', }, - visibilityWarning: { - id: 'course-authoring.course-outline.configure-modal.visibility-tab.visibility-warning', - defaultMessage: 'If you make this section visible to learners, learners will be able to see its content after the release date has passed and you have published the unit. Only units that are explicitly hidden from learners will remain hidden after you clear this option for the section.', + restrictAccessTo: { + id: 'course-authoring.course-outline.configure-modal.visibility.restrict-access-to', + defaultMessage: 'Restrict access to', + }, + sectionVisibilityWarning: { + id: 'course-authoring.course-outline.configure-modal.visibility-tab.section-visibility-warning', + defaultMessage: 'If you make this section visible to learners, learners will be able to see its content after the release date has passed and you have published the section. Only units that are explicitly hidden from learners will remain hidden after you clear this option for the section.', + }, + unitVisibilityWarning: { + id: 'course-authoring.course-outline.configure-modal.unit-tab.unit-visibility-warning', + defaultMessage: 'If the unit was previously published and released to learners, any changes you made to the unit when it was hidden will now be visible to learners.', + }, + subsectionVisibilityWarning: { + id: 'course-authoring.course-outline.configure-modal.unit-tab.subsection-visibility-warning', + defaultMessage: 'If you select an option other than "Hide entire subsection", published units in this subsection will become available to learners unless they are explicitly hidden.', + }, + unitSelectGroup: { + id: 'course-authoring.course-outline.configure-modal.unit-tab.unit-select-group', + defaultMessage: 'Select one or more groups:', + }, + unitSelectGroupType: { + id: 'course-authoring.course-outline.configure-modal.unit-tab.unit-select-group-type', + defaultMessage: 'Select a group type', + }, + unitAllLearnersAndStaff: { + id: 'course-authoring.course-outline.configure-modal.unit-tab.unit-all-learners-staff', + defaultMessage: 'All Learners and Staff', }, cancelButton: { id: 'course-authoring.course-outline.configure-modal.button.cancel', @@ -45,6 +77,194 @@ const messages = defineMessages({ id: 'course-authoring.course-outline.configure-modal.button.label', defaultMessage: 'Save', }, + grading: { + id: 'course-authoring.course-outline.configure-modal.basic-tab.grading', + defaultMessage: 'Grading', + }, + gradeAs: { + id: 'course-authoring.course-outline.configure-modal.basic-tab.grade-as', + defaultMessage: 'Grade as:', + }, + dueDate: { + id: 'course-authoring.course-outline.configure-modal.basic-tab.due-date', + defaultMessage: 'Due Date:', + }, + dueTimeUTC: { + id: 'course-authoring.course-outline.configure-modal.basic-tab.due-time-UTC', + defaultMessage: 'Due Time in UTC:', + }, + subsectionVisibility: { + id: 'course-authoring.course-outline.configure-modal.visibility-tab.subsection-visibility', + defaultMessage: 'Subsection Visibility', + }, + showEntireSubsection: { + id: 'course-authoring.course-outline.configure-modal.visibility-tab.show-entire-subsection', + defaultMessage: 'Show entire subsection', + }, + showEntireSubsectionDescription: { + id: 'course-authoring.course-outline.configure-modal.visibility-tab.show-entire-subsection-description', + defaultMessage: 'Learners see the published subsection and can access its content', + }, + hideContentAfterDue: { + id: 'course-authoring.course-outline.configure-modal.visibility-tab.hide-content-after-due', + defaultMessage: 'Hide content after due date', + }, + hideContentAfterDueDescription: { + id: 'course-authoring.course-outline.configure-modal.visibility-tab.hide-content-after-due-description', + defaultMessage: 'After the subsection\'s due date has passed, learners can no longer access its content. The subsection is not included in grade calculations.', + }, + hideEntireSubsection: { + id: 'course-authoring.course-outline.configure-modal.visibility-tab.hide-entire-subsection', + defaultMessage: 'Hide entire subsection', + }, + hideEntireSubsectionDescription: { + id: 'course-authoring.course-outline.configure-modal.visibility-tab.hide-entire-subsection-description', + defaultMessage: 'Learners do not see the subsection in the course outline. The subsection is not included in grade calculations.', + }, + assessmentResultsVisibility: { + id: 'course-authoring.course-outline.configure-modal.visibility-tab.assessment-results-visibility', + defaultMessage: 'Assessment Results Visibility', + }, + alwaysShowAssessmentResults: { + id: 'course-authoring.course-outline.configure-modal.visibility-tab.always-show-assessment-results', + defaultMessage: 'Always show assessment results', + }, + alwaysShowAssessmentResultsDescription: { + id: 'course-authoring.course-outline.configure-modal.visibility-tab.always-show-assessment-results-description', + defaultMessage: 'When learners submit an answer to an assessment, they immediately see whether the answer is correct or incorrect, and the score received.', + }, + neverShowAssessmentResults: { + id: 'course-authoring.course-outline.configure-modal.visibility-tab.never-show-assessment-results', + defaultMessage: 'Never show assessment results', + }, + neverShowAssessmentResultsDescription: { + id: 'course-authoring.course-outline.configure-modal.visibility-tab.never-show-assessment-results-description', + defaultMessage: 'Learners never see whether their answers to assessments are correct or incorrect, nor the score received.', + }, + showAssessmentResultsPastDue: { + id: 'course-authoring.course-outline.configure-modal.visibility-tab.show-assessment-results-past-due', + defaultMessage: 'Show assessment results when subsection is past due', + }, + showAssessmentResultsPastDueDescription: { + id: 'course-authoring.course-outline.configure-modal.visibility-tab.show-assessment-results-past-due-description', + defaultMessage: 'Learners do not see whether their answer to assessments were correct or incorrect, nor the score received, until after the due date for the subsection has passed. If the subsection does not have a due date, learners always see their scores when they submit answers to assessments.', + }, + setSpecialExam: { + id: 'course-authoring.course-outline.configure-modal.advanced-tab.set-special-exam', + defaultMessage: 'Set as a Special Exam', + }, + none: { + id: 'course-authoring.course-outline.configure-modal.advanced-tab.none', + defaultMessage: 'None', + }, + timed: { + id: 'course-authoring.course-outline.configure-modal.advanced-tab.timed', + defaultMessage: 'Timed', + }, + timedDescription: { + id: 'course-authoring.course-outline.configure-modal.advanced-tab.timed-description', + defaultMessage: 'Use a timed exam to limit the time learners can spend on problems in this subsection. Learners must submit answers before the time expires. You can allow additional time for individual learners through the instructor Dashboard.', + }, + proctoredExam: { + id: 'course-authoring.course-outline.configure-modal.advanced-tab.proctoredExam', + defaultMessage: 'Proctored', + }, + proctoredExamDescription: { + id: 'course-authoring.course-outline.configure-modal.advanced-tab.timed-description', + defaultMessage: 'Proctored exams are timed and they record video of each learner taking the exam. The videos are then reviewed to ensure that learners follow all examination rules. Please note that setting this exam as proctored will change the visibility settings to "Hide content after due date."', + }, + onboardingExam: { + id: 'course-authoring.course-outline.configure-modal.advanced-tab.onboardingExam', + defaultMessage: 'Onboarding', + }, + onboardingExamDescription: { + id: 'course-authoring.course-outline.configure-modal.advanced-tab.timed-description', + defaultMessage: 'Use Onboarding to introduce learners to proctoring, verify their identity, and create an onboarding profile. Learners must complete the onboarding profile step prior to taking a proctored exam. Profile reviews take 2+ business days.', + }, + practiceExam: { + id: 'course-authoring.course-outline.configure-modal.advanced-tab.practiceExam', + defaultMessage: 'Practice proctored', + }, + practiceExamDescription: { + id: 'course-authoring.course-outline.configure-modal.advanced-tab.timed-description', + defaultMessage: 'Use a practice proctored exam to introduce learners to the proctoring tools and processes. Results of a practice exam do not affect a learner\'s grade.', + }, + advancedTabTitle: { + id: 'course-authoring.course-outline.configure-modal.advanced-tab.title', + defaultMessage: 'Advanced', + }, + timeAllotted: { + id: 'course-authoring.course-outline.configure-modal.advanced-tab.time-allotted', + defaultMessage: 'Time Allotted (HH:MM):', + }, + timeLimitDescription: { + id: 'course-authoring.course-outline.configure-modal.advanced-tab.time-limit-description', + defaultMessage: 'Select a time allotment for the exam. If it is over 24 hours, type in the amount of time. You can grant individual learners extra time to complete the exam through the Instructor Dashboard.', + }, + prereqTitle: { + id: 'course-authoring.course-outline.configure-modal.advanced-tab.prereqTitle', + defaultMessage: 'Use as a Prerequisite', + }, + prereqCheckboxLabel: { + id: 'course-authoring.course-outline.configure-modal.advanced-tab.prereqCheckboxLabel', + defaultMessage: 'Make this subsection available as a prerequisite to other content', + }, + limitAccessTitle: { + id: 'course-authoring.course-outline.configure-modal.advanced-tab.limitAccessTitle', + defaultMessage: 'Limit access', + }, + limitAccessDescription: { + id: 'course-authoring.course-outline.configure-modal.advanced-tab.limitAccessDescription', + defaultMessage: 'Select a prerequisite subsection and enter a minimum score percentage and minimum completion percentage to limit access to this subsection. Allowed values are 0-100', + }, + noPrerequisiteOption: { + id: 'course-authoring.course-outline.configure-modal.advanced-tab.noPrerequisiteOption', + defaultMessage: 'No prerequisite', + }, + prerequisiteSelectLabel: { + id: 'course-authoring.course-outline.configure-modal.advanced-tab.prerequisiteSelectLabel', + defaultMessage: 'Prerequisite:', + }, + minScoreLabel: { + id: 'course-authoring.course-outline.configure-modal.advanced-tab.minScoreLabel', + defaultMessage: 'Minimum score:', + }, + minCompletionLabel: { + id: 'course-authoring.course-outline.configure-modal.advanced-tab.minCompletionLabel', + defaultMessage: 'Minimum completion:', + }, + minScoreError: { + id: 'course-authoring.course-outline.configure-modal.advanced-tab.minScoreError', + defaultMessage: 'The minimum score percentage must be a whole number between 0 and 100.', + }, + minCompletionError: { + id: 'course-authoring.course-outline.configure-modal.advanced-tab.minCompletionError', + defaultMessage: 'The minimum completion percentage must be a whole number between 0 and 100.', + }, + proctoredExamLockedAndisNotProctoredExamAlert: { + id: 'course-authoring.course-outline.configure-modal.advanced-tab.proctoredExamLockedAndisNotProctoredExamAlert', + defaultMessage: 'This subsection was released to learners as a proctored exam, but was reverted back to a basic or timed exam. You may not configure it as a proctored exam now. Contact edX Support for assistance.', + }, + proctoredExamLockedAndisProctoredExamAlert: { + id: 'course-authoring.course-outline.configure-modal.advanced-tab.proctoredExamLockedAndisProctoredExamAlert', + defaultMessage: 'This proctored exam has been released to learners. You may not convert it to another type of special exam. You may revert this subsection back to being a basic exam by selecting \'None\', or a timed exam, but you will NOT be able to configure it as a proctored exam in the future.', + }, + reviewRulesLabel: { + id: 'course-authoring.course-outline.configure-modal.advanced-tab.reviewRulesLabel', + defaultMessage: 'Review rules', + }, + reviewRulesDescription: { + id: 'course-authoring.course-outline.configure-modal.advanced-tab.reviewRulesDescription', + defaultMessage: 'Specify any rules or rule exceptions that the proctoring review team should enforce when reviewing the videos. For example, you could specify that calculators are allowed. These specified rules are visible to learners before the learners start the exam.', + }, + reviewRulesDescriptionWithLink: { + id: 'course-authoring.course-outline.configure-modal.advanced-tab.reviewRulesDescriptionWithLink', + defaultMessage: 'Specify any rules or rule exceptions that the proctoring review team should enforce when reviewing the videos. For example, you could specify that calculators are allowed. These specified rules are visible to learners before the learners start the exam, along with the {hyperlink}.', + }, + reviewRulesDescriptionLinkText: { + id: 'course-authoring.course-outline.configure-modal.advanced-tab.reviewRulesDescriptionLinkText', + defaultMessage: 'general proctored exam rules', + }, }); export default messages; diff --git a/src/course-outline/constants.js b/src/course-outline/constants.js index f9a2c8821e..2ce86bb602 100644 --- a/src/course-outline/constants.js +++ b/src/course-outline/constants.js @@ -1,12 +1,12 @@ export const ITEM_BADGE_STATUS = /** @type {const} */ ({ live: 'live', + gated: 'gated', publishedNotLive: 'published_not_live', + unpublishedChanges: 'unpublished_changes', staffOnly: 'staff_only', draft: 'draft', }); -export const STAFF_ONLY = 'staff_only'; - export const HIGHLIGHTS_FIELD_MAX_LENGTH = 250; export const CHECKLIST_FILTERS = /** @type {const} */ ({ @@ -74,3 +74,9 @@ export const BEST_PRACTICES_CHECKLIST = /** @type {const} */ ({ }, ], }); + +export const VIDEO_SHARING_OPTIONS = /** @type {const} */ ({ + perVideo: 'per-video', + allOn: 'all-on', + allOff: 'all-off', +}); diff --git a/src/course-outline/data/api.js b/src/course-outline/data/api.js index 702715ce8f..3c2e038088 100644 --- a/src/course-outline/data/api.js +++ b/src/course-outline/data/api.js @@ -19,7 +19,7 @@ export const getCourseLaunchApiUrl = ({ all, }) => `${getApiBaseUrl()}/api/courses/v1/validation/${courseId}/?graded_only=${gradedOnly}&validate_oras=${validateOras}&all=${all}`; -export const getEnableHighlightsEmailsApiUrl = (courseId) => { +export const getCourseBlockApiUrl = (courseId) => { const formattedCourseId = courseId.split('course-v1:')[1]; return `${getApiBaseUrl()}/xblock/block-v1:${formattedCourseId}+type@course+block@course`; }; @@ -28,6 +28,7 @@ export const getCourseReindexApiUrl = (reindexLink) => `${getApiBaseUrl()}${rein export const getXBlockBaseApiUrl = () => `${getApiBaseUrl()}/xblock/`; export const getCourseItemApiUrl = (itemId) => `${getXBlockBaseApiUrl()}${itemId}`; export const getXBlockApiUrl = (blockId) => `${getXBlockBaseApiUrl()}outline/${blockId}`; +export const getClipboardUrl = () => `${getApiBaseUrl()}/api/content-staging/v1/clipboard/`; /** * @typedef {Object} courseOutline @@ -112,7 +113,7 @@ export async function getCourseLaunch({ */ export async function enableCourseHighlightsEmails(courseId) { const { data } = await getAuthenticatedHttpClient() - .post(getEnableHighlightsEmailsApiUrl(courseId), { + .post(getCourseBlockApiUrl(courseId), { publish: 'republish', metadata: { highlights_enabled_for_messaging: true, @@ -218,6 +219,8 @@ export async function publishCourseSection(sectionId) { /** * Configure course section * @param {string} sectionId + * @param {boolean} isVisibleToStaffOnly + * @param {string} startDatetime * @returns {Promise} */ export async function configureCourseSection(sectionId, isVisibleToStaffOnly, startDatetime) { @@ -234,6 +237,93 @@ export async function configureCourseSection(sectionId, isVisibleToStaffOnly, st return data; } +/** + * Configure course section + * @param {string} itemId + * @param {string} isVisibleToStaffOnly + * @param {string} releaseDate + * @param {string} graderType + * @param {string} dueDate + * @param {boolean} isProctoredExam, + * @param {boolean} isOnboardingExam, + * @param {boolean} isPracticeExam, + * @param {string} examReviewRules, + * @param {boolean} isTimeLimited + * @param {number} defaultTimeLimitMin + * @param {string} hideAfterDue + * @param {string} showCorrectness + * @param {boolean} isPrereq, + * @param {string} prereqUsageKey, + * @param {number} prereqMinScore, + * @param {number} prereqMinCompletion, + * @returns {Promise} + */ +export async function configureCourseSubsection( + itemId, + isVisibleToStaffOnly, + releaseDate, + graderType, + dueDate, + isTimeLimited, + isProctoredExam, + isOnboardingExam, + isPracticeExam, + examReviewRules, + defaultTimeLimitMin, + hideAfterDue, + showCorrectness, + isPrereq, + prereqUsageKey, + prereqMinScore, + prereqMinCompletion, +) { + const { data } = await getAuthenticatedHttpClient() + .post(getCourseItemApiUrl(itemId), { + publish: 'republish', + graderType, + isPrereq, + prereqUsageKey, + prereqMinScore, + prereqMinCompletion, + metadata: { + // The backend expects metadata.visible_to_staff_only to either true or null + visible_to_staff_only: isVisibleToStaffOnly ? true : null, + due: dueDate, + hide_after_due: hideAfterDue, + show_correctness: showCorrectness, + is_practice_exam: isPracticeExam, + is_time_limited: isTimeLimited, + is_proctored_enabled: isProctoredExam || isPracticeExam || isOnboardingExam, + exam_review_rules: examReviewRules, + default_time_limit_minutes: defaultTimeLimitMin, + is_onboarding_exam: isOnboardingExam, + start: releaseDate, + }, + }); + return data; +} + +/** + * Configure course unit + * @param {string} unitId + * @param {boolean} isVisibleToStaffOnly + * @param {object} groupAccess + * @returns {Promise} + */ +export async function configureCourseUnit(unitId, isVisibleToStaffOnly, groupAccess) { + const { data } = await getAuthenticatedHttpClient() + .post(getCourseItemApiUrl(unitId), { + publish: 'republish', + metadata: { + // The backend expects metadata.visible_to_staff_only to either true or null + visible_to_staff_only: isVisibleToStaffOnly ? true : null, + group_access: groupAccess, + }, + }); + + return data; +} + /** * Edit course section * @param {string} itemId @@ -305,9 +395,80 @@ export async function addNewCourseItem(parentLocator, category, displayName) { */ export async function setSectionOrderList(courseId, children) { const { data } = await getAuthenticatedHttpClient() - .put(getEnableHighlightsEmailsApiUrl(courseId), { + .put(getCourseBlockApiUrl(courseId), { + children, + }); + + return data; +} + +/** + * Set order for the list of the subsections + * @param {string} itemId Subsection or unit ID + * @param {Array} children list of sections id's + * @returns {Promise} +*/ +export async function setCourseItemOrderList(itemId, children) { + const { data } = await getAuthenticatedHttpClient() + .put(getCourseItemApiUrl(itemId), { children, }); return data; } + +/** + * Set video sharing setting + * @param {string} courseId + * @param {string} videoSharingOption + * @returns {Promise} +*/ +export async function setVideoSharingOption(courseId, videoSharingOption) { + const { data } = await getAuthenticatedHttpClient() + .post(getCourseBlockApiUrl(courseId), { + metadata: { + video_sharing_options: videoSharingOption, + }, + }); + + return data; +} + +/** + * Copy block to clipboard + * @param {string} usageKey + * @returns {Promise} +*/ +export async function copyBlockToClipboard(usageKey) { + const { data } = await getAuthenticatedHttpClient() + .post(getClipboardUrl(), { + usage_key: usageKey, + }); + + return camelCaseObject(data); +} + +/** + * Paste block to clipboard + * @param {string} parentLocator + * @returns {Promise} +*/ +export async function pasteBlock(parentLocator) { + const { data } = await getAuthenticatedHttpClient() + .post(getXBlockBaseApiUrl(), { + parent_locator: parentLocator, + staged_content: 'clipboard', + }); + + return data; +} + +/** + * Dismiss notification + * @param {string} url + * @returns void +*/ +export async function dismissNotification(url) { + await getAuthenticatedHttpClient() + .delete(url); +} diff --git a/src/course-outline/data/selectors.js b/src/course-outline/data/selectors.js index 4e0c28375e..fcf1b4881f 100644 --- a/src/course-outline/data/selectors.js +++ b/src/course-outline/data/selectors.js @@ -6,3 +6,7 @@ export const getSectionsList = (state) => state.courseOutline.sectionsList; export const getCurrentItem = (state) => state.courseOutline.currentItem; export const getCurrentSection = (state) => state.courseOutline.currentSection; export const getCurrentSubsection = (state) => state.courseOutline.currentSubsection; +export const getCourseActions = (state) => state.courseOutline.actions; +export const getCustomRelativeDatesActiveFlag = (state) => state.courseOutline.isCustomRelativeDatesActive; +export const getInitialUserClipboard = (state) => state.courseOutline.initialUserClipboard; +export const getProctoredExamsFlag = (state) => state.courseOutline.enableProctoredExams; diff --git a/src/course-outline/data/slice.js b/src/course-outline/data/slice.js index bd408c958e..0e8a3d342a 100644 --- a/src/course-outline/data/slice.js +++ b/src/course-outline/data/slice.js @@ -1,6 +1,7 @@ /* eslint-disable no-param-reassign */ import { createSlice } from '@reduxjs/toolkit'; +import { VIDEO_SHARING_OPTIONS } from '../constants'; import { RequestStatus } from '../../data/constants'; const slice = createSlice({ @@ -23,16 +24,35 @@ const slice = createSlice({ totalCourseBestPracticesChecks: 0, completedCourseBestPracticesChecks: 0, }, + videoSharingEnabled: false, + videoSharingOptions: VIDEO_SHARING_OPTIONS.perVideo, }, sectionsList: [], + isCustomRelativeDatesActive: false, currentSection: {}, currentSubsection: {}, currentItem: {}, + actions: { + deletable: true, + draggable: true, + childAddable: true, + duplicable: true, + }, + initialUserClipboard: { + content: {}, + sourceUsageKey: null, + sourceContexttitle: null, + sourceEditUrl: null, + }, + enableProctoredExams: false, }, reducers: { fetchOutlineIndexSuccess: (state, { payload }) => { state.outlineIndexData = payload; state.sectionsList = payload.courseStructure?.childInfo?.children || []; + state.isCustomRelativeDatesActive = payload.isCustomRelativeDatesActive; + state.initialUserClipboard = payload.initialUserClipboard; + state.enableProctoredExams = payload.courseStructure?.enableProctoredExams; }, updateOutlineIndexLoadingStatus: (state, { payload }) => { state.loadingStatus = { @@ -58,6 +78,15 @@ const slice = createSlice({ ...payload, }; }, + updateClipboardContent: (state, { payload }) => { + state.initialUserClipboard = payload; + }, + updateCourseActions: (state, { payload }) => { + state.actions = { + ...state.actions, + ...payload, + }; + }, fetchStatusBarChecklistSuccess: (state, { payload }) => { state.statusBarData.checklist = { ...state.statusBarData.checklist, @@ -82,6 +111,22 @@ const slice = createSlice({ state.sectionsList = [...sectionsList]; }, + reorderSubsectionList: (state, { payload }) => { + const { sectionId, subsectionListIds } = payload; + const sections = [...state.sectionsList]; + const i = sections.findIndex(section => section.id === sectionId); + sections[i].childInfo.children.sort((a, b) => subsectionListIds.indexOf(a.id) - subsectionListIds.indexOf(b.id)); + state.sectionsList = [...sections]; + }, + reorderUnitList: (state, { payload }) => { + const { sectionId, subsectionId, unitListIds } = payload; + const sections = [...state.sectionsList]; + const i = sections.findIndex(section => section.id === sectionId); + const j = sections[i].childInfo.children.findIndex(subsection => subsection.id === subsectionId); + const subsection = sections[i].childInfo.children[j]; + subsection.childInfo.children.sort((a, b) => unitListIds.indexOf(a.id) - unitListIds.indexOf(b.id)); + state.sectionsList = [...sections]; + }, setCurrentSection: (state, { payload }) => { state.currentSection = payload; }, @@ -121,6 +166,23 @@ const slice = createSlice({ return section; }); }, + deleteUnit: (state, { payload }) => { + state.sectionsList = state.sectionsList.map((section) => { + if (section.id !== payload.sectionId) { + return section; + } + section.childInfo.children = section.childInfo.children.map((subsection) => { + if (subsection.id !== payload.subsectionId) { + return subsection; + } + subsection.childInfo.children = subsection.childInfo.children.filter( + ({ id }) => id !== payload.itemId, + ); + return subsection; + }); + return section; + }); + }, duplicateSection: (state, { payload }) => { state.sectionsList = state.sectionsList.reduce((result, currentValue) => { if (currentValue.id === payload.id) { @@ -139,6 +201,7 @@ export const { updateOutlineIndexLoadingStatus, updateReindexLoadingStatus, updateStatusBar, + updateCourseActions, fetchStatusBarChecklistSuccess, fetchStatusBarSelPacedSuccess, updateFetchSectionLoadingStatus, @@ -149,8 +212,12 @@ export const { setCurrentSubsection, deleteSection, deleteSubsection, + deleteUnit, duplicateSection, reorderSectionList, + reorderSubsectionList, + reorderUnitList, + updateClipboardContent, } = slice.actions; export const { diff --git a/src/course-outline/data/thunk.js b/src/course-outline/data/thunk.js index 50909f5a90..f5f4bd392f 100644 --- a/src/course-outline/data/thunk.js +++ b/src/course-outline/data/thunk.js @@ -21,9 +21,16 @@ import { getCourseItem, publishCourseSection, configureCourseSection, + configureCourseSubsection, + configureCourseUnit, restartIndexingOnCourse, updateCourseSectionHighlights, setSectionOrderList, + setVideoSharingOption, + setCourseItemOrderList, + copyBlockToClipboard, + pasteBlock, + dismissNotification, } from './api'; import { addSection, @@ -32,6 +39,7 @@ import { updateOutlineIndexLoadingStatus, updateReindexLoadingStatus, updateStatusBar, + updateCourseActions, fetchStatusBarChecklistSuccess, fetchStatusBarSelPacedSuccess, updateSavingStatus, @@ -39,8 +47,12 @@ import { updateFetchSectionLoadingStatus, deleteSection, deleteSubsection, + deleteUnit, duplicateSection, reorderSectionList, + reorderSubsectionList, + reorderUnitList, + updateClipboardContent, } from './slice'; export function fetchCourseOutlineIndexQuery(courseId) { @@ -49,9 +61,23 @@ export function fetchCourseOutlineIndexQuery(courseId) { try { const outlineIndex = await getCourseOutlineIndex(courseId); - const { courseReleaseDate, courseStructure: { highlightsEnabledForMessaging } } = outlineIndex; + const { + courseReleaseDate, + courseStructure: { + highlightsEnabledForMessaging, + videoSharingEnabled, + videoSharingOptions, + actions, + }, + } = outlineIndex; dispatch(fetchOutlineIndexSuccess(outlineIndex)); - dispatch(updateStatusBar({ courseReleaseDate, highlightsEnabledForMessaging })); + dispatch(updateStatusBar({ + courseReleaseDate, + highlightsEnabledForMessaging, + videoSharingOptions, + videoSharingEnabled, + })); + dispatch(updateCourseActions(actions)); dispatch(updateOutlineIndexLoadingStatus({ status: RequestStatus.SUCCESSFUL })); } catch (error) { @@ -115,6 +141,24 @@ export function enableCourseHighlightsEmailsQuery(courseId) { }; } +export function setVideoSharingOptionQuery(courseId, option) { + return async (dispatch) => { + dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); + dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving)); + + try { + await setVideoSharingOption(courseId, option); + dispatch(updateStatusBar({ videoSharingOptions: option })); + + dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); + dispatch(hideProcessingNotification()); + } catch (error) { + dispatch(updateSavingStatus({ status: RequestStatus.FAILED })); + dispatch(hideProcessingNotification()); + } + }; +} + export function fetchCourseReindexQuery(courseId, reindexLink) { return async (dispatch) => { dispatch(updateReindexLoadingStatus({ status: RequestStatus.IN_PROGRESS })); @@ -183,13 +227,13 @@ export function publishCourseItemQuery(itemId, sectionId) { }; } -export function configureCourseSectionQuery(sectionId, isVisibleToStaffOnly, startDatetime) { +export function configureCourseItemQuery(sectionId, configureFn) { return async (dispatch) => { dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving)); try { - await configureCourseSection(sectionId, isVisibleToStaffOnly, startDatetime).then(async (result) => { + await configureFn().then(async (result) => { if (result) { await dispatch(fetchCourseSectionQuery(sectionId)); dispatch(hideProcessingNotification()); @@ -203,6 +247,70 @@ export function configureCourseSectionQuery(sectionId, isVisibleToStaffOnly, sta }; } +export function configureCourseSectionQuery(sectionId, isVisibleToStaffOnly, startDatetime) { + return async (dispatch) => { + dispatch(configureCourseItemQuery( + sectionId, + async () => configureCourseSection(sectionId, isVisibleToStaffOnly, startDatetime), + )); + }; +} + +export function configureCourseSubsectionQuery( + itemId, + sectionId, + isVisibleToStaffOnly, + releaseDate, + graderType, + dueDate, + isTimeLimited, + isProctoredExam, + isOnboardingExam, + isPracticeExam, + examReviewRules, + defaultTimeLimitMin, + hideAfterDue, + showCorrectness, + isPrereq, + prereqUsageKey, + prereqMinScore, + prereqMinCompletion, +) { + return async (dispatch) => { + dispatch(configureCourseItemQuery( + sectionId, + async () => configureCourseSubsection( + itemId, + isVisibleToStaffOnly, + releaseDate, + graderType, + dueDate, + isTimeLimited, + isProctoredExam, + isOnboardingExam, + isPracticeExam, + examReviewRules, + defaultTimeLimitMin, + hideAfterDue, + showCorrectness, + isPrereq, + prereqUsageKey, + prereqMinScore, + prereqMinCompletion, + ), + )); + }; +} + +export function configureCourseUnitQuery(itemId, sectionId, isVisibleToStaffOnly, groupAccess) { + return async (dispatch) => { + dispatch(configureCourseItemQuery( + sectionId, + async () => configureCourseUnit(itemId, isVisibleToStaffOnly, groupAccess), + )); + }; +} + export function editCourseItemQuery(itemId, sectionId, displayName) { return async (dispatch) => { dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); @@ -264,6 +372,15 @@ export function deleteCourseSubsectionQuery(subsectionId, sectionId) { }; } +export function deleteCourseUnitQuery(unitId, subsectionId, sectionId) { + return async (dispatch) => { + dispatch(deleteCourseItemQuery( + unitId, + () => deleteUnit({ itemId: unitId, subsectionId, sectionId }), + )); + }; +} + /** * Generic function to duplicate any course item. See wrapper functions below for specific implementations. * @param {string} itemId @@ -274,7 +391,7 @@ export function deleteCourseSubsectionQuery(subsectionId, sectionId) { function duplicateCourseItemQuery(itemId, parentLocator, duplicateFn) { return async (dispatch) => { dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); - dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving)); + dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.duplicating)); try { await duplicateCourseItem(itemId, parentLocator).then(async (result) => { @@ -316,6 +433,16 @@ export function duplicateSubsectionQuery(subsectionId, sectionId) { }; } +export function duplicateUnitQuery(unitId, subsectionId, sectionId) { + return async (dispatch) => { + dispatch(duplicateCourseItemQuery( + unitId, + subsectionId, + async () => dispatch(fetchCourseSectionQuery(sectionId, true)), + )); + }; +} + /** * Generic function to add any course item. See wrapper functions below for specific implementations. * @param {string} parentLocator @@ -336,10 +463,7 @@ function addNewCourseItemQuery(parentLocator, category, displayName, addItemFn) displayName, ).then(async (result) => { if (result) { - const data = await getCourseItem(result.locator); - // Page should scroll to newly created item. - data.shouldScroll = true; - dispatch(addItemFn(data)); + await addItemFn(result); dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); dispatch(hideProcessingNotification()); } @@ -357,7 +481,12 @@ export function addNewSectionQuery(parentLocator) { parentLocator, COURSE_BLOCK_NAMES.chapter.id, COURSE_BLOCK_NAMES.chapter.name, - (data) => addSection(data), + async (result) => { + const data = await getCourseItem(result.locator); + // Page should scroll to newly created section. + data.shouldScroll = true; + dispatch(addSection(data)); + }, )); }; } @@ -368,20 +497,36 @@ export function addNewSubsectionQuery(parentLocator) { parentLocator, COURSE_BLOCK_NAMES.sequential.id, COURSE_BLOCK_NAMES.sequential.name, - (data) => addSubsection({ parentLocator, data }), + async (result) => { + const data = await getCourseItem(result.locator); + // Page should scroll to newly created subsection. + data.shouldScroll = true; + dispatch(addSubsection({ parentLocator, data })); + }, )); }; } -export function setSectionOrderListQuery(courseId, newListId, restoreCallback) { +export function addNewUnitQuery(parentLocator, callback) { + return async (dispatch) => { + dispatch(addNewCourseItemQuery( + parentLocator, + COURSE_BLOCK_NAMES.vertical.id, + COURSE_BLOCK_NAMES.vertical.name, + async (result) => callback(result.locator), + )); + }; +} + +export function setSectionOrderListQuery(courseId, sectionListIds, restoreCallback) { return async (dispatch) => { dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving)); try { - await setSectionOrderList(courseId, newListId).then(async (result) => { + await setSectionOrderList(courseId, sectionListIds).then(async (result) => { if (result) { - dispatch(reorderSectionList(newListId)); + dispatch(reorderSectionList(sectionListIds)); dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); dispatch(hideProcessingNotification()); } @@ -393,3 +538,103 @@ export function setSectionOrderListQuery(courseId, newListId, restoreCallback) { } }; } + +export function setSubsectionOrderListQuery(sectionId, subsectionListIds, restoreCallback) { + return async (dispatch) => { + dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); + dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving)); + + try { + await setCourseItemOrderList(sectionId, subsectionListIds).then(async (result) => { + if (result) { + dispatch(reorderSubsectionList({ sectionId, subsectionListIds })); + dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); + dispatch(hideProcessingNotification()); + } + }); + } catch (error) { + restoreCallback(); + dispatch(hideProcessingNotification()); + dispatch(updateSavingStatus({ status: RequestStatus.FAILED })); + } + }; +} + +export function setUnitOrderListQuery(sectionId, subsectionId, unitListIds, restoreCallback) { + return async (dispatch) => { + dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); + dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving)); + + try { + await setCourseItemOrderList(subsectionId, unitListIds).then(async (result) => { + if (result) { + dispatch(reorderUnitList({ sectionId, subsectionId, unitListIds })); + dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); + dispatch(hideProcessingNotification()); + } + }); + } catch (error) { + restoreCallback(); + dispatch(hideProcessingNotification()); + dispatch(updateSavingStatus({ status: RequestStatus.FAILED })); + } + }; +} + +export function setClipboardContent(usageKey, broadcastClipboard) { + return async (dispatch) => { + dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); + dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.copying)); + + try { + await copyBlockToClipboard(usageKey).then(async (result) => { + const status = result?.content?.status; + if (status === 'ready') { + dispatch(updateClipboardContent(result)); + broadcastClipboard(result); + dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); + dispatch(hideProcessingNotification()); + } else { + throw new Error(`Unexpected clipboard status "${status}" in successful API response.`); + } + }); + } catch (error) { + dispatch(hideProcessingNotification()); + dispatch(updateSavingStatus({ status: RequestStatus.FAILED })); + } + }; +} + +export function pasteClipboardContent(parentLocator, sectionId) { + return async (dispatch) => { + dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); + dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.pasting)); + + try { + await pasteBlock(parentLocator).then(async (result) => { + if (result) { + dispatch(fetchCourseSectionQuery(sectionId, true)); + dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); + dispatch(hideProcessingNotification()); + } + }); + } catch (error) { + dispatch(hideProcessingNotification()); + dispatch(updateSavingStatus({ status: RequestStatus.FAILED })); + } + }; +} + +export function dismissNotificationQuery(url) { + return async (dispatch) => { + dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); + + try { + await dismissNotification(url).then(async () => { + dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); + }); + } catch (error) { + dispatch(updateSavingStatus({ status: RequestStatus.FAILED })); + } + }; +} diff --git a/src/course-outline/drag-helper/ConditionalSortableElement.jsx b/src/course-outline/drag-helper/ConditionalSortableElement.jsx new file mode 100644 index 0000000000..8390b282cd --- /dev/null +++ b/src/course-outline/drag-helper/ConditionalSortableElement.jsx @@ -0,0 +1,57 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Col, Row } from '@edx/paragon'; +import { SortableItem } from '@edx/frontend-lib-content-components'; + +const ConditionalSortableElement = ({ + id, + draggable, + children, + componentStyle, +}) => { + const style = { + background: 'white', + padding: '1rem 1.5rem', + marginBottom: '1.5rem', + borderRadius: '0.35rem', + boxShadow: '0 0 .125rem rgba(0, 0, 0, .15), 0 0 .25rem rgba(0, 0, 0, .15)', + ...componentStyle, + }; + + if (draggable) { + return ( + + + {children} + + + ); + } + return ( + + + {children} + + + ); +}; + +ConditionalSortableElement.defaultProps = { + componentStyle: null, +}; + +ConditionalSortableElement.propTypes = { + id: PropTypes.string.isRequired, + draggable: PropTypes.bool.isRequired, + children: PropTypes.node.isRequired, + componentStyle: PropTypes.shape({}), +}; + +export default ConditionalSortableElement; diff --git a/src/course-outline/drag-helper/ConditionalSortableElement.scss b/src/course-outline/drag-helper/ConditionalSortableElement.scss new file mode 100644 index 0000000000..00393c48f1 --- /dev/null +++ b/src/course-outline/drag-helper/ConditionalSortableElement.scss @@ -0,0 +1,5 @@ +.extend-margin { + .item-children { + margin-right: -2.75rem; + } +} diff --git a/src/course-outline/empty-placeholder/EmptyPlaceholder.jsx b/src/course-outline/empty-placeholder/EmptyPlaceholder.jsx index e8936449dd..a98a46011c 100644 --- a/src/course-outline/empty-placeholder/EmptyPlaceholder.jsx +++ b/src/course-outline/empty-placeholder/EmptyPlaceholder.jsx @@ -6,35 +6,41 @@ import { Button, OverlayTrigger, Tooltip } from '@openedx/paragon'; import messages from './messages'; -const EmptyPlaceholder = ({ onCreateNewSection }) => { +const EmptyPlaceholder = ({ + onCreateNewSection, + childAddable, +}) => { const intl = useIntl(); return (

{intl.formatMessage(messages.title)}

- - {intl.formatMessage(messages.tooltip)} - - )} - > - - + + + )}
); }; EmptyPlaceholder.propTypes = { onCreateNewSection: PropTypes.func.isRequired, + childAddable: PropTypes.bool.isRequired, }; export default EmptyPlaceholder; diff --git a/src/course-outline/empty-placeholder/EmptyPlaceholder.test.jsx b/src/course-outline/empty-placeholder/EmptyPlaceholder.test.jsx index f76a1178c7..45c6841fd8 100644 --- a/src/course-outline/empty-placeholder/EmptyPlaceholder.test.jsx +++ b/src/course-outline/empty-placeholder/EmptyPlaceholder.test.jsx @@ -9,7 +9,10 @@ const onCreateNewSectionMock = jest.fn(); const renderComponent = () => render( - + , ); diff --git a/src/course-outline/header-navigations/HeaderNavigations.jsx b/src/course-outline/header-navigations/HeaderNavigations.jsx index ec6b0f0fdb..b4359a3c24 100644 --- a/src/course-outline/header-navigations/HeaderNavigations.jsx +++ b/src/course-outline/header-navigations/HeaderNavigations.jsx @@ -16,6 +16,7 @@ const HeaderNavigations = ({ isSectionsExpanded, isDisabledReindexButton, hasSections, + courseActions, }) => { const intl = useIntl(); const { @@ -24,21 +25,23 @@ const HeaderNavigations = ({ return (