diff --git a/.env b/.env index ec000feba0..831aeb2a24 100644 --- a/.env +++ b/.env @@ -1,3 +1,4 @@ +APP_ID='authoring' NODE_ENV='production' ACCESS_TOKEN_COOKIE_NAME='' BASE_URL='' @@ -40,7 +41,7 @@ HOTJAR_APP_ID='' HOTJAR_VERSION=6 HOTJAR_DEBUG=false INVITE_STUDENTS_EMAIL_TO='' -ENABLE_HOME_PAGE_COURSE_API_V2=false +ENABLE_HOME_PAGE_COURSE_API_V2=true ENABLE_CHECKLIST_QUALITY='' ENABLE_GRADING_METHOD_IN_PROBLEMS=false -LIBRARY_MODE="v1 only" +LIBRARY_SUPPORTED_BLOCKS="problem,video,html" diff --git a/.env.development b/.env.development index 1cc8f24da6..23b726e5e6 100644 --- a/.env.development +++ b/.env.development @@ -1,3 +1,4 @@ +APP_ID='authoring' NODE_ENV='development' ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload' BASE_URL='http://localhost:2001' @@ -43,7 +44,7 @@ HOTJAR_APP_ID='' HOTJAR_VERSION=6 HOTJAR_DEBUG=true INVITE_STUDENTS_EMAIL_TO="someone@domain.com" -ENABLE_HOME_PAGE_COURSE_API_V2=false +ENABLE_HOME_PAGE_COURSE_API_V2=true ENABLE_CHECKLIST_QUALITY=true ENABLE_GRADING_METHOD_IN_PROBLEMS=false -LIBRARY_MODE="mixed" +LIBRARY_SUPPORTED_BLOCKS="problem,video,html" diff --git a/.env.test b/.env.test index 7c591ff68e..b9e352ebc8 100644 --- a/.env.test +++ b/.env.test @@ -1,3 +1,4 @@ +APP_ID='authoring' ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload' BASE_URL='http://localhost:2001' CREDENTIALS_BASE_URL='http://localhost:18150' @@ -38,4 +39,4 @@ INVITE_STUDENTS_EMAIL_TO="someone@domain.com" ENABLE_HOME_PAGE_COURSE_API_V2=true ENABLE_CHECKLIST_QUALITY=true ENABLE_GRADING_METHOD_IN_PROBLEMS=false -LIBRARY_MODE="mixed" +LIBRARY_SUPPORTED_BLOCKS="problem,video,html" diff --git a/.eslintrc.js b/.eslintrc.js index 06e92c1d56..6fc94024bf 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -12,6 +12,8 @@ module.exports = createConfig( 'template-curly-spacing': 'off', 'react-hooks/exhaustive-deps': 'off', 'no-restricted-exports': 'off', + // There is no reason to disallow this syntax anymore; we don't use regenerator-runtime in new browsers + 'no-restricted-syntax': 'off', }, settings: { // Import URLs should be resolved using aliases diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000..9186f7421b --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,7 @@ +version: 2 +updates: + # Adding new check for github-actions + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index d25fd8ed00..aca616fbe9 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -16,6 +16,24 @@ jobs: with: node-version-file: '.nvmrc' - run: make validate.ci + - name: Archive code coverage results + uses: actions/upload-artifact@v4 + with: + name: code-coverage-report-${{ matrix.node }} + # When we're only using Node 20, replace the line above with the following: + # name: code-coverage-report + path: coverage/*.* + coverage: + runs-on: ubuntu-latest + needs: tests + steps: + - uses: actions/checkout@v4 + - name: Download code coverage results + uses: actions/download-artifact@v4 + with: + name: code-coverage-report-20 + # When we're only using Node 20, replace the line above with the following: + # name: code-coverage-report - name: Upload coverage uses: codecov/codecov-action@v4 with: diff --git a/CODEOWNERS b/CODEOWNERS index 9904a5264e..0ed4f80a66 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1,2 +1,2 @@ -# The following users are the maintainers of all frontend-app-course-authoring files +# The following users are the maintainers of all frontend-app-authoring files * @openedx/2u-tnl diff --git a/README.rst b/README.rst index 0b2d30be71..5490b135cc 100644 --- a/README.rst +++ b/README.rst @@ -1,5 +1,5 @@ -frontend-app-course-authoring -############################# +frontend-app-authoring +###################### |license-badge| |status-badge| |codecov-badge| @@ -7,9 +7,9 @@ frontend-app-course-authoring Purpose ******* -This is the Course Authoring micro-frontend, currently under development by `2U `_. +This implements most of the frontend for **Open edX Studio**, allowing authors to create and edit courses, libraries, and their learning components. -Its purpose is to provide both a framework and UI for new or replacement React-based authoring features outside ``edx-platform``. You can find the current set described below. +A few parts of Studio still default to the `"legacy" pages defined in edx-platform `_, but those are rapidly being deprecated and replaced with the React- and Paragon-based pages defined here. Getting Started @@ -18,51 +18,87 @@ Getting Started Prerequisites ============= -The `devstack`_ is currently recommended as a development environment for your -new MFE. If you start it with ``make dev.up.lms`` that should give you -everything you need as a companion to this frontend. - -Note that it is also possible to use `Tutor`_ to develop an MFE. You can refer -to the `relevant tutor-mfe documentation`_ to get started using it. - -.. _Devstack: https://github.com/openedx/devstack +`Tutor`_ is currently recommended as a development environment for the Authoring +MFE. Most likely, it already has this MFE configured; however, you'll need to +make some changes in order to run it in development mode. You can refer +to the `relevant tutor-mfe documentation`_ for details, or follow the quick +guide below. .. _Tutor: https://github.com/overhangio/tutor .. _relevant tutor-mfe documentation: https://github.com/overhangio/tutor-mfe#mfe-development -Configuration -============= -All features that integrate into the edx-platform CMS require that the ``COURSE_AUTHORING_MICROFRONTEND_URL`` Django setting is set in the CMS environment and points to this MFE's deployment URL. This should be done automatically if you are using devstack or tutor-mfe. +Cloning and Setup +================= + +1. Clone your new repo: + +.. code-block:: bash + + git clone https://github.com/openedx/frontend-app-authoring.git + +2. Use node v20.x. + + The current version of the micro-frontend build scripts supports node 20. + Using other major versions of node *may* work, but this is unsupported. For + convenience, this repository includes an ``.nvmrc`` file to help in setting the + correct node version via `nvm `_. + +3. Stop the Tutor devstack, if it's running: ``tutor dev stop`` -Cloning and Startup -=================== +4. Next, we need to tell Tutor that we're going to be running this repo in + development mode, and it should be excluded from the ``mfe`` container that + otherwise runs every MFE. Run this: +.. code-block:: bash -1. Clone the repo: + tutor mounts add /path/to/frontend-app-authoring - ``git clone https://github.com/openedx/frontend-app-course-authoring.git`` +5. Start Tutor in development mode. This command will start the LMS and Studio, + and other required MFEs like ``authn`` and ``account``, but will not start + the Authoring MFE, which we're going to run on the host instead of in a + container managed by Tutor. Run: -2. Use node v18.x. +.. code-block:: bash - The current version of the micro-frontend build scripts support node 18. - Using other major versions of node *may* work, but this is unsupported. For - convenience, this repository includes an .nvmrc file to help in setting the - correct node version via `nvm use`_. + tutor dev start lms cms mfe -3. Install npm dependencies: +Startup +======= - ``cd frontend-app-course-authoring && npm install`` +1. Install npm dependencies: +.. code-block:: bash -4. Start the dev server: + cd frontend-app-authoring && npm ci - ``npm start`` +2. Start the dev server: +.. code-block:: bash -The dev server is running at `http://localhost:2001 `_. -or whatever port you setup. + npm run dev + +Then you can access the app at http://apps.local.openedx.io:2001/course-authoring/home + +Troubleshooting +--------------- + +* If you see an "Invalid Host header" error, then you're probably using a different domain name for your devstack such as +``local.edly.io`` or ``local.overhang.io`` (not the new recommended default, ``local.openedx.io``). In that case, run +these commands to update your devstack's domain names: + +.. code-block:: bash + + tutor dev stop + tutor config save --set LMS_HOST=local.openedx.io --set CMS_HOST=studio.local.openedx.io + tutor dev launch -I --skip-build + tutor dev stop authoring # We will run this MFE on the host + +* If tutor-mfe is not starting the authoring MFE in development mode (eg. `tutor dev start authoring` fails), it may be due to + using a tutor version that expects the MFE name to be frontend-app-course-authoring (the previous name of this repo). To fix + this, you can rename the cloned repo directory to frontend-app-course-authoring. More information can be found in + [this forum post](https://discuss.openedx.org/t/repo-rename-frontend-app-course-authoring-frontend-app-authoring/13930/2) Features @@ -268,11 +304,13 @@ Configuration In additional to the standard settings, the following local configurations can be set to switch between different library modes: -* ``LIBRARY_MODE``: can be set to ``mixed`` (default for development), ``v1 only`` (default for production) and ``v2 only``. +* ``MEILISEARCH_ENABLED``: Studio setting which is enabled when the `meilisearch plugin`_ is installed. +* ``edx-platform`` Waffle flags: + + * ``contentstore.new_studio_mfe.disable_legacy_libraries``: this feature flag must be OFF to show legacy Libraries V1 + * ``contentstore.new_studio_mfe.disable_new_libraries``: this feature flag must be OFF to show Content Libraries V2 - * ``mixed``: Shows 2 tabs, "Libraries" that lists the v2 libraries and "Legacy Libraries" that lists the v1 libraries. When creating a new library in this mode it will create a new v2 library. - * ``v1 only``: Shows only 1 tab, "Libraries" that lists v1 libraries only. When creating a new library in this mode it will create a new v1 library. - * ``v2 only``: Shows only 1 tab, "Libraries" that lists v2 libraries only. When creating a new library in this mode it will create a new v2 library. +.. _meilisearch plugin: https://github.com/open-craft/tutor-contrib-meilisearch Developing ********** @@ -306,8 +344,8 @@ The production build is created with ``npm run build``. :target: https://travis-ci.com/edx/frontend-app-course-authoring .. |Codecov| image:: https://codecov.io/gh/edx/frontend-app-course-authoring/branch/master/graph/badge.svg :target: https://codecov.io/gh/edx/frontend-app-course-authoring -.. |license| image:: https://img.shields.io/npm/l/@edx/frontend-app-course-authoring.svg - :target: @edx/frontend-app-course-authoring +.. |license| image:: https://img.shields.io/npm/l/@edx/frontend-app-authoring.svg + :target: @edx/frontend-app-authoring Internationalization ==================== diff --git a/catalog-info.yaml b/catalog-info.yaml index 7867f553f2..9eec06ff6c 100644 --- a/catalog-info.yaml +++ b/catalog-info.yaml @@ -4,11 +4,11 @@ apiVersion: backstage.io/v1alpha1 kind: Component metadata: - name: 'frontend-app-course-authoring' - description: "The frontend (MFE) for Open edX Course Authoring (aka Studio)" + name: 'frontend-app-authoring' + description: "The frontend (MFE) for Open edX Authoring (aka Studio)" links: - - url: "https://github.com/openedx/frontend-app-course-authoring" - title: "Frontend app course authoring" + - url: "https://github.com/openedx/frontend-app-authoring" + title: "Frontend app authoring" icon: "Web" annotations: openedx.org/arch-interest-groups: "" diff --git a/package-lock.json b/package-lock.json index f985d5030b..2bbd4d2784 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "@edx/frontend-app-course-authoring", + "name": "@edx/frontend-app-authoring", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@edx/frontend-app-course-authoring", + "name": "@edx/frontend-app-authoring", "version": "0.1.0", "license": "AGPL-3.0", "dependencies": { @@ -19,8 +19,9 @@ "@dnd-kit/sortable": "^8.0.0", "@dnd-kit/utilities": "^3.2.2", "@edx/brand": "npm:@openedx/brand-openedx@^1.2.3", - "@edx/frontend-component-footer": "^14.0.3", - "@edx/frontend-component-header": "^5.3.3", + "@edx/browserslist-config": "1.2.0", + "@edx/frontend-component-footer": "^14.1.0", + "@edx/frontend-component-header": "^5.6.0", "@edx/frontend-enterprise-hotjar": "^2.0.0", "@edx/frontend-platform": "^8.0.3", "@edx/openedx-atlas": "^0.6.0", @@ -34,8 +35,9 @@ "@openedx-plugins/course-app-teams": "file:plugins/course-apps/teams", "@openedx-plugins/course-app-wiki": "file:plugins/course-apps/wiki", "@openedx-plugins/course-app-xpert_unit_summary": "file:plugins/course-apps/xpert_unit_summary", + "@openedx/frontend-build": "^14.0.14", "@openedx/frontend-plugin-framework": "^1.2.1", - "@openedx/paragon": "^22.5.1", + "@openedx/paragon": "^22.8.1", "@redux-devtools/extension": "^3.3.0", "@reduxjs/toolkit": "1.9.7", "@tanstack/react-query": "4.36.1", @@ -69,7 +71,6 @@ "react-transition-group": "4.4.5", "redux": "4.0.5", "redux-logger": "^3.0.6", - "redux-mock-store": "^1.5.4", "redux-thunk": "^2.4.1", "reselect": "^4.1.5", "start": "^5.1.0", @@ -80,31 +81,22 @@ "yup": "0.31.1" }, "devDependencies": { - "@edx/browserslist-config": "1.2.0", "@edx/react-unit-test-utils": "3.0.0", - "@edx/reactifex": "^1.0.3", "@edx/stylelint-config-edx": "2.3.3", "@edx/typescript-config": "^1.0.1", - "@openedx/frontend-build": "^14.0.14", "@testing-library/jest-dom": "5.17.0", "@testing-library/react": "12.1.5", "@testing-library/react-hooks": "^8.0.1", "@testing-library/user-event": "^13.2.1", "@types/lodash": "^4.17.7", - "axios": "^0.28.0", "axios-mock-adapter": "1.22.0", "eslint-import-resolver-webpack": "^0.13.8", "fetch-mock-jest": "^1.5.1", - "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.1" - }, - "peerDependencies": { - "decode-uri-component": ">=0.2.2" + "redux-mock-store": "^1.5.4" } }, "node_modules/@adobe/css-tools": { @@ -2141,11 +2133,12 @@ }, "node_modules/@edx/browserslist-config": { "version": "1.2.0", - "dev": true, "license": "AGPL-3.0" }, "node_modules/@edx/eslint-config": { - "version": "4.1.0", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@edx/eslint-config/-/eslint-config-4.2.0.tgz", + "integrity": "sha512-2wuIw49uyj6gRwS74qJ8WhBU+X2FOP4uot40sthIC4YU9qCM7WJOcOuAhkRPP1FvZKd3UQH3gZM7eJ85xzDBqA==", "license": "MIT", "peerDependencies": { "@typescript-eslint/eslint-plugin": "^5.62.0", @@ -2160,14 +2153,16 @@ } }, "node_modules/@edx/frontend-component-footer": { - "version": "14.0.8", - "license": "AGPL-3.0", + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@edx/frontend-component-footer/-/frontend-component-footer-14.1.0.tgz", + "integrity": "sha512-hdQEGbZosa5Lp8d4sLCu7+e0+X2dQDQZgd5stABbGNbDD1UPU7Efb3duJ5HhcNscpCHMhtYeNbajfUU5K+tKrg==", "dependencies": { - "@fortawesome/fontawesome-svg-core": "6.5.2", - "@fortawesome/free-brands-svg-icons": "6.5.2", - "@fortawesome/free-regular-svg-icons": "6.5.2", - "@fortawesome/free-solid-svg-icons": "6.5.2", + "@fortawesome/fontawesome-svg-core": "6.6.0", + "@fortawesome/free-brands-svg-icons": "6.6.0", + "@fortawesome/free-regular-svg-icons": "6.6.0", + "@fortawesome/free-solid-svg-icons": "6.6.0", "@fortawesome/react-fontawesome": "0.2.2", + "classnames": "^2.5.1", "jest-environment-jsdom": "^29.7.0", "lodash": "^4.17.21", "ts-jest": "^29.1.2" @@ -2181,16 +2176,19 @@ } }, "node_modules/@edx/frontend-component-header": { - "version": "5.3.4", - "license": "AGPL-3.0", + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/@edx/frontend-component-header/-/frontend-component-header-5.6.0.tgz", + "integrity": "sha512-ITLLrej6BbWVc/0baMkKg/ACTvUGSR188Rn/BC2Y82Tdu8gRsZB6+0GUsDX/6FJjeIazLXdUusKlfwVU90sXLA==", "dependencies": { "@fortawesome/fontawesome-svg-core": "6.6.0", "@fortawesome/free-brands-svg-icons": "6.6.0", "@fortawesome/free-regular-svg-icons": "6.6.0", "@fortawesome/free-solid-svg-icons": "6.6.0", "@fortawesome/react-fontawesome": "^0.2.0", + "@openedx/frontend-plugin-framework": "^1.3.0", "axios-mock-adapter": "1.22.0", "babel-polyfill": "6.26.0", + "classnames": "^2.5.1", "jest-environment-jsdom": "^29.7.0", "react-responsive": "8.2.0", "react-transition-group": "4.4.5" @@ -2203,53 +2201,6 @@ "react-dom": "^16.9.0 || ^17.0.0" } }, - "node_modules/@edx/frontend-component-header/node_modules/@fortawesome/fontawesome-common-types": { - "version": "6.6.0", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/@edx/frontend-component-header/node_modules/@fortawesome/fontawesome-svg-core": { - "version": "6.6.0", - "license": "MIT", - "dependencies": { - "@fortawesome/fontawesome-common-types": "6.6.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/@edx/frontend-component-header/node_modules/@fortawesome/free-brands-svg-icons": { - "version": "6.6.0", - "license": "(CC-BY-4.0 AND MIT)", - "dependencies": { - "@fortawesome/fontawesome-common-types": "6.6.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/@edx/frontend-component-header/node_modules/@fortawesome/free-regular-svg-icons": { - "version": "6.6.0", - "license": "(CC-BY-4.0 AND MIT)", - "dependencies": { - "@fortawesome/fontawesome-common-types": "6.6.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/@edx/frontend-component-header/node_modules/@fortawesome/free-solid-svg-icons": { - "version": "6.6.0", - "license": "(CC-BY-4.0 AND MIT)", - "dependencies": { - "@fortawesome/fontawesome-common-types": "6.6.0" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/@edx/frontend-component-header/node_modules/react-responsive": { "version": "8.2.0", "license": "MIT", @@ -2349,7 +2300,9 @@ } }, "node_modules/@edx/openedx-atlas": { - "version": "0.6.1", + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@edx/openedx-atlas/-/openedx-atlas-0.6.2.tgz", + "integrity": "sha512-28Q8vzJDMS4wUxdkbIUBQpzWJ3HTdMaGlaEhFjrVGfuZkh++1AG6Tn/7FMD88cegalYAkphu530VQCHEkMZQhw==", "license": "AGPL-3.0", "bin": { "atlas": "atlas" @@ -2405,26 +2358,6 @@ "deep-equal": "^2.0.5" } }, - "node_modules/@edx/reactifex": { - "version": "1.1.0", - "dev": true, - "license": "MIT", - "dependencies": { - "axios": "^0.21.1", - "yargs": "^17.1.1" - }, - "bin": { - "edx_reactifex": "main.js" - } - }, - "node_modules/@edx/reactifex/node_modules/axios": { - "version": "0.21.4", - "dev": true, - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.14.0" - } - }, "node_modules/@edx/stylelint-config-edx": { "version": "2.3.3", "dev": true, @@ -2915,52 +2848,57 @@ } }, "node_modules/@fortawesome/fontawesome-common-types": { - "version": "6.5.2", - "hasInstallScript": true, + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.6.0.tgz", + "integrity": "sha512-xyX0X9mc0kyz9plIyryrRbl7ngsA9jz77mCZJsUkLl+ZKs0KWObgaEBoSgQiYWAsSmjz/yjl0F++Got0Mdp4Rw==", "license": "MIT", "engines": { "node": ">=6" } }, "node_modules/@fortawesome/fontawesome-svg-core": { - "version": "6.5.2", - "hasInstallScript": true, + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.6.0.tgz", + "integrity": "sha512-KHwPkCk6oRT4HADE7smhfsKudt9N/9lm6EJ5BVg0tD1yPA5hht837fB87F8pn15D8JfTqQOjhKTktwmLMiD7Kg==", "license": "MIT", "dependencies": { - "@fortawesome/fontawesome-common-types": "6.5.2" + "@fortawesome/fontawesome-common-types": "6.6.0" }, "engines": { "node": ">=6" } }, "node_modules/@fortawesome/free-brands-svg-icons": { - "version": "6.5.2", - "hasInstallScript": true, + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-6.6.0.tgz", + "integrity": "sha512-1MPD8lMNW/earme4OQi1IFHtmHUwAKgghXlNwWi9GO7QkTfD+IIaYpIai4m2YJEzqfEji3jFHX1DZI5pbY/biQ==", "license": "(CC-BY-4.0 AND MIT)", "dependencies": { - "@fortawesome/fontawesome-common-types": "6.5.2" + "@fortawesome/fontawesome-common-types": "6.6.0" }, "engines": { "node": ">=6" } }, "node_modules/@fortawesome/free-regular-svg-icons": { - "version": "6.5.2", - "hasInstallScript": true, + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-6.6.0.tgz", + "integrity": "sha512-Yv9hDzL4aI73BEwSEh20clrY8q/uLxawaQ98lekBx6t9dQKDHcDzzV1p2YtBGTtolYtNqcWdniOnhzB+JPnQEQ==", "license": "(CC-BY-4.0 AND MIT)", "dependencies": { - "@fortawesome/fontawesome-common-types": "6.5.2" + "@fortawesome/fontawesome-common-types": "6.6.0" }, "engines": { "node": ">=6" } }, "node_modules/@fortawesome/free-solid-svg-icons": { - "version": "6.5.2", - "hasInstallScript": true, + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.6.0.tgz", + "integrity": "sha512-IYv/2skhEDFc2WGUcqvFJkeK39Q+HyPf5GHUrT/l2pKbtgEIv1al1TKd6qStR5OIwQdN1GZP54ci3y4mroJWjA==", "license": "(CC-BY-4.0 AND MIT)", "dependencies": { - "@fortawesome/fontawesome-common-types": "6.5.2" + "@fortawesome/fontawesome-common-types": "6.6.0" }, "engines": { "node": ">=6" @@ -3616,7 +3554,9 @@ "link": true }, "node_modules/@openedx/frontend-build": { - "version": "14.0.15", + "version": "14.1.4", + "resolved": "https://registry.npmjs.org/@openedx/frontend-build/-/frontend-build-14.1.4.tgz", + "integrity": "sha512-DMzkitHqemtqwxmDsF8y7zRVAJcW8URPfWcLKtFvXffqJ3WW7fJXXMmiZWKra/vGBw3SRyYRqvdzQG1d2giPAw==", "license": "AGPL-3.0", "dependencies": { "@babel/cli": "7.24.8", @@ -3627,7 +3567,7 @@ "@babel/plugin-syntax-dynamic-import": "7.8.3", "@babel/preset-env": "7.24.8", "@babel/preset-react": "7.24.7", - "@edx/eslint-config": "4.1.0", + "@edx/eslint-config": "4.2.0", "@edx/new-relic-source-map-webpack-plugin": "2.1.0", "@edx/typescript-config": "1.1.0", "@formatjs/cli": "^6.0.3", @@ -3637,7 +3577,7 @@ "@types/jest": "29.5.12", "@typescript-eslint/eslint-plugin": "^5.58.0", "@typescript-eslint/parser": "^5.58.0", - "autoprefixer": "10.4.19", + "autoprefixer": "10.4.20", "babel-jest": "29.6.1", "babel-loader": "9.1.3", "babel-plugin-formatjs": "^10.4.0", @@ -3665,7 +3605,8 @@ "jest": "29.6.1", "jest-environment-jsdom": "29.6.1", "mini-css-extract-plugin": "1.6.2", - "postcss": "8.4.39", + "parse5": "7.1.2", + "postcss": "8.4.47", "postcss-custom-media": "10.0.8", "postcss-loader": "7.3.4", "postcss-rtlcss": "5.1.2", @@ -3684,7 +3625,8 @@ "webpack-bundle-analyzer": "^4.10.1", "webpack-cli": "^5.1.4", "webpack-dev-server": "^4.15.1", - "webpack-merge": "^5.10.0" + "webpack-merge": "^5.10.0", + "webpack-remove-empty-scripts": "1.0.4" }, "bin": { "fedx-scripts": "bin/fedx-scripts.js" @@ -3695,6 +3637,8 @@ }, "node_modules/@openedx/frontend-build/node_modules/@eslint/js": { "version": "8.44.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.44.0.tgz", + "integrity": "sha512-Ag+9YM4ocKQx9AarydN0KY2j0ErMHNIocPDrVo8zAE44xLTjEtz81OdR68/cydGtk6m6jDb5Za3r2useMzYmSw==", "license": "MIT", "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" @@ -3702,10 +3646,14 @@ }, "node_modules/@openedx/frontend-build/node_modules/argparse": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, "node_modules/@openedx/frontend-build/node_modules/eslint": { "version": "8.44.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.44.0.tgz", + "integrity": "sha512-0wpHoUbDUHgNCyvFB5aXLiQVfK9B0at6gUvzy83k4kAsQ/u769TQDX6iKC+aO4upIHO9WSaA3QoXYQDHbNwf1A==", "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", @@ -3760,6 +3708,8 @@ }, "node_modules/@openedx/frontend-build/node_modules/eslint-plugin-import": { "version": "2.27.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.27.5.tgz", + "integrity": "sha512-LmEt3GVofgiGuiE+ORpnvP+kAm3h6MLZJ4Q5HCyHADofsb4VzXFsRiWj3c0OFiV+3DWFh0qg3v9gcPlfc3zRow==", "license": "MIT", "dependencies": { "array-includes": "^3.1.6", @@ -3787,6 +3737,8 @@ }, "node_modules/@openedx/frontend-build/node_modules/eslint-plugin-import/node_modules/debug": { "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", "license": "MIT", "dependencies": { "ms": "^2.1.1" @@ -3794,6 +3746,8 @@ }, "node_modules/@openedx/frontend-build/node_modules/eslint-plugin-import/node_modules/doctrine": { "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", "license": "Apache-2.0", "dependencies": { "esutils": "^2.0.2" @@ -3804,6 +3758,8 @@ }, "node_modules/@openedx/frontend-build/node_modules/eslint-scope": { "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", "license": "BSD-2-Clause", "dependencies": { "esrecurse": "^4.3.0", @@ -3818,6 +3774,8 @@ }, "node_modules/@openedx/frontend-build/node_modules/eslint-visitor-keys": { "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", "license": "Apache-2.0", "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" @@ -3828,6 +3786,8 @@ }, "node_modules/@openedx/frontend-build/node_modules/find-up": { "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "license": "MIT", "dependencies": { "locate-path": "^6.0.0", @@ -3842,6 +3802,8 @@ }, "node_modules/@openedx/frontend-build/node_modules/glob-parent": { "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "license": "ISC", "dependencies": { "is-glob": "^4.0.3" @@ -3852,6 +3814,8 @@ }, "node_modules/@openedx/frontend-build/node_modules/globals": { "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", "license": "MIT", "dependencies": { "type-fest": "^0.20.2" @@ -3865,6 +3829,8 @@ }, "node_modules/@openedx/frontend-build/node_modules/jest": { "version": "29.6.1", + "resolved": "https://registry.npmjs.org/jest/-/jest-29.6.1.tgz", + "integrity": "sha512-Nirw5B4nn69rVUZtemCQhwxOBhm0nsp3hmtF4rzCeWD7BkjAXRIji7xWQfnTNbz9g0aVsBX6aZK3n+23LM6uDw==", "license": "MIT", "dependencies": { "@jest/core": "^29.6.1", @@ -3889,6 +3855,8 @@ }, "node_modules/@openedx/frontend-build/node_modules/jest-environment-jsdom": { "version": "29.6.1", + "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-29.6.1.tgz", + "integrity": "sha512-PoY+yLaHzVRhVEjcVKSfJ7wXmJW4UqPYNhR05h7u/TK0ouf6DmRNZFBL/Z00zgQMyWGMBXn69/FmOvhEJu8cIw==", "license": "MIT", "dependencies": { "@jest/environment": "^29.6.1", @@ -3914,6 +3882,8 @@ }, "node_modules/@openedx/frontend-build/node_modules/js-yaml": { "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -3924,6 +3894,8 @@ }, "node_modules/@openedx/frontend-build/node_modules/locate-path": { "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "license": "MIT", "dependencies": { "p-locate": "^5.0.0" @@ -3937,6 +3909,8 @@ }, "node_modules/@openedx/frontend-build/node_modules/p-locate": { "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "license": "MIT", "dependencies": { "p-limit": "^3.0.2" @@ -3950,6 +3924,8 @@ }, "node_modules/@openedx/frontend-build/node_modules/ts-jest": { "version": "29.1.4", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.1.4.tgz", + "integrity": "sha512-YiHwDhSvCiItoAgsKtoLFCuakDzDsJ1DLDnSouTaTmdOcOwIkSzbLXduaQ6M5DRVhuZC/NYaaZ/mtHbWMv/S6Q==", "license": "MIT", "dependencies": { "bs-logger": "0.x", @@ -3995,6 +3971,8 @@ }, "node_modules/@openedx/frontend-build/node_modules/ts-jest/node_modules/semver": { "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -4005,6 +3983,8 @@ }, "node_modules/@openedx/frontend-build/node_modules/type-fest": { "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=10" @@ -4015,14 +3995,17 @@ }, "node_modules/@openedx/frontend-build/node_modules/yargs-parser": { "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", "license": "ISC", "engines": { "node": ">=12" } }, "node_modules/@openedx/frontend-plugin-framework": { - "version": "1.2.3", - "license": "AGPL-3.0", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@openedx/frontend-plugin-framework/-/frontend-plugin-framework-1.3.0.tgz", + "integrity": "sha512-qLtX/4HIuWXiIhBdtBuL6mPVbV2un0rsFYx3I5+3tIUf7+T7WRq81a6JHU5QGyAmZy9dfiv7QwbqwiEQOVXVuQ==", "dependencies": { "@edx/brand": "npm:@openedx/brand-openedx@^1.2.2", "classnames": "^2.3.2", @@ -4057,7 +4040,9 @@ } }, "node_modules/@openedx/paragon": { - "version": "22.7.0", + "version": "22.8.1", + "resolved": "https://registry.npmjs.org/@openedx/paragon/-/paragon-22.8.1.tgz", + "integrity": "sha512-lm2x0tvNZrtJvp0L+cjvLLmkE9NoUbNIzt9L1FaOx9g92gf8rFVgq4aadq7IVAjN12HW19/QJMEJaQ0SVsvY2A==", "license": "Apache-2.0", "workspaces": [ "example", @@ -4108,6 +4093,8 @@ }, "node_modules/@openedx/paragon/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==", "license": "MIT", "dependencies": { "prop-types": "^15.8.1" @@ -4119,6 +4106,8 @@ }, "node_modules/@openedx/paragon/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==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -4126,6 +4115,9 @@ }, "node_modules/@openedx/paragon/node_modules/glob": { "version": "8.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", + "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", + "deprecated": "Glob versions prior to v9 are no longer supported", "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", @@ -4143,6 +4135,8 @@ }, "node_modules/@openedx/paragon/node_modules/minimatch": { "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" @@ -4153,6 +4147,8 @@ }, "node_modules/@openedx/paragon/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==", "license": "MIT", "dependencies": { "hyphenate-style-name": "^1.0.0", @@ -4169,6 +4165,8 @@ }, "node_modules/@openedx/paragon/node_modules/uuid": { "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" @@ -5737,6 +5735,19 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/ansis": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/ansis/-/ansis-1.5.2.tgz", + "integrity": "sha512-T3vUABrcgSj/HXv27P+A/JxGk5b/ydx0JjN3lgjBTC2iZUFxQGjh43zCzLSbU4C1QTgmx9oaPeWNJFM+auI8qw==", + "license": "ISC", + "engines": { + "node": ">=12.13" + }, + "funding": { + "type": "patreon", + "url": "https://patreon.com/biodiscus" + } + }, "node_modules/anymatch": { "version": "3.1.3", "license": "ISC", @@ -5822,24 +5833,6 @@ "node": ">=0.10.0" } }, - "node_modules/array.prototype.find": { - "version": "2.2.3", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-object-atoms": "^1.0.0", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/array.prototype.findlastindex": { "version": "1.2.5", "license": "MIT", @@ -5972,7 +5965,9 @@ } }, "node_modules/autoprefixer": { - "version": "10.4.19", + "version": "10.4.20", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.20.tgz", + "integrity": "sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==", "funding": [ { "type": "opencollective", @@ -5989,11 +5984,11 @@ ], "license": "MIT", "dependencies": { - "browserslist": "^4.23.0", - "caniuse-lite": "^1.0.30001599", + "browserslist": "^4.23.3", + "caniuse-lite": "^1.0.30001646", "fraction.js": "^4.3.7", "normalize-range": "^0.1.2", - "picocolors": "^1.0.0", + "picocolors": "^1.0.1", "postcss-value-parser": "^4.2.0" }, "bin": { @@ -6029,6 +6024,7 @@ "node_modules/axios": { "version": "0.28.1", "license": "MIT", + "peer": true, "dependencies": { "follow-redirects": "^1.15.0", "form-data": "^4.0.0", @@ -6381,8 +6377,9 @@ } }, "node_modules/body-parser": { - "version": "1.20.2", - "license": "MIT", + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", "dependencies": { "bytes": "3.1.2", "content-type": "~1.0.5", @@ -6392,7 +6389,7 @@ "http-errors": "2.0.0", "iconv-lite": "0.4.24", "on-finished": "2.4.1", - "qs": "6.11.0", + "qs": "6.13.0", "raw-body": "2.5.2", "type-is": "~1.6.18", "unpipe": "1.0.0" @@ -6404,14 +6401,16 @@ }, "node_modules/body-parser/node_modules/debug": { "version": "2.6.9", - "license": "MIT", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "dependencies": { "ms": "2.0.0" } }, "node_modules/body-parser/node_modules/ms": { "version": "2.0.0", - "license": "MIT" + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, "node_modules/bonjour-service": { "version": "1.2.1", @@ -6536,7 +6535,8 @@ }, "node_modules/bytes": { "version": "3.1.2", - "license": "MIT", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", "engines": { "node": ">= 0.8" } @@ -6626,7 +6626,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001651", + "version": "1.0.30001667", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001667.tgz", + "integrity": "sha512-7LTwJjcRkzKFmtqGsibMeuXmvFDfZq/nzIjnmgCGzKKRVzjD72selLDK1oPF/Oxzmt4fNcPvTDvGqSDG4tCALw==", "funding": [ { "type": "opencollective", @@ -7019,7 +7021,8 @@ }, "node_modules/content-type": { "version": "1.0.5", - "license": "MIT", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", "engines": { "node": ">= 0.6" } @@ -7566,14 +7569,6 @@ "version": "10.4.3", "license": "MIT" }, - "node_modules/decode-uri-component": { - "version": "0.4.1", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=14.16" - } - }, "node_modules/decompress-response": { "version": "6.0.0", "license": "MIT", @@ -7765,7 +7760,8 @@ }, "node_modules/depd": { "version": "2.0.0", - "license": "MIT", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", "engines": { "node": ">= 0.8" } @@ -7779,7 +7775,8 @@ }, "node_modules/destroy": { "version": "1.2.0", - "license": "MIT", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", "engines": { "node": ">= 0.8", "npm": "1.2.8000 || >= 1.4.16" @@ -7999,7 +7996,8 @@ }, "node_modules/ee-first": { "version": "1.1.1", - "license": "MIT" + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" }, "node_modules/ejs": { "version": "3.1.10", @@ -8053,8 +8051,9 @@ } }, "node_modules/encodeurl": { - "version": "1.0.2", - "license": "MIT", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", "engines": { "node": ">= 0.8" } @@ -8422,11 +8421,12 @@ } }, "node_modules/eslint-import-resolver-webpack": { - "version": "0.13.8", + "version": "0.13.9", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-webpack/-/eslint-import-resolver-webpack-0.13.9.tgz", + "integrity": "sha512-yGngeefNiHXau2yzKKs2BNON4HLpxBabY40BGL/vUSKZtqzjlVsTTZm57jhHULhm+mJEwKsEIIN3NXup5AiiBQ==", "dev": true, "license": "MIT", "dependencies": { - "array.prototype.find": "^2.2.2", "debug": "^3.2.7", "enhanced-resolve": "^0.9.1", "find-root": "^1.1.0", @@ -8448,6 +8448,8 @@ }, "node_modules/eslint-import-resolver-webpack/node_modules/debug": { "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", "dev": true, "license": "MIT", "dependencies": { @@ -8456,6 +8458,8 @@ }, "node_modules/eslint-import-resolver-webpack/node_modules/resolve": { "version": "2.0.0-next.5", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", + "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", "dev": true, "license": "MIT", "dependencies": { @@ -8472,6 +8476,8 @@ }, "node_modules/eslint-import-resolver-webpack/node_modules/semver": { "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", "dev": true, "license": "ISC", "bin": { @@ -9026,7 +9032,8 @@ }, "node_modules/etag": { "version": "1.8.1", - "license": "MIT", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", "engines": { "node": ">= 0.6" } @@ -9091,35 +9098,36 @@ } }, "node_modules/express": { - "version": "4.19.2", - "license": "MIT", + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.0.tgz", + "integrity": "sha512-VqcNGcj/Id5ZT1LZ/cfihi3ttTn+NJmkli2eZADigjq29qTlWi/hAQ43t/VLPq8+UX06FCEx3ByOYet6ZFblng==", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "1.20.2", + "body-parser": "1.20.3", "content-disposition": "0.5.4", "content-type": "~1.0.4", "cookie": "0.6.0", "cookie-signature": "1.0.6", "debug": "2.6.9", "depd": "2.0.0", - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", - "finalhandler": "1.2.0", + "finalhandler": "1.3.1", "fresh": "0.5.2", "http-errors": "2.0.0", - "merge-descriptors": "1.0.1", + "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "0.1.7", + "path-to-regexp": "0.1.10", "proxy-addr": "~2.0.7", - "qs": "6.11.0", + "qs": "6.13.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", - "send": "0.18.0", - "serve-static": "1.15.0", + "send": "0.19.0", + "serve-static": "1.16.2", "setprototypeof": "1.2.0", "statuses": "2.0.1", "type-is": "~1.6.18", @@ -9433,11 +9441,12 @@ } }, "node_modules/finalhandler": { - "version": "1.2.0", - "license": "MIT", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", "dependencies": { "debug": "2.6.9", - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "2.4.1", "parseurl": "~1.3.3", @@ -9450,14 +9459,16 @@ }, "node_modules/finalhandler/node_modules/debug": { "version": "2.6.9", - "license": "MIT", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "dependencies": { "ms": "2.0.0" } }, "node_modules/finalhandler/node_modules/ms": { "version": "2.0.0", - "license": "MIT" + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, "node_modules/find-cache-dir": { "version": "4.0.0", @@ -9723,13 +9734,16 @@ }, "node_modules/fresh": { "version": "0.5.2", - "license": "MIT", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", "engines": { "node": ">= 0.6" } }, "node_modules/frontend-components-tinymce-advanced-plugins": { - "version": "1.0.3", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/frontend-components-tinymce-advanced-plugins/-/frontend-components-tinymce-advanced-plugins-1.0.4.tgz", + "integrity": "sha512-3PzEaOa9k1csUsVqvrJ11LXiUgu804lax0sq6eWmirtfYMJNYrajUEwW4REKTnpQrv1ByDSkVV/kaporN0PkWA==", "license": "AGPL-3.0", "dependencies": { "tinymce": "^5.10.4" @@ -10299,7 +10313,8 @@ }, "node_modules/http-errors": { "version": "2.0.0", - "license": "MIT", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", @@ -12462,6 +12477,7 @@ }, "node_modules/lodash.isplainobject": { "version": "4.0.6", + "dev": true, "license": "MIT" }, "node_modules/lodash.memoize": { @@ -12622,7 +12638,8 @@ }, "node_modules/media-typer": { "version": "0.3.0", - "license": "MIT", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", "engines": { "node": ">= 0.6" } @@ -12730,8 +12747,12 @@ } }, "node_modules/merge-descriptors": { - "version": "1.0.1", - "license": "MIT" + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, "node_modules/merge-stream": { "version": "2.0.0", @@ -13098,7 +13119,9 @@ } }, "node_modules/npm": { - "version": "10.8.2", + "version": "10.8.3", + "resolved": "https://registry.npmjs.org/npm/-/npm-10.8.3.tgz", + "integrity": "sha512-0IQlyAYvVtQ7uOhDFYZCGK8kkut2nh8cpAdA9E6FvRSJaTgtZRZgNjlC5ZCct//L73ygrpY93CxXpRJDtNqPVg==", "bundleDependencies": [ "@isaacs/string-locale-compare", "@npmcli/arborist", @@ -13190,13 +13213,13 @@ "@sigstore/tuf": "^2.3.4", "abbrev": "^2.0.0", "archy": "~1.0.0", - "cacache": "^18.0.3", + "cacache": "^18.0.4", "chalk": "^5.3.0", "ci-info": "^4.0.0", "cli-columns": "^4.0.0", "fastest-levenshtein": "^1.0.16", "fs-minipass": "^3.0.3", - "glob": "^10.4.2", + "glob": "^10.4.5", "graceful-fs": "^4.2.11", "hosted-git-info": "^7.0.2", "ini": "^4.1.3", @@ -13205,7 +13228,7 @@ "json-parse-even-better-errors": "^3.0.2", "libnpmaccess": "^8.0.6", "libnpmdiff": "^6.1.4", - "libnpmexec": "^8.1.3", + "libnpmexec": "^8.1.4", "libnpmfund": "^5.0.12", "libnpmhook": "^10.0.5", "libnpmorg": "^6.0.6", @@ -13219,12 +13242,12 @@ "minipass": "^7.1.1", "minipass-pipeline": "^1.2.4", "ms": "^2.1.2", - "node-gyp": "^10.1.0", + "node-gyp": "^10.2.0", "nopt": "^7.2.1", "normalize-package-data": "^6.0.2", "npm-audit-report": "^5.0.0", "npm-install-checks": "^6.3.0", - "npm-package-arg": "^11.0.2", + "npm-package-arg": "^11.0.3", "npm-pick-manifest": "^9.1.0", "npm-profile": "^10.0.0", "npm-registry-fetch": "^17.1.0", @@ -13235,7 +13258,7 @@ "proc-log": "^4.2.0", "qrcode-terminal": "^0.12.0", "read": "^3.0.1", - "semver": "^7.6.2", + "semver": "^7.6.3", "spdx-expression-parse": "^4.0.0", "ssri": "^10.0.6", "supports-color": "^9.4.0", @@ -13762,7 +13785,7 @@ } }, "node_modules/npm/node_modules/cacache": { - "version": "18.0.3", + "version": "18.0.4", "inBundle": true, "license": "ISC", "dependencies": { @@ -13915,7 +13938,7 @@ } }, "node_modules/npm/node_modules/debug": { - "version": "4.3.5", + "version": "4.3.6", "inBundle": true, "license": "MIT", "dependencies": { @@ -13989,7 +14012,7 @@ } }, "node_modules/npm/node_modules/foreground-child": { - "version": "3.2.1", + "version": "3.3.0", "inBundle": true, "license": "ISC", "dependencies": { @@ -14015,7 +14038,7 @@ } }, "node_modules/npm/node_modules/glob": { - "version": "10.4.2", + "version": "10.4.5", "inBundle": true, "license": "ISC", "dependencies": { @@ -14029,9 +14052,6 @@ "bin": { "glob": "dist/esm/bin.mjs" }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, "funding": { "url": "https://github.com/sponsors/isaacs" } @@ -14198,15 +14218,12 @@ "license": "ISC" }, "node_modules/npm/node_modules/jackspeak": { - "version": "3.4.0", + "version": "3.4.3", "inBundle": true, "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/cliui": "^8.0.2" }, - "engines": { - "node": ">=14" - }, "funding": { "url": "https://github.com/sponsors/isaacs" }, @@ -14284,7 +14301,7 @@ } }, "node_modules/npm/node_modules/libnpmexec": { - "version": "8.1.3", + "version": "8.1.4", "inBundle": true, "license": "ISC", "dependencies": { @@ -14409,12 +14426,9 @@ } }, "node_modules/npm/node_modules/lru-cache": { - "version": "10.2.2", + "version": "10.4.3", "inBundle": true, - "license": "ISC", - "engines": { - "node": "14 || >=16.14" - } + "license": "ISC" }, "node_modules/npm/node_modules/make-fetch-happen": { "version": "13.0.1", @@ -14609,7 +14623,7 @@ } }, "node_modules/npm/node_modules/node-gyp": { - "version": "10.1.0", + "version": "10.2.0", "inBundle": true, "license": "MIT", "dependencies": { @@ -14619,9 +14633,9 @@ "graceful-fs": "^4.2.6", "make-fetch-happen": "^13.0.0", "nopt": "^7.0.0", - "proc-log": "^3.0.0", + "proc-log": "^4.1.0", "semver": "^7.3.5", - "tar": "^6.1.2", + "tar": "^6.2.1", "which": "^4.0.0" }, "bin": { @@ -14631,14 +14645,6 @@ "node": "^16.14.0 || >=18.0.0" } }, - "node_modules/npm/node_modules/node-gyp/node_modules/proc-log": { - "version": "3.0.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, "node_modules/npm/node_modules/nopt": { "version": "7.2.1", "inBundle": true, @@ -14705,7 +14711,7 @@ } }, "node_modules/npm/node_modules/npm-package-arg": { - "version": "11.0.2", + "version": "11.0.3", "inBundle": true, "license": "ISC", "dependencies": { @@ -14867,7 +14873,7 @@ } }, "node_modules/npm/node_modules/postcss-selector-parser": { - "version": "6.1.0", + "version": "6.1.2", "inBundle": true, "license": "MIT", "dependencies": { @@ -14991,7 +14997,7 @@ "optional": true }, "node_modules/npm/node_modules/semver": { - "version": "7.6.2", + "version": "7.6.3", "inBundle": true, "license": "ISC", "bin": { @@ -15617,7 +15623,8 @@ }, "node_modules/on-finished": { "version": "2.4.1", - "license": "MIT", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", "dependencies": { "ee-first": "1.1.1" }, @@ -15871,8 +15878,9 @@ "license": "MIT" }, "node_modules/path-to-regexp": { - "version": "0.1.7", - "license": "MIT" + "version": "0.1.10", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz", + "integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==" }, "node_modules/path-type": { "version": "4.0.0", @@ -15882,7 +15890,9 @@ } }, "node_modules/picocolors": { - "version": "1.0.1", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz", + "integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==", "license": "ISC" }, "node_modules/picomatch": { @@ -16087,7 +16097,9 @@ } }, "node_modules/postcss": { - "version": "8.4.39", + "version": "8.4.47", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", + "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==", "funding": [ { "type": "opencollective", @@ -16105,8 +16117,8 @@ "license": "MIT", "dependencies": { "nanoid": "^3.3.7", - "picocolors": "^1.0.1", - "source-map-js": "^1.2.0" + "picocolors": "^1.1.0", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12 || >=14" @@ -16900,10 +16912,11 @@ } }, "node_modules/qs": { - "version": "6.11.0", - "license": "BSD-3-Clause", + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", "dependencies": { - "side-channel": "^1.0.4" + "side-channel": "^1.0.6" }, "engines": { "node": ">=0.6" @@ -16996,7 +17009,8 @@ }, "node_modules/raw-body": { "version": "2.5.2", - "license": "MIT", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.0", @@ -17645,14 +17659,6 @@ "react-dom": ">=16.6.0" } }, - "node_modules/reactifex": { - "version": "1.1.1", - "dev": true, - "license": "MIT", - "bin": { - "reactifex": "main.js" - } - }, "node_modules/read-pkg": { "version": "6.0.0", "dev": true, @@ -17831,6 +17837,7 @@ }, "node_modules/redux-mock-store": { "version": "1.5.4", + "dev": true, "license": "MIT", "dependencies": { "lodash.isplainobject": "^4.0.6" @@ -18303,8 +18310,9 @@ } }, "node_modules/send": { - "version": "0.18.0", - "license": "MIT", + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", "dependencies": { "debug": "2.6.9", "depd": "2.0.0", @@ -18326,18 +18334,29 @@ }, "node_modules/send/node_modules/debug": { "version": "2.6.9", - "license": "MIT", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "dependencies": { "ms": "2.0.0" } }, "node_modules/send/node_modules/debug/node_modules/ms": { "version": "2.0.0", - "license": "MIT" + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "engines": { + "node": ">= 0.8" + } }, "node_modules/send/node_modules/ms": { "version": "2.1.3", - "license": "MIT" + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, "node_modules/serialize-javascript": { "version": "6.0.2", @@ -18409,13 +18428,14 @@ } }, "node_modules/serve-static": { - "version": "1.15.0", - "license": "MIT", + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", "dependencies": { - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", - "send": "0.18.0" + "send": "0.19.0" }, "engines": { "node": ">= 0.8.0" @@ -18455,7 +18475,8 @@ }, "node_modules/setprototypeof": { "version": "1.2.0", - "license": "ISC" + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" }, "node_modules/shallow-clone": { "version": "3.0.1", @@ -18673,7 +18694,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.0", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -18829,7 +18852,8 @@ }, "node_modules/statuses": { "version": "2.0.1", - "license": "MIT", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", "engines": { "node": ">= 0.8" } @@ -19629,7 +19653,8 @@ }, "node_modules/toidentifier": { "version": "1.0.1", - "license": "MIT", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", "engines": { "node": ">=0.6" } @@ -19757,56 +19782,6 @@ "node": ">=12" } }, - "node_modules/ts-loader": { - "version": "9.5.1", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.1.0", - "enhanced-resolve": "^5.0.0", - "micromatch": "^4.0.0", - "semver": "^7.3.4", - "source-map": "^0.7.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "typescript": "*", - "webpack": "^5.0.0" - } - }, - "node_modules/ts-loader/node_modules/enhanced-resolve": { - "version": "5.17.1", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.2.0" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/ts-loader/node_modules/semver": { - "version": "7.6.3", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/ts-loader/node_modules/tapable": { - "version": "2.2.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/tsconfig-paths": { "version": "3.15.0", "license": "MIT", @@ -19894,7 +19869,8 @@ }, "node_modules/type-is": { "version": "1.6.18", - "license": "MIT", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" @@ -20072,7 +20048,8 @@ }, "node_modules/unpipe": { "version": "1.0.0", - "license": "MIT", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", "engines": { "node": ">= 0.8" } @@ -20617,6 +20594,25 @@ "node": ">=10.0.0" } }, + "node_modules/webpack-remove-empty-scripts": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/webpack-remove-empty-scripts/-/webpack-remove-empty-scripts-1.0.4.tgz", + "integrity": "sha512-W/Vd94oNXMsQam+W9G+aAzGgFlX1aItcJpkG3byuHGDaxyK3H17oD/b5RcqS/ZHzStIKepksdLDznejDhDUs+Q==", + "license": "ISC", + "dependencies": { + "ansis": "1.5.2" + }, + "engines": { + "node": ">=12.14" + }, + "funding": { + "type": "patreon", + "url": "https://patreon.com/biodiscus" + }, + "peerDependencies": { + "webpack": ">=5.32.0" + } + }, "node_modules/webpack-sources": { "version": "1.4.3", "license": "MIT", @@ -20946,14 +20942,14 @@ "name": "@openedx-plugins/course-app-calculator", "version": "0.1.0", "peerDependencies": { - "@edx/frontend-app-course-authoring": "*", + "@edx/frontend-app-authoring": "*", "@edx/frontend-platform": "*", "@openedx/paragon": "*", "prop-types": "*", "react": "*" }, "peerDependenciesMeta": { - "@edx/frontend-app-course-authoring": { + "@edx/frontend-app-authoring": { "optional": true } } @@ -20962,14 +20958,14 @@ "name": "@openedx-plugins/course-app-edxnotes", "version": "0.1.0", "peerDependencies": { - "@edx/frontend-app-course-authoring": "*", + "@edx/frontend-app-authoring": "*", "@edx/frontend-platform": "*", "@openedx/paragon": "*", "prop-types": "*", "react": "*" }, "peerDependenciesMeta": { - "@edx/frontend-app-course-authoring": { + "@edx/frontend-app-authoring": { "optional": true } } @@ -20978,7 +20974,7 @@ "name": "@openedx-plugins/course-app-learning_assistant", "version": "0.1.0", "peerDependencies": { - "@edx/frontend-app-course-authoring": "*", + "@edx/frontend-app-authoring": "*", "@edx/frontend-platform": "*", "@openedx/paragon": "*", "prop-types": "*", @@ -20986,7 +20982,7 @@ "yup": "*" }, "peerDependenciesMeta": { - "@edx/frontend-app-course-authoring": { + "@edx/frontend-app-authoring": { "optional": true } } @@ -20995,7 +20991,7 @@ "name": "@openedx-plugins/course-app-live", "version": "0.1.0", "peerDependencies": { - "@edx/frontend-app-course-authoring": "*", + "@edx/frontend-app-authoring": "*", "@edx/frontend-platform": "*", "@openedx/paragon": "*", "@reduxjs/toolkit": "*", @@ -21007,7 +21003,7 @@ "yup": "*" }, "peerDependenciesMeta": { - "@edx/frontend-app-course-authoring": { + "@edx/frontend-app-authoring": { "optional": true } } @@ -21016,15 +21012,16 @@ "name": "@openedx-plugins/course-app-ora_settings", "version": "0.1.0", "peerDependencies": { - "@edx/frontend-app-course-authoring": "*", + "@edx/frontend-app-authoring": "*", "@edx/frontend-platform": "*", "@openedx/paragon": "*", "prop-types": "*", "react": "*", + "react-redux": "*", "yup": "*" }, "peerDependenciesMeta": { - "@edx/frontend-app-course-authoring": { + "@edx/frontend-app-authoring": { "optional": true } } @@ -21033,7 +21030,7 @@ "name": "@openedx-plugins/course-app-proctoring", "version": "0.1.0", "peerDependencies": { - "@edx/frontend-app-course-authoring": "*", + "@edx/frontend-app-authoring": "*", "@edx/frontend-platform": "*", "@openedx/paragon": "*", "classnames": "*", @@ -21043,7 +21040,7 @@ "react": "*" }, "peerDependenciesMeta": { - "@edx/frontend-app-course-authoring": { + "@edx/frontend-app-authoring": { "optional": true } } @@ -21052,7 +21049,7 @@ "name": "@openedx-plugins/course-app-progress", "version": "0.1.0", "peerDependencies": { - "@edx/frontend-app-course-authoring": "*", + "@edx/frontend-app-authoring": "*", "@edx/frontend-platform": "*", "@openedx/paragon": "*", "prop-types": "*", @@ -21060,7 +21057,7 @@ "yup": "*" }, "peerDependenciesMeta": { - "@edx/frontend-app-course-authoring": { + "@edx/frontend-app-authoring": { "optional": true } } @@ -21069,7 +21066,7 @@ "name": "@openedx-plugins/course-app-teams", "version": "0.1.0", "peerDependencies": { - "@edx/frontend-app-course-authoring": "*", + "@edx/frontend-app-authoring": "*", "@edx/frontend-platform": "*", "@openedx/paragon": "*", "formik": "*", @@ -21079,7 +21076,7 @@ "yup": "*" }, "peerDependenciesMeta": { - "@edx/frontend-app-course-authoring": { + "@edx/frontend-app-authoring": { "optional": true } } @@ -21088,7 +21085,7 @@ "name": "@openedx-plugins/course-app-wiki", "version": "0.1.0", "peerDependencies": { - "@edx/frontend-app-course-authoring": "*", + "@edx/frontend-app-authoring": "*", "@edx/frontend-platform": "*", "@openedx/paragon": "*", "prop-types": "*", @@ -21096,7 +21093,7 @@ "yup": "*" }, "peerDependenciesMeta": { - "@edx/frontend-app-course-authoring": { + "@edx/frontend-app-authoring": { "optional": true } } @@ -21105,7 +21102,7 @@ "name": "@openedx-plugins/course-app-xpert_unit_summary", "version": "0.1.0", "peerDependencies": { - "@edx/frontend-app-course-authoring": "*", + "@edx/frontend-app-authoring": "*", "@edx/frontend-platform": "*", "@openedx/paragon": "*", "formik": "*", @@ -21116,7 +21113,7 @@ "yup": "*" }, "peerDependenciesMeta": { - "@edx/frontend-app-course-authoring": { + "@edx/frontend-app-authoring": { "optional": true } } diff --git a/package.json b/package.json index 7c59b263c6..8fa50f78ea 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,10 @@ { - "name": "@edx/frontend-app-course-authoring", + "name": "@edx/frontend-app-authoring", "version": "0.1.0", "description": "Frontend application template", "repository": { "type": "git", - "url": "git+https://github.com/openedx/frontend-app-course-authoring.git" + "url": "git+https://github.com/openedx/frontend-app-authoring.git" }, "browserslist": [ "extends @edx/browserslist-config" @@ -18,6 +18,7 @@ "snapshot": "TZ=UTC fedx-scripts jest --updateSnapshot", "start": "fedx-scripts webpack-dev-server --progress", "start:with-theme": "paragon install-theme && npm start && npm install", + "dev": "PUBLIC_PATH=/authoring/ MFE_CONFIG_API_URL='http://localhost:8000/api/mfe_config/v1' fedx-scripts webpack-dev-server --progress --host apps.local.openedx.io", "test": "TZ=UTC fedx-scripts jest --coverage --passWithNoTests", "test:ci": "TZ=UTC fedx-scripts jest --silent --coverage --passWithNoTests", "types": "tsc --noEmit" @@ -29,12 +30,12 @@ }, "author": "edX", "license": "AGPL-3.0", - "homepage": "https://github.com/openedx/frontend-app-course-authoring#readme", + "homepage": "https://github.com/openedx/frontend-app-authoring#readme", "publishConfig": { "access": "public" }, "bugs": { - "url": "https://github.com/openedx/frontend-app-course-authoring/issues" + "url": "https://github.com/openedx/frontend-app-authoring/issues" }, "dependencies": { "@codemirror/lang-html": "^6.0.0", @@ -47,8 +48,9 @@ "@dnd-kit/sortable": "^8.0.0", "@dnd-kit/utilities": "^3.2.2", "@edx/brand": "npm:@openedx/brand-openedx@^1.2.3", - "@edx/frontend-component-footer": "^14.0.3", - "@edx/frontend-component-header": "^5.3.3", + "@edx/browserslist-config": "1.2.0", + "@edx/frontend-component-footer": "^14.1.0", + "@edx/frontend-component-header": "^5.6.0", "@edx/frontend-enterprise-hotjar": "^2.0.0", "@edx/frontend-platform": "^8.0.3", "@edx/openedx-atlas": "^0.6.0", @@ -62,8 +64,9 @@ "@openedx-plugins/course-app-teams": "file:plugins/course-apps/teams", "@openedx-plugins/course-app-wiki": "file:plugins/course-apps/wiki", "@openedx-plugins/course-app-xpert_unit_summary": "file:plugins/course-apps/xpert_unit_summary", + "@openedx/frontend-build": "^14.0.14", "@openedx/frontend-plugin-framework": "^1.2.1", - "@openedx/paragon": "^22.5.1", + "@openedx/paragon": "^22.8.1", "@redux-devtools/extension": "^3.3.0", "@reduxjs/toolkit": "1.9.7", "@tanstack/react-query": "4.36.1", @@ -97,7 +100,6 @@ "react-transition-group": "4.4.5", "redux": "4.0.5", "redux-logger": "^3.0.6", - "redux-mock-store": "^1.5.4", "redux-thunk": "^2.4.1", "reselect": "^4.1.5", "start": "^5.1.0", @@ -108,30 +110,21 @@ "yup": "0.31.1" }, "devDependencies": { - "@edx/browserslist-config": "1.2.0", "@edx/react-unit-test-utils": "3.0.0", - "@edx/reactifex": "^1.0.3", "@edx/stylelint-config-edx": "2.3.3", "@edx/typescript-config": "^1.0.1", - "@openedx/frontend-build": "^14.0.14", "@testing-library/jest-dom": "5.17.0", "@testing-library/react": "12.1.5", "@testing-library/react-hooks": "^8.0.1", "@testing-library/user-event": "^13.2.1", "@types/lodash": "^4.17.7", - "axios": "^0.28.0", "axios-mock-adapter": "1.22.0", "eslint-import-resolver-webpack": "^0.13.8", "fetch-mock-jest": "^1.5.1", - "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.1" - }, - "peerDependencies": { - "decode-uri-component": ">=0.2.2" + "redux-mock-store": "^1.5.4" } } diff --git a/plugins/course-apps/calculator/package.json b/plugins/course-apps/calculator/package.json index 6f4a98670e..dec9813433 100644 --- a/plugins/course-apps/calculator/package.json +++ b/plugins/course-apps/calculator/package.json @@ -3,14 +3,14 @@ "version": "0.1.0", "description": "Calculator configuration for courses using it", "peerDependencies": { - "@edx/frontend-app-course-authoring": "*", + "@edx/frontend-app-authoring": "*", "@edx/frontend-platform": "*", "@openedx/paragon": "*", "prop-types": "*", "react": "*" }, "peerDependenciesMeta": { - "@edx/frontend-app-course-authoring": { + "@edx/frontend-app-authoring": { "optional": true } } diff --git a/plugins/course-apps/edxnotes/package.json b/plugins/course-apps/edxnotes/package.json index ed2287db22..39c643d015 100644 --- a/plugins/course-apps/edxnotes/package.json +++ b/plugins/course-apps/edxnotes/package.json @@ -3,14 +3,14 @@ "version": "0.1.0", "description": "edxnotes configuration for courses using it", "peerDependencies": { - "@edx/frontend-app-course-authoring": "*", + "@edx/frontend-app-authoring": "*", "@edx/frontend-platform": "*", "@openedx/paragon": "*", "prop-types": "*", "react": "*" }, "peerDependenciesMeta": { - "@edx/frontend-app-course-authoring": { + "@edx/frontend-app-authoring": { "optional": true } } diff --git a/plugins/course-apps/learning_assistant/package.json b/plugins/course-apps/learning_assistant/package.json index 0c96b6fc5c..1ad1a000a9 100644 --- a/plugins/course-apps/learning_assistant/package.json +++ b/plugins/course-apps/learning_assistant/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "description": "Learning Assistant configuration for courses using it", "peerDependencies": { - "@edx/frontend-app-course-authoring": "*", + "@edx/frontend-app-authoring": "*", "@edx/frontend-platform": "*", "@openedx/paragon": "*", "prop-types": "*", @@ -11,7 +11,7 @@ "yup": "*" }, "peerDependenciesMeta": { - "@edx/frontend-app-course-authoring": { + "@edx/frontend-app-authoring": { "optional": true } } diff --git a/plugins/course-apps/live/package.json b/plugins/course-apps/live/package.json index 6fbd074feb..ac9485ea7e 100644 --- a/plugins/course-apps/live/package.json +++ b/plugins/course-apps/live/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "description": "Live course configuration for courses using it", "peerDependencies": { - "@edx/frontend-app-course-authoring": "*", + "@edx/frontend-app-authoring": "*", "@edx/frontend-platform": "*", "@openedx/paragon": "*", "@reduxjs/toolkit": "*", @@ -15,7 +15,7 @@ "yup": "*" }, "peerDependenciesMeta": { - "@edx/frontend-app-course-authoring": { + "@edx/frontend-app-authoring": { "optional": true } } diff --git a/plugins/course-apps/ora_settings/Settings.jsx b/plugins/course-apps/ora_settings/Settings.jsx index b3e3c0d287..f16d10f48b 100644 --- a/plugins/course-apps/ora_settings/Settings.jsx +++ b/plugins/course-apps/ora_settings/Settings.jsx @@ -1,69 +1,176 @@ -import React from 'react'; +import { useEffect, useState, useRef } from 'react'; import PropTypes from 'prop-types'; -import * as Yup from 'yup'; -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { useDispatch, useSelector } from 'react-redux'; -import { Hyperlink } from '@openedx/paragon'; -import { useModel } from 'CourseAuthoring/generic/model-store'; +import { + ActionRow, Alert, Badge, Form, Hyperlink, ModalDialog, StatefulButton, +} from '@openedx/paragon'; +import { Info } from '@openedx/paragon/icons'; +import { updateModel, useModel } from 'CourseAuthoring/generic/model-store'; +import { RequestStatus } from 'CourseAuthoring/data/constants'; import FormSwitchGroup from 'CourseAuthoring/generic/FormSwitchGroup'; -import { useAppSetting } from 'CourseAuthoring/utils'; -import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal'; +import Loading from 'CourseAuthoring/generic/Loading'; +import PermissionDeniedAlert from 'CourseAuthoring/generic/PermissionDeniedAlert'; +import ConnectionErrorAlert from 'CourseAuthoring/generic/ConnectionErrorAlert'; +import { useAppSetting, useIsMobile } from 'CourseAuthoring/utils'; +import { getLoadingStatus, getSavingStatus } from 'CourseAuthoring/pages-and-resources/data/selectors'; +import { updateSavingStatus } from 'CourseAuthoring/pages-and-resources/data/slice'; + import messages from './messages'; -const ORASettings = ({ intl, onClose }) => { +const ORASettings = ({ onClose }) => { + const dispatch = useDispatch(); + const { formatMessage } = useIntl(); + const alertRef = useRef(null); + const updateSettingsRequestStatus = useSelector(getSavingStatus); + const loadingStatus = useSelector(getLoadingStatus); + const isMobile = useIsMobile(); + const modalVariant = isMobile ? 'dark' : 'default'; const appId = 'ora_settings'; const appInfo = useModel('courseApps', appId); + const [enableFlexiblePeerGrade, saveSetting] = useAppSetting( 'forceOnFlexiblePeerOpenassessments', ); + const initialFormValues = { enableFlexiblePeerGrade }; + + const [formValues, setFormValues] = useState(initialFormValues); + const [saveError, setSaveError] = useState(false); + + const submitButtonState = updateSettingsRequestStatus === RequestStatus.IN_PROGRESS ? 'pending' : 'default'; const handleSettingsSave = (values) => saveSetting(values.enableFlexiblePeerGrade); - const title = ( -
-

{intl.formatMessage(messages.heading)}

-
- - {intl.formatMessage(messages.ORASettingsHelpLink)} - -
-
- ); + const handleSubmit = async (event) => { + let success = true; + event.preventDefault(); + + success = success && await handleSettingsSave(formValues); + await setSaveError(!success); + if ((initialFormValues.enableFlexiblePeerGrade !== formValues.enableFlexiblePeerGrade) && success) { + success = await dispatch(updateModel({ + modelType: 'courseApps', + model: { + id: appId, enabled: formValues.enableFlexiblePeerGrade, + }, + })); + } + !success && alertRef?.current.scrollIntoView(); // eslint-disable-line @typescript-eslint/no-unused-expressions + }; + + const handleChange = (e) => { + setFormValues({ enableFlexiblePeerGrade: e.target.checked }); + }; + + useEffect(() => { + if (updateSettingsRequestStatus === RequestStatus.SUCCESSFUL) { + dispatch(updateSavingStatus({ status: '' })); + onClose(); + } + }, [updateSettingsRequestStatus]); + + const renderBody = () => { + switch (loadingStatus) { + case RequestStatus.SUCCESSFUL: + return ( + <> + {saveError && ( + + + {formatMessage(messages.errorSavingTitle)} + + {formatMessage(messages.errorSavingMessage)} + + )} + + {formatMessage(messages.enableFlexPeerGradeLabel)} + {formValues.enableFlexiblePeerGrade && ( + + {formatMessage(messages.enabledBadgeLabel)} + + )} + + )} + helpText={( +
+

{formatMessage(messages.enableFlexPeerGradeHelp)}

+ + + {formatMessage(messages.ORASettingsHelpLink)} + + +
+ )} + onChange={handleChange} + checked={formValues.enableFlexiblePeerGrade} + /> + + ); + case RequestStatus.DENIED: + return ; + case RequestStatus.FAILED: + return ; + default: + return ; + } + }; return ( - - {({ values, handleChange, handleBlur }) => ( - - )} - +
+ + + {formatMessage(messages.heading)} + + + + {renderBody()} + + + + + {formatMessage(messages.cancelLabel)} + + + + +
+ ); }; ORASettings.propTypes = { - intl: intlShape.isRequired, onClose: PropTypes.func.isRequired, }; -export default injectIntl(ORASettings); +export default ORASettings; diff --git a/plugins/course-apps/ora_settings/Settings.test.jsx b/plugins/course-apps/ora_settings/Settings.test.jsx index d74cab9e69..a037e0c438 100644 --- a/plugins/course-apps/ora_settings/Settings.test.jsx +++ b/plugins/course-apps/ora_settings/Settings.test.jsx @@ -1,33 +1,152 @@ -import { shallow } from '@edx/react-unit-test-utils'; +import { + render, + screen, + waitFor, + within, +} from '@testing-library/react'; +import ReactDOM from 'react-dom'; +import { Routes, Route, MemoryRouter } from 'react-router-dom'; +import { initializeMockApp } from '@edx/frontend-platform'; +import MockAdapter from 'axios-mock-adapter'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { AppProvider, PageWrap } from '@edx/frontend-platform/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; + +import initializeStore from 'CourseAuthoring/store'; +import { executeThunk } from 'CourseAuthoring/utils'; +import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider'; +import { getCourseAppsApiUrl, getCourseAdvancedSettingsApiUrl } from 'CourseAuthoring/pages-and-resources/data/api'; +import { fetchCourseApps, fetchCourseAppSettings } from 'CourseAuthoring/pages-and-resources/data/thunks'; import ORASettings from './Settings'; +import messages from './messages'; +import { + courseId, + inititalState, +} from './factories/mockData'; + +let axiosMock; +let store; +const oraSettingsUrl = `/course/${courseId}/pages-and-resources/live/settings`; + +// Modal creates a portal. Overriding ReactDOM.createPortal allows portals to be tested in jest. +ReactDOM.createPortal = jest.fn(node => node); + +const renderComponent = () => ( + render( + + + + + + } /> + + + + + , + ) +); -jest.mock('@edx/frontend-platform/i18n', () => ({ - ...jest.requireActual('@edx/frontend-platform/i18n'), // use actual for all non-hook parts - injectIntl: (component) => component, - intlShape: {}, -})); -jest.mock('yup', () => ({ - boolean: jest.fn().mockReturnValue('Yub.boolean'), -})); -jest.mock('CourseAuthoring/generic/model-store', () => ({ - useModel: jest.fn().mockReturnValue({ documentationLinks: { learnMoreConfiguration: 'https://learnmore.test' } }), -})); -jest.mock('CourseAuthoring/generic/FormSwitchGroup', () => 'FormSwitchGroup'); -jest.mock('CourseAuthoring/utils', () => ({ - useAppSetting: jest.fn().mockReturnValue(['abitrary value', jest.fn().mockName('saveSetting')]), -})); -jest.mock('CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal', () => 'AppSettingsModal'); - -const props = { - onClose: jest.fn().mockName('onClose'), - intl: { - formatMessage: (message) => message.defaultMessage, - }, +const mockStore = async ({ + apiStatus, + enabled, +}) => { + const settings = ['forceOnFlexiblePeerOpenassessments']; + const fetchCourseAppsUrl = `${getCourseAppsApiUrl()}/${courseId}`; + const fetchAdvancedSettingsUrl = `${getCourseAdvancedSettingsApiUrl()}/${courseId}`; + + axiosMock.onGet(fetchCourseAppsUrl).reply( + 200, + [{ + allowed_operations: { enable: false, configure: true }, + description: 'setting', + documentation_links: { learnMoreConfiguration: '' }, + enabled, + id: 'ora_settings', + name: 'Flexible Peer Grading for ORAs', + }], + ); + axiosMock.onGet(fetchAdvancedSettingsUrl).reply( + apiStatus, + { force_on_flexible_peer_openassessments: { value: enabled } }, + ); + + await executeThunk(fetchCourseApps(courseId), store.dispatch); + await executeThunk(fetchCourseAppSettings(courseId, settings), store.dispatch); }; describe('ORASettings', () => { - it('should render', () => { - const wrapper = shallow(); - expect(wrapper.snapshot).toMatchSnapshot(); + beforeEach(async () => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: false, + roles: [], + }, + }); + store = initializeStore(inititalState); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + }); + + it('Flexible peer grading configuration modal is visible', async () => { + renderComponent(); + expect(screen.getByRole('dialog')).toBeVisible(); + }); + + it('Displays "Configure Flexible Peer Grading" heading', async () => { + renderComponent(); + const headingElement = screen.getByText(messages.heading.defaultMessage); + + expect(headingElement).toBeVisible(); + }); + + it('Displays loading component', () => { + renderComponent(); + const loadingElement = screen.getByRole('status'); + + expect(within(loadingElement).getByText('Loading...')).toBeInTheDocument(); + }); + + it('Displays Connection Error Alert', async () => { + await mockStore({ apiStatus: 404, enabled: true }); + renderComponent(); + const errorAlert = screen.getByRole('alert'); + + expect(within(errorAlert).getByText('We encountered a technical error when loading this page.', { exact: false })).toBeVisible(); + }); + + it('Displays Permissions Error Alert', async () => { + await mockStore({ apiStatus: 403, enabled: true }); + renderComponent(); + const errorAlert = screen.getByRole('alert'); + + expect(within(errorAlert).getByText('You are not authorized to view this page', { exact: false })).toBeVisible(); + }); + + it('Displays title, helper text and badge when flexible peer grading button is enabled', async () => { + renderComponent(); + await mockStore({ apiStatus: 200, enabled: true }); + + waitFor(() => { + const label = screen.getByText(messages.enableFlexPeerGradeLabel.defaultMessage); + const enableBadge = screen.getByTestId('enable-badge'); + + expect(label).toBeVisible(); + + expect(enableBadge).toHaveTextContent('Enabled'); + }); + }); + + it('Displays title, helper text and hides badge when flexible peer grading button is disabled', async () => { + renderComponent(); + await mockStore({ apiStatus: 200, enabled: false }); + + const label = screen.getByText(messages.enableFlexPeerGradeLabel.defaultMessage); + const enableBadge = screen.queryByTestId('enable-badge'); + + expect(label).toBeVisible(); + + expect(enableBadge).toBeNull(); }); }); diff --git a/plugins/course-apps/ora_settings/__snapshots__/Settings.test.jsx.snap b/plugins/course-apps/ora_settings/__snapshots__/Settings.test.jsx.snap deleted file mode 100644 index 676fae11a9..0000000000 --- a/plugins/course-apps/ora_settings/__snapshots__/Settings.test.jsx.snap +++ /dev/null @@ -1,47 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`ORASettings should render 1`] = ` - -

- Configure open response assessment -

-
- - Learn more about open response assessment settings - -
- - } - validationSchema={ - { - "enableFlexiblePeerGrade": "Yub.boolean", - } - } -> - [Function] -
-`; diff --git a/plugins/course-apps/ora_settings/factories/mockData.js b/plugins/course-apps/ora_settings/factories/mockData.js new file mode 100644 index 0000000000..a86ccc6a13 --- /dev/null +++ b/plugins/course-apps/ora_settings/factories/mockData.js @@ -0,0 +1,32 @@ +export const courseId = 'course-v1:org+num+run'; + +export const inititalState = { + courseDetail: { + courseId, + status: 'successful', + }, + pagesAndResources: { + courseAppIds: ['ora_settings'], + loadingStatus: 'in-progress', + savingStatus: '', + courseAppsApiStatus: {}, + courseAppSettings: {}, + }, + models: { + courseApps: { + ora_settings: { + id: 'ora_settings', + name: 'Flexible Peer Grading', + enabled: true, + description: 'Enable flexible peer grading', + allowedOperations: { + enable: false, + configure: true, + }, + documentationLinks: { + learnMoreConfiguration: '', + }, + }, + }, + }, +}; diff --git a/plugins/course-apps/ora_settings/messages.js b/plugins/course-apps/ora_settings/messages.js index 7b05afa5d4..3b119b5660 100644 --- a/plugins/course-apps/ora_settings/messages.js +++ b/plugins/course-apps/ora_settings/messages.js @@ -3,19 +3,51 @@ import { defineMessages } from '@edx/frontend-platform/i18n'; const messages = defineMessages({ heading: { id: 'course-authoring.pages-resources.ora.heading', - defaultMessage: 'Configure open response assessment', + defaultMessage: 'Configure Flexible Peer Grading', + description: 'Title for the modal dialog header', }, ORASettingsHelpLink: { id: 'course-authoring.pages-resources.ora.flex-peer-grading.link', defaultMessage: 'Learn more about open response assessment settings', + description: 'Descriptive text for the hyperlink to the docs site', }, enableFlexPeerGradeLabel: { id: 'course-authoring.pages-resources.ora.flex-peer-grading.label', defaultMessage: 'Flex Peer Grading', + description: 'Label for form switch', }, enableFlexPeerGradeHelp: { id: 'course-authoring.pages-resources.ora.flex-peer-grading.help', defaultMessage: 'Turn on Flexible Peer Grading for all open response assessments in the course with peer grading.', + description: 'Help text describing what happens when the switch is enabled', + }, + enabledBadgeLabel: { + id: 'course-authoring.pages-resources.ora.flex-peer-grading.enabled-badge.label', + defaultMessage: 'Enabled', + description: 'Label for badge that show users that a setting is enabled', + }, + cancelLabel: { + id: 'course-authoring.pages-resources.ora.flex-peer-grading.cancel-button.label', + defaultMessage: 'Cancel', + description: 'Label for button that cancels user changes', + }, + saveLabel: { + id: 'course-authoring.pages-resources.ora.flex-peer-grading.save-button.label', + defaultMessage: 'Save', + description: 'Label for button that saves user changes', + }, + pendingSaveLabel: { + id: 'course-authoring.pages-resources.ora.flex-peer-grading.pending-save-button.label', + defaultMessage: 'Saving', + description: 'Label for button that has pending api save calls', + }, + errorSavingTitle: { + id: 'course-authoring.pages-resources.ora.flex-peer-grading.save-error.title', + defaultMessage: 'We couldn\'t apply your changes.', + }, + errorSavingMessage: { + id: 'course-authoring.pages-resources.ora.flex-peer-grading.save-error.message', + defaultMessage: 'Please check your entries and try again.', }, }); diff --git a/plugins/course-apps/ora_settings/package.json b/plugins/course-apps/ora_settings/package.json index d6de338820..3f93dcb722 100644 --- a/plugins/course-apps/ora_settings/package.json +++ b/plugins/course-apps/ora_settings/package.json @@ -3,15 +3,16 @@ "version": "0.1.0", "description": "Open Response Assessment configuration for courses using it", "peerDependencies": { - "@edx/frontend-app-course-authoring": "*", + "@edx/frontend-app-authoring": "*", "@edx/frontend-platform": "*", "@openedx/paragon": "*", "prop-types": "*", "react": "*", + "react-redux": "*", "yup": "*" }, "peerDependenciesMeta": { - "@edx/frontend-app-course-authoring": { + "@edx/frontend-app-authoring": { "optional": true } } diff --git a/plugins/course-apps/proctoring/Settings.jsx b/plugins/course-apps/proctoring/Settings.jsx index 4debbcfd74..c4bcec80a9 100644 --- a/plugins/course-apps/proctoring/Settings.jsx +++ b/plugins/course-apps/proctoring/Settings.jsx @@ -148,9 +148,9 @@ const ProctoringSettings = ({ intl, onClose }) => { setSaveSuccess(true); setSaveError(false); setSubmissionInProgress(false); - }).catch(() => { + }).catch((error) => { setSaveSuccess(false); - setSaveError(true); + setSaveError(error); setSubmissionInProgress(false); }); } @@ -460,21 +460,32 @@ const ProctoringSettings = ({ intl, onClose }) => { } function renderSaveError() { - return ( - setSaveError(false)} - dismissible - > + let errorMessage = ( + + {intl.formatMessage(messages['authoring.proctoring.support.text'])} + + ), + }} + /> + ); + + if (saveError?.response.status === 403) { + errorMessage = ( { ), }} /> + ); + } + + return ( + setSaveError(false)} + dismissible + > + {errorMessage} ); } diff --git a/plugins/course-apps/proctoring/Settings.test.jsx b/plugins/course-apps/proctoring/Settings.test.jsx index e0e700edc9..4edb7f1c28 100644 --- a/plugins/course-apps/proctoring/Settings.test.jsx +++ b/plugins/course-apps/proctoring/Settings.test.jsx @@ -814,6 +814,24 @@ describe('ProctoredExamSettings', () => { }); }); + test('Exams API permission error', async () => { + axiosMock.onPatch( + `${ExamsApiService.getExamsBaseUrl()}/api/v1/configs/course_id/${defaultProps.courseId}`, + ).reply(403, 'error'); + + await act(async () => render(intlWrapper())); + const submitButton = screen.getByTestId('submissionButton'); + fireEvent.click(submitButton); + expect(axiosMock.history.post.length).toBe(1); + await waitFor(() => { + const errorAlert = screen.getByTestId('saveError'); + expect(errorAlert.textContent).toEqual( + expect.stringContaining('You do not have permission to edit proctored exam settings for this course'), + ); + expect(document.activeElement).toEqual(errorAlert); + }); + }); + it('Manages focus correctly after different save statuses', async () => { // first make a call that will cause a save error axiosMock.onPost( diff --git a/plugins/course-apps/proctoring/messages.js b/plugins/course-apps/proctoring/messages.js index 5c9d459fa7..82b147fc03 100644 --- a/plugins/course-apps/proctoring/messages.js +++ b/plugins/course-apps/proctoring/messages.js @@ -1,6 +1,16 @@ import { defineMessages } from '@edx/frontend-platform/i18n'; const messages = defineMessages({ + 'authoring.proctoring.alert.error': { + id: 'authoring.proctoring.alert.error', + defaultMessage: 'We encountered a technical error while trying to save proctored exam settings. This might be a temporary issue, so please try again in a few minutes. If the problem persists, please go to the {support_link} for help.', + description: 'Alert message for proctoring settings save error.', + }, + 'authoring.proctoring.alert.forbidden': { + id: 'authoring.proctoring.alert.forbidden', + defaultMessage: 'You do not have permission to edit proctored exam settings for this course. If you are a course team member and this problem persists, please go to the {support_link} for help.', + description: 'Alert message for proctoring settings permission error.', + }, 'authoring.proctoring.no': { id: 'authoring.proctoring.no', defaultMessage: 'No', diff --git a/plugins/course-apps/proctoring/package.json b/plugins/course-apps/proctoring/package.json index 82801ab695..55e973ed20 100644 --- a/plugins/course-apps/proctoring/package.json +++ b/plugins/course-apps/proctoring/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "description": "Proctoring configuration for courses using it", "peerDependencies": { - "@edx/frontend-app-course-authoring": "*", + "@edx/frontend-app-authoring": "*", "@edx/frontend-platform": "*", "@openedx/paragon": "*", "classnames": "*", @@ -13,7 +13,7 @@ "moment": "*" }, "peerDependenciesMeta": { - "@edx/frontend-app-course-authoring": { + "@edx/frontend-app-authoring": { "optional": true } } diff --git a/plugins/course-apps/progress/package.json b/plugins/course-apps/progress/package.json index 1541af3903..678de578b9 100644 --- a/plugins/course-apps/progress/package.json +++ b/plugins/course-apps/progress/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "description": "Progress configuration for courses using it", "peerDependencies": { - "@edx/frontend-app-course-authoring": "*", + "@edx/frontend-app-authoring": "*", "@edx/frontend-platform": "*", "@openedx/paragon": "*", "prop-types": "*", @@ -11,7 +11,7 @@ "yup": "*" }, "peerDependenciesMeta": { - "@edx/frontend-app-course-authoring": { + "@edx/frontend-app-authoring": { "optional": true } } diff --git a/plugins/course-apps/teams/package.json b/plugins/course-apps/teams/package.json index 64471e694f..9b377a1763 100644 --- a/plugins/course-apps/teams/package.json +++ b/plugins/course-apps/teams/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "description": "Teams configuration for courses using it", "peerDependencies": { - "@edx/frontend-app-course-authoring": "*", + "@edx/frontend-app-authoring": "*", "@edx/frontend-platform": "*", "@openedx/paragon": "*", "formik": "*", @@ -13,7 +13,7 @@ "yup": "*" }, "peerDependenciesMeta": { - "@edx/frontend-app-course-authoring": { + "@edx/frontend-app-authoring": { "optional": true } } diff --git a/plugins/course-apps/wiki/package.json b/plugins/course-apps/wiki/package.json index e14e897db3..b7d92a69bb 100644 --- a/plugins/course-apps/wiki/package.json +++ b/plugins/course-apps/wiki/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "description": "Wiki configuration for courses using it", "peerDependencies": { - "@edx/frontend-app-course-authoring": "*", + "@edx/frontend-app-authoring": "*", "@edx/frontend-platform": "*", "@openedx/paragon": "*", "prop-types": "*", @@ -11,7 +11,7 @@ "yup": "*" }, "peerDependenciesMeta": { - "@edx/frontend-app-course-authoring": { + "@edx/frontend-app-authoring": { "optional": true } } diff --git a/plugins/course-apps/xpert_unit_summary/package.json b/plugins/course-apps/xpert_unit_summary/package.json index e8850ecdfa..26c0c7d5b9 100644 --- a/plugins/course-apps/xpert_unit_summary/package.json +++ b/plugins/course-apps/xpert_unit_summary/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "description": "Xpert Unit Summaries configuration for courses using it", "peerDependencies": { - "@edx/frontend-app-course-authoring": "*", + "@edx/frontend-app-authoring": "*", "@edx/frontend-platform": "*", "@openedx/paragon": "*", "formik": "*", @@ -14,7 +14,7 @@ "react-router-dom": "*" }, "peerDependenciesMeta": { - "@edx/frontend-app-course-authoring": { + "@edx/frontend-app-authoring": { "optional": true } } diff --git a/src/CourseAuthoringPage.jsx b/src/CourseAuthoringPage.jsx index 5d3d5e37b3..41da0bc232 100644 --- a/src/CourseAuthoringPage.jsx +++ b/src/CourseAuthoringPage.jsx @@ -11,6 +11,7 @@ import { fetchCourseDetail } from './data/thunks'; import { useModel } from './generic/model-store'; import NotFoundAlert from './generic/NotFoundAlert'; import PermissionDeniedAlert from './generic/PermissionDeniedAlert'; +import { fetchStudioHomeData } from './studio-home/data/thunks'; import { getCourseAppsApiStatus } from './pages-and-resources/data/selectors'; import { RequestStatus } from './data/constants'; import Loading from './generic/Loading'; @@ -22,6 +23,10 @@ const CourseAuthoringPage = ({ courseId, children }) => { dispatch(fetchCourseDetail(courseId)); }, [courseId]); + useEffect(() => { + dispatch(fetchStudioHomeData()); + }, []); + const courseDetail = useModel('courseDetails', courseId); const courseNumber = courseDetail ? courseDetail.number : null; diff --git a/src/CourseAuthoringRoutes.jsx b/src/CourseAuthoringRoutes.jsx index 51599317e6..0c9d2a1680 100644 --- a/src/CourseAuthoringRoutes.jsx +++ b/src/CourseAuthoringRoutes.jsx @@ -88,7 +88,7 @@ const CourseAuthoringRoutes = () => { /> } + element={} />

{intl.formatMessage(messages.collapsibleNoTagsAddedText)} - + {canTagObject && ( + + )}

)} @@ -418,7 +420,7 @@ const ContentTagsCollapsible = ({ )}
- {isEditMode && canTagObject && ( + {isEditMode && ( handleLetterChange(e, idx)} - disabled={idx === gradingSegments.length} - /> - )} - {gradingSegments.length > 2 && ( - handleLetterChange(e, idx)} - disabled={idx === gradingSegments.length} - /> - )} - - {gradingSegments[idx === 0 ? 0 : idx - 1]?.previous} - {value === 100 ? value : value - 1} - -
- {idx !== gradingSegments.length && idx - 1 !== 0 && ( - - )} - -); - -GradingScaleSegment.propTypes = { - intl: intlShape.isRequired, - idx: PropTypes.number.isRequired, - value: PropTypes.number.isRequired, - getSegmentProps: PropTypes.func.isRequired, - handleLetterChange: PropTypes.func.isRequired, - removeGradingSegment: PropTypes.func.isRequired, - gradingSegments: PropTypes.arrayOf( - PropTypes.shape({ - current: PropTypes.number.isRequired, - previous: PropTypes.number.isRequired, - }), - ).isRequired, - letters: PropTypes.arrayOf(PropTypes.string).isRequired, -}; - -export default injectIntl(GradingScaleSegment); diff --git a/src/grading-settings/grading-scale/components/GradingScaleSegment.tsx b/src/grading-settings/grading-scale/components/GradingScaleSegment.tsx new file mode 100644 index 0000000000..9cc3e059d1 --- /dev/null +++ b/src/grading-settings/grading-scale/components/GradingScaleSegment.tsx @@ -0,0 +1,86 @@ +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Button } from '@openedx/paragon'; +import React, { ChangeEvent } from 'react'; +import messages from '../messages'; + +import { getLettersOnLongScale, getLettersOnShortScale } from '../utils'; + +interface RangeSegment { + previous: number, + current: number, +} + +interface GradingScaleSegmentProps { + idx: number, + value: number, + getSegmentProps: () => { [key: string]: string }, + handleLetterChange: (event: ChangeEvent, idx: number) => void, + letters: [string], + gradingSegments: RangeSegment[], + removeGradingSegment: (idx: number) => void, +} + +const GradingScaleSegment = ({ + idx, + value, + getSegmentProps, + handleLetterChange, + letters, + gradingSegments, + removeGradingSegment, +}: GradingScaleSegmentProps) => { + const intl = useIntl(); + const prevValue = gradingSegments[idx === 0 ? 0 : idx - 1]?.previous ?? 0; + const segmentRightMargin = (value - prevValue) < 6 ? '0.125rem' : '1.25rem'; + return ( +
+
+ {gradingSegments.length === 2 && ( + handleLetterChange(e, idx)} + disabled={idx === gradingSegments.length} + /> + )} + {gradingSegments.length > 2 && ( + handleLetterChange(e, idx)} + disabled={idx === gradingSegments.length} + /> + )} + + {gradingSegments[idx === 0 ? 0 : idx - 1]?.previous} - {value === 100 ? value : value - 1} + +
+ {idx !== gradingSegments.length && idx - 1 !== 0 && ( + + )} +
+ ); +}; + +export default GradingScaleSegment; diff --git a/src/header/Header.tsx b/src/header/Header.tsx index 42dc5f0469..80991c78d5 100644 --- a/src/header/Header.tsx +++ b/src/header/Header.tsx @@ -1,15 +1,16 @@ -/* eslint-disable react/require-default-props */ import React from 'react'; import { getConfig } from '@edx/frontend-platform'; import { useIntl } from '@edx/frontend-platform/i18n'; import { StudioHeader } from '@edx/frontend-component-header'; -import { useToggle } from '@openedx/paragon'; +import { type Container, useToggle } from '@openedx/paragon'; import { generatePath, useHref } from 'react-router-dom'; import { SearchModal } from '../search-modal'; -import { getContentMenuItems, getSettingMenuItems, getToolsMenuItems } from './utils'; +import { useContentMenuItems, useSettingMenuItems, useToolsMenuItems } from './hooks'; import messages from './messages'; +type ContainerPropsType = React.ComponentProps; + interface HeaderProps { contextId?: string, number?: string, @@ -17,6 +18,7 @@ interface HeaderProps { title?: string, isHiddenMainMenu?: boolean, isLibrary?: boolean, + containerProps?: ContainerPropsType, } const Header = ({ @@ -26,6 +28,7 @@ const Header = ({ title = '', isHiddenMainMenu = false, isLibrary = false, + containerProps = {}, }: HeaderProps) => { const intl = useIntl(); const libraryHref = useHref('/library/:libraryId'); @@ -34,23 +37,28 @@ const Header = ({ const studioBaseUrl = getConfig().STUDIO_BASE_URL; const meiliSearchEnabled = [true, 'true'].includes(getConfig().MEILISEARCH_ENABLED); + + const contentMenuItems = useContentMenuItems(contextId); + const settingMenuItems = useSettingMenuItems(contextId); + const toolsMenuItems = useToolsMenuItems(contextId); const mainMenuDropdowns = !isLibrary ? [ { id: `${intl.formatMessage(messages['header.links.content'])}-dropdown-menu`, buttonTitle: intl.formatMessage(messages['header.links.content']), - items: getContentMenuItems({ studioBaseUrl, courseId: contextId, intl }), + items: contentMenuItems, }, { id: `${intl.formatMessage(messages['header.links.settings'])}-dropdown-menu`, buttonTitle: intl.formatMessage(messages['header.links.settings']), - items: getSettingMenuItems({ studioBaseUrl, courseId: contextId, intl }), + items: settingMenuItems, }, { id: `${intl.formatMessage(messages['header.links.tools'])}-dropdown-menu`, buttonTitle: intl.formatMessage(messages['header.links.tools']), - items: getToolsMenuItems({ studioBaseUrl, courseId: contextId, intl }), + items: toolsMenuItems, }, ] : []; + const outlineLink = !isLibrary ? `${studioBaseUrl}/course/${contextId}` : generatePath(libraryHref, { libraryId: contextId }); @@ -65,8 +73,9 @@ const Header = ({ mainMenuDropdowns={mainMenuDropdowns} outlineLink={outlineLink} searchButtonAction={meiliSearchEnabled ? openSearchModal : undefined} + containerProps={containerProps} /> - { meiliSearchEnabled && ( + {meiliSearchEnabled && ( { +export const useContentMenuItems = courseId => { + const intl = useIntl(); + const studioBaseUrl = getConfig().STUDIO_BASE_URL; + const items = [ { href: `${studioBaseUrl}/course/${courseId}`, @@ -31,7 +37,11 @@ export const getContentMenuItems = ({ studioBaseUrl, courseId, intl }) => { return items; }; -export const getSettingMenuItems = ({ studioBaseUrl, courseId, intl }) => { +export const useSettingMenuItems = courseId => { + const intl = useIntl(); + const studioBaseUrl = getConfig().STUDIO_BASE_URL; + const { canAccessAdvancedSettings } = useSelector(getStudioHomeData); + const items = [ { href: `${studioBaseUrl}/settings/details/${courseId}`, @@ -49,10 +59,12 @@ export const getSettingMenuItems = ({ studioBaseUrl, courseId, intl }) => { href: `${studioBaseUrl}/group_configurations/${courseId}`, title: intl.formatMessage(messages['header.links.groupConfigurations']), }, - { - href: `${studioBaseUrl}/settings/advanced/${courseId}`, - title: intl.formatMessage(messages['header.links.advancedSettings']), - }, + ...(canAccessAdvancedSettings === true + ? [{ + href: `${studioBaseUrl}/settings/advanced/${courseId}`, + title: intl.formatMessage(messages['header.links.advancedSettings']), + }] : [] + ), ]; if (getConfig().ENABLE_CERTIFICATE_PAGE === 'true') { items.push({ @@ -63,23 +75,29 @@ export const getSettingMenuItems = ({ studioBaseUrl, courseId, intl }) => { return items; }; -export const getToolsMenuItems = ({ studioBaseUrl, courseId, intl }) => ([ - { - href: `${studioBaseUrl}/import/${courseId}`, - title: intl.formatMessage(messages['header.links.import']), - }, - { - href: `${studioBaseUrl}/export/${courseId}`, - title: intl.formatMessage(messages['header.links.exportCourse']), - }, - ...(getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true' - ? [{ - href: '#export-tags', - title: intl.formatMessage(messages['header.links.exportTags']), - }] : [] - ), - { - href: `${studioBaseUrl}/checklists/${courseId}`, - title: intl.formatMessage(messages['header.links.checklists']), - }, -]); +export const useToolsMenuItems = courseId => { + const intl = useIntl(); + const studioBaseUrl = getConfig().STUDIO_BASE_URL; + + const items = [ + { + href: `${studioBaseUrl}/import/${courseId}`, + title: intl.formatMessage(messages['header.links.import']), + }, + { + href: `${studioBaseUrl}/export/${courseId}`, + title: intl.formatMessage(messages['header.links.exportCourse']), + }, + ...(getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true' + ? [{ + href: `${studioBaseUrl}/course/${courseId}#export-tags`, + title: intl.formatMessage(messages['header.links.exportTags']), + }] : [] + ), + { + href: `${studioBaseUrl}/checklists/${courseId}`, + title: intl.formatMessage(messages['header.links.checklists']), + }, + ]; + return items; +}; diff --git a/src/header/utils.test.js b/src/header/hooks.test.js similarity index 50% rename from src/header/utils.test.js rename to src/header/hooks.test.js index f2c2f3acb5..9b9f1cbae2 100644 --- a/src/header/utils.test.js +++ b/src/header/hooks.test.js @@ -1,13 +1,19 @@ +import { useSelector } from 'react-redux'; import { getConfig, setConfig } from '@edx/frontend-platform'; -import { getContentMenuItems, getToolsMenuItems, getSettingMenuItems } from './utils'; +import { renderHook } from '@testing-library/react-hooks'; +import { useContentMenuItems, useToolsMenuItems, useSettingMenuItems } from './hooks'; -const props = { - studioBaseUrl: 'UrLSTuiO', - courseId: '123', - intl: { +jest.mock('@edx/frontend-platform/i18n', () => ({ + ...jest.requireActual('@edx/frontend-platform/i18n'), + useIntl: () => ({ formatMessage: jest.fn(message => message.defaultMessage), - }, -}; + }), +})); + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(), +})); describe('header utils', () => { describe('getContentMenuItems', () => { @@ -16,7 +22,7 @@ describe('header utils', () => { ...getConfig(), ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN: 'true', }); - const actualItems = getContentMenuItems(props); + const actualItems = renderHook(() => useContentMenuItems('course-123')).result.current; expect(actualItems).toHaveLength(5); }); it('should not include Video Uploads option', () => { @@ -24,18 +30,20 @@ describe('header utils', () => { ...getConfig(), ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN: 'false', }); - const actualItems = getContentMenuItems(props); + const actualItems = renderHook(() => useContentMenuItems('course-123')).result.current; expect(actualItems).toHaveLength(4); }); }); describe('getSettingsMenuitems', () => { + useSelector.mockReturnValue({ canAccessAdvancedSettings: true }); + it('should include certificates option', () => { setConfig({ ...getConfig(), ENABLE_CERTIFICATE_PAGE: 'true', }); - const actualItems = getSettingMenuItems(props); + const actualItems = renderHook(() => useSettingMenuItems('course-123')).result.current; expect(actualItems).toHaveLength(6); }); it('should not include certificates option', () => { @@ -43,9 +51,18 @@ describe('header utils', () => { ...getConfig(), ENABLE_CERTIFICATE_PAGE: 'false', }); - const actualItems = getSettingMenuItems(props); + const actualItems = renderHook(() => useSettingMenuItems('course-123')).result.current; expect(actualItems).toHaveLength(5); }); + it('should include advanced settings option', () => { + const actualItemsTitle = renderHook(() => useSettingMenuItems('course-123')).result.current.map((item) => item.title); + expect(actualItemsTitle).toContain('Advanced Settings'); + }); + it('should not include advanced settings option', () => { + useSelector.mockReturnValue({ canAccessAdvancedSettings: false }); + const actualItemsTitle = renderHook(() => useSettingMenuItems('course-123')).result.current.map((item) => item.title); + expect(actualItemsTitle).not.toContain('Advanced Settings'); + }); }); describe('getToolsMenuItems', () => { @@ -54,7 +71,7 @@ describe('header utils', () => { ...getConfig(), ENABLE_TAGGING_TAXONOMY_PAGES: 'true', }); - const actualItemsTitle = getToolsMenuItems(props).map((item) => item.title); + const actualItemsTitle = renderHook(() => useToolsMenuItems('course-123')).result.current.map((item) => item.title); expect(actualItemsTitle).toEqual([ 'Import', 'Export Course', @@ -67,7 +84,7 @@ describe('header utils', () => { ...getConfig(), ENABLE_TAGGING_TAXONOMY_PAGES: 'false', }); - const actualItemsTitle = getToolsMenuItems(props).map((item) => item.title); + const actualItemsTitle = renderHook(() => useToolsMenuItems('course-123')).result.current.map((item) => item.title); expect(actualItemsTitle).toEqual([ 'Import', 'Export Course', diff --git a/src/hooks.js b/src/hooks.js deleted file mode 100644 index 73597e3ef6..0000000000 --- a/src/hooks.js +++ /dev/null @@ -1,37 +0,0 @@ -import { useEffect, useState } from 'react'; -import { history } from '@edx/frontend-platform'; - -export const useScrollToHashElement = ({ isLoading }) => { - const [elementWithHash, setElementWithHash] = useState(null); - - useEffect(() => { - const currentHash = window.location.hash.substring(1); - - if (currentHash) { - const element = document.getElementById(currentHash); - if (element) { - element.scrollIntoView(); - history.replace({ hash: '' }); - } - setElementWithHash(currentHash); - } - }, [isLoading]); - - return { elementWithHash }; -}; - -export const useEscapeClick = ({ onEscape, dependency }) => { - useEffect(() => { - const handleEscapeClick = (event) => { - if (event.key === 'Escape') { - onEscape(); - } - }; - - window.addEventListener('keydown', handleEscapeClick); - - return () => { - window.removeEventListener('keydown', handleEscapeClick); - }; - }, [dependency]); -}; diff --git a/src/hooks.ts b/src/hooks.ts new file mode 100644 index 0000000000..87b94a4f4d --- /dev/null +++ b/src/hooks.ts @@ -0,0 +1,68 @@ +import { useEffect, useState } from 'react'; +import { history } from '@edx/frontend-platform'; + +export const useScrollToHashElement = ({ isLoading }: { isLoading: boolean }) => { + const [elementWithHash, setElementWithHash] = useState(null); + + useEffect(() => { + const currentHash = window.location.hash.substring(1); + + if (currentHash) { + const element = document.getElementById(currentHash); + if (element) { + element.scrollIntoView(); + history.replace({ hash: '' }); + } + setElementWithHash(currentHash); + } + }, [isLoading]); + + return { elementWithHash }; +}; + +export const useEscapeClick = ({ onEscape, dependency }: { onEscape: () => void, dependency: any }) => { + useEffect(() => { + const handleEscapeClick = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onEscape(); + } + }; + + window.addEventListener('keydown', handleEscapeClick); + + return () => { + window.removeEventListener('keydown', handleEscapeClick); + }; + }, [dependency]); +}; + +/** + * Hook which loads next page of items on scroll + */ +export const useLoadOnScroll = ( + hasNextPage: boolean | undefined, + isFetchingNextPage: boolean, + fetchNextPage: () => void, + enabled: boolean, +) => { + useEffect(() => { + if (enabled) { + const onscroll = () => { + // Verify the position of the scroll to implement an infinite scroll. + // Used `loadLimit` to fetch next page before reach the end of the screen. + const loadLimit = 300; + const scrolledTo = window.scrollY + window.innerHeight; + const scrollDiff = document.body.scrollHeight - scrolledTo; + const isNearToBottom = scrollDiff <= loadLimit; + if (isNearToBottom && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }; + window.addEventListener('scroll', onscroll); + return () => { + window.removeEventListener('scroll', onscroll); + }; + } + return () => { }; + }, [hasNextPage, isFetchingNextPage, fetchNextPage]); +}; diff --git a/src/index.jsx b/src/index.jsx index 675e927097..34f27f1b9a 100755 --- a/src/index.jsx +++ b/src/index.jsx @@ -16,7 +16,12 @@ import { initializeHotjar } from '@edx/frontend-enterprise-hotjar'; import { logError } from '@edx/frontend-platform/logging'; import messages from './i18n'; -import { CreateLibrary, LibraryLayout } from './library-authoring'; +import { + ComponentPicker, + CreateLibrary, + LibraryLayout, + PreviewChangesEmbed, +} from './library-authoring'; import initializeStore from './store'; import CourseAuthoringRoutes from './CourseAuthoringRoutes'; import Head from './head/Head'; @@ -55,6 +60,9 @@ const App = () => { } /> } /> } /> + } /> + } /> + } /> } /> } /> {getConfig().ENABLE_ACCESSIBILITY_PAGE === 'true' && ( @@ -111,6 +119,7 @@ initialize({ SUPPORT_URL: process.env.SUPPORT_URL || null, SUPPORT_EMAIL: process.env.SUPPORT_EMAIL || null, LEARNING_BASE_URL: process.env.LEARNING_BASE_URL, + LMS_BASE_URL: process.env.LMS_BASE_URL || null, EXAMS_BASE_URL: process.env.EXAMS_BASE_URL || null, CALCULATOR_HELP_URL: process.env.CALCULATOR_HELP_URL || null, ENABLE_PROGRESS_GRAPH_SETTINGS: process.env.ENABLE_PROGRESS_GRAPH_SETTINGS || 'false', @@ -131,7 +140,7 @@ initialize({ ENABLE_HOME_PAGE_COURSE_API_V2: process.env.ENABLE_HOME_PAGE_COURSE_API_V2 === 'true', ENABLE_CHECKLIST_QUALITY: process.env.ENABLE_CHECKLIST_QUALITY || 'true', ENABLE_GRADING_METHOD_IN_PROBLEMS: process.env.ENABLE_GRADING_METHOD_IN_PROBLEMS === 'true', - LIBRARY_MODE: process.env.LIBRARY_MODE || 'v1 only', + LIBRARY_SUPPORTED_BLOCKS: (process.env.LIBRARY_SUPPORTED_BLOCKS || 'problem,video,html').split(','), }, 'CourseAuthoringConfig'); }, }, diff --git a/src/index.scss b/src/index.scss index 764489d1d4..69f9b8b34f 100644 --- a/src/index.scss +++ b/src/index.scss @@ -61,3 +61,7 @@ body { background-color: $light-100; } } + +mark { + padding: 0; +} diff --git a/src/library-authoring/EmptyStates.tsx b/src/library-authoring/EmptyStates.tsx index e8e03cb4a6..71297926de 100644 --- a/src/library-authoring/EmptyStates.tsx +++ b/src/library-authoring/EmptyStates.tsx @@ -1,36 +1,43 @@ -import React, { useContext } from 'react'; -import { useParams } from 'react-router'; import { FormattedMessage } from '@edx/frontend-platform/i18n'; +import type { MessageDescriptor } from 'react-intl'; import { Button, Stack, } from '@openedx/paragon'; import { Add } from '@openedx/paragon/icons'; import { ClearFiltersButton } from '../search-manager'; import messages from './messages'; -import { LibraryContext } from './common/context'; -import { useContentLibrary } from './data/apiHooks'; +import { useLibraryContext } from './common/context'; -export const NoComponents = () => { - const { openAddContentSidebar } = useContext(LibraryContext); - const { libraryId } = useParams(); - const { data: libraryData } = useContentLibrary(libraryId); - const canEditLibrary = libraryData?.canEditLibrary ?? false; +export const NoComponents = ({ + infoText = messages.noComponents, + addBtnText = messages.addComponent, + handleBtnClick, +}: { + infoText?: MessageDescriptor; + addBtnText?: MessageDescriptor; + handleBtnClick: () => void; +}) => { + const { readOnly } = useLibraryContext(); return ( - - {canEditLibrary && ( - )} ); }; -export const NoSearchResults = () => ( - - +export const NoSearchResults = ({ + infoText = messages.noSearchResults, +}: { + infoText?: MessageDescriptor; +}) => ( + + ); diff --git a/src/library-authoring/LibraryAuthoringPage.scss b/src/library-authoring/LibraryAuthoringPage.scss index eaf87428b8..b4c08e02b3 100644 --- a/src/library-authoring/LibraryAuthoringPage.scss +++ b/src/library-authoring/LibraryAuthoringPage.scss @@ -6,12 +6,26 @@ .open-border { border: 2px solid; + margin: -1px 0; } } } .library-authoring-sidebar { - min-width: 300px; - max-width: map-get($grid-breakpoints, "sm"); - z-index: 1001; // to appear over header + z-index: 1000; // same as header + flex: 450px 0 0; + position: sticky; + top: 0; + right: 0; + height: 100vh; + overflow-y: auto; +} + +.dropdown-menu { + z-index: 1001; // over the sidebar +} + +// Reduce breadcrumb bottom margin +ol.list-inline { + margin-bottom: 0; } diff --git a/src/library-authoring/LibraryAuthoringPage.test.tsx b/src/library-authoring/LibraryAuthoringPage.test.tsx index f7b6544355..84e50c70fc 100644 --- a/src/library-authoring/LibraryAuthoringPage.test.tsx +++ b/src/library-authoring/LibraryAuthoringPage.test.tsx @@ -1,44 +1,36 @@ -import React from 'react'; -import MockAdapter from 'axios-mock-adapter'; -import { initializeMockApp } from '@edx/frontend-platform'; -import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; -import { IntlProvider } from '@edx/frontend-platform/i18n'; -import { AppProvider } from '@edx/frontend-platform/react'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { getConfig } from '@edx/frontend-platform'; +import fetchMock from 'fetch-mock-jest'; +import { Helmet } from 'react-helmet'; import { fireEvent, + initializeMocks, render, - waitFor, screen, + waitFor, within, -} from '@testing-library/react'; -import fetchMock from 'fetch-mock-jest'; -import initializeStore from '../store'; -import { getContentSearchConfigUrl } from '../search-manager/data/api'; -import mockResult from '../search-modal/__mocks__/search-result.json'; +} from '../testUtils'; +import mockResult from './__mocks__/library-search.json'; import mockEmptyResult from '../search-modal/__mocks__/empty-search-result.json'; -import { getContentLibraryApiUrl, getXBlockFieldsApiUrl, type ContentLibrary } from './data/api'; +import { + mockContentLibrary, + mockGetCollectionMetadata, + mockGetLibraryTeam, + mockXBlockFields, +} from './data/api.mocks'; +import { mockContentSearchConfig } from '../search-manager/data/api.mock'; +import { mockBroadcastChannel } from '../generic/data/api.mock'; import { LibraryLayout } from '.'; +import { getLibraryCollectionsApiUrl } from './data/api'; -let store; -const mockUseParams = jest.fn(); -let axiosMock; - -jest.mock('react-router-dom', () => ({ - ...jest.requireActual('react-router-dom'), // use actual for all non-hook parts - useParams: () => mockUseParams(), -})); +mockGetCollectionMetadata.applyMock(); +mockContentSearchConfig.applyMock(); +mockContentLibrary.applyMock(); +mockGetLibraryTeam.applyMock(); +mockXBlockFields.applyMock(); +mockBroadcastChannel(); const searchEndpoint = 'http://mock.meilisearch.local/multi-search'; -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - }, -}); - /** * Returns 0 components from the search query. */ @@ -48,9 +40,12 @@ const returnEmptyResult = (_url, req) => { // We have to replace the query (search keywords) in the mock results with the actual query, // because otherwise we may have an inconsistent state that causes more queries and unexpected results. mockEmptyResult.results[0].query = query; + mockEmptyResult.results[2].query = query; // And fake the required '_formatted' fields; it contains the highlighting ... around matched words // eslint-disable-next-line no-underscore-dangle, no-param-reassign mockEmptyResult.results[0]?.hits.forEach((hit) => { hit._formatted = { ...hit }; }); + // eslint-disable-next-line no-underscore-dangle, no-param-reassign + mockEmptyResult.results[2]?.hits.forEach((hit) => { hit._formatted = { ...hit }; }); return mockEmptyResult; }; @@ -68,273 +63,187 @@ const returnLowNumberResults = (_url, req) => { newMockResult.results[0].query = query; // Limit number of results to just 2 newMockResult.results[0].hits = mockResult.results[0]?.hits.slice(0, 2); + newMockResult.results[2].hits = mockResult.results[2]?.hits.slice(0, 2); newMockResult.results[0].estimatedTotalHits = 2; + newMockResult.results[2].estimatedTotalHits = 2; // And fake the required '_formatted' fields; it contains the highlighting ... around matched words // eslint-disable-next-line no-underscore-dangle, no-param-reassign newMockResult.results[0]?.hits.forEach((hit) => { hit._formatted = { ...hit }; }); + // eslint-disable-next-line no-underscore-dangle, no-param-reassign + newMockResult.results[2]?.hits.forEach((hit) => { hit._formatted = { ...hit }; }); return newMockResult; }; -const libraryData: ContentLibrary = { - id: 'lib:org1:lib1', - type: 'complex', - org: 'org1', - slug: 'lib1', - title: 'lib1', - description: 'lib1', - numBlocks: 2, - version: 0, - lastPublished: null, - lastDraftCreated: '2024-07-22', - publishedBy: 'staff', - lastDraftCreatedBy: 'staff', - allowLti: false, - allowPublicLearning: false, - allowPublicRead: false, - hasUnpublishedChanges: true, - hasUnpublishedDeletes: false, - canEditLibrary: true, - license: '', - created: '2024-06-26', - updated: '2024-07-20', -}; - -const xBlockFields = { - display_name: 'Test HTML Block', - metadata: { - display_name: 'Test HTML Block', - }, -}; - -const clipboardBroadcastChannelMock = { - postMessage: jest.fn(), - close: jest.fn(), -}; - -(global as any).BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock); - -const RootWrapper = () => ( - - - - - - - -); +const path = '/library/:libraryId/*'; +const libraryTitle = mockContentLibrary.libraryData.title; describe('', () => { beforeEach(() => { - initializeMockApp({ - authenticatedUser: { - userId: 3, - username: 'abc123', - administrator: true, - roles: [], - }, - }); - store = initializeStore(); - axiosMock = new MockAdapter(getAuthenticatedHttpClient()); - mockUseParams.mockReturnValue({ libraryId: '1' }); - - // The API method to get the Meilisearch connection details uses Axios: - axiosMock = new MockAdapter(getAuthenticatedHttpClient()); - axiosMock.onGet(getContentSearchConfigUrl()).reply(200, { - url: 'http://mock.meilisearch.local', - index_name: 'studio', - api_key: 'test-key', - }); + initializeMocks(); // The Meilisearch client-side API uses fetch, not Axios. + fetchMock.mockReset(); fetchMock.post(searchEndpoint, (_url, req) => { const requestData = JSON.parse(req.body?.toString() ?? ''); const query = requestData?.queries[0]?.q ?? ''; // We have to replace the query (search keywords) in the mock results with the actual query, // because otherwise Instantsearch will update the UI and change the query, // leading to unexpected results in the test cases. - mockResult.results[0].query = query; + const newMockResult = { ...mockResult }; + newMockResult.results[0].query = query; // And fake the required '_formatted' fields; it contains the highlighting ... around matched words // eslint-disable-next-line no-underscore-dangle, no-param-reassign - mockResult.results[0]?.hits.forEach((hit) => { hit._formatted = { ...hit }; }); - return mockResult; + newMockResult.results[0]?.hits.forEach((hit) => { hit._formatted = { ...hit }; }); + return newMockResult; }); }); - afterEach(() => { - jest.clearAllMocks(); - axiosMock.restore(); - fetchMock.mockReset(); - queryClient.clear(); - }); - const renderLibraryPage = async () => { - mockUseParams.mockReturnValue({ libraryId: libraryData.id }); - axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData); - - const result = render(); + render(, { path, params: { libraryId: mockContentLibrary.libraryId } }); // Ensure the search endpoint is called: // Call 1: To fetch searchable/filterable/sortable library data await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); }); - - return result; }; it('shows the spinner before the query is complete', () => { - mockUseParams.mockReturnValue({ libraryId: '1' }); - // @ts-ignore Use unresolved promise to keep the Loading visible - axiosMock.onGet(getContentLibraryApiUrl('1')).reply(() => new Promise()); - const { getByRole } = render(); - const spinner = getByRole('status'); + // This mock will never return data about the library (it loads forever): + const libraryId = mockContentLibrary.libraryIdThatNeverLoads; + render(, { path, params: { libraryId } }); + const spinner = screen.getByRole('status'); expect(spinner.textContent).toEqual('Loading...'); }); it('shows an error component if no library returned', async () => { - mockUseParams.mockReturnValue({ libraryId: 'invalid' }); - axiosMock.onGet(getContentLibraryApiUrl('invalid')).reply(400); - - const { findByTestId } = render(); - - expect(await findByTestId('notFoundAlert')).toBeInTheDocument(); + // This mock will simulate a 404 error: + const libraryId = mockContentLibrary.library404; + render(, { path, params: { libraryId } }); + expect(await screen.findByTestId('notFoundAlert')).toBeInTheDocument(); }); - it('shows an error component if no library param', async () => { - mockUseParams.mockReturnValue({ libraryId: '' }); - - const { findByTestId } = render(); - - expect(await findByTestId('notFoundAlert')).toBeInTheDocument(); - }); - - it('show library data', async () => { - const { - getByRole, getAllByText, getByText, queryByText, findByText, findAllByText, - } = await renderLibraryPage(); + it('shows library data', async () => { + await renderLibraryPage(); - await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); }); + expect(await screen.findByText('Content library')).toBeInTheDocument(); + expect((await screen.findAllByText(libraryTitle))[0]).toBeInTheDocument(); - expect(await findByText('Content library')).toBeInTheDocument(); - expect((await findAllByText(libraryData.title))[0]).toBeInTheDocument(); + const browserTabTitle = Helmet.peek().title.join(''); + const siteName = getConfig().SITE_NAME; + expect(browserTabTitle).toEqual(`${libraryTitle} | ${siteName}`); - expect(queryByText('You have not added any content to this library yet.')).not.toBeInTheDocument(); + expect(screen.queryByText('You have not added any content to this library yet.')).not.toBeInTheDocument(); // "Recently Modified" header + sort shown - expect(getAllByText('Recently Modified').length).toEqual(2); - expect(getByText('Collections (0)')).toBeInTheDocument(); - expect(getByText('Components (6)')).toBeInTheDocument(); - expect((await findAllByText('Test HTML Block'))[0]).toBeInTheDocument(); + expect(screen.getAllByText('Recently Modified').length).toEqual(2); + expect(screen.getByText('Collections (6)')).toBeInTheDocument(); + expect(screen.getByText('Components (10)')).toBeInTheDocument(); + expect((await screen.findAllByText('Introduction to Testing'))[0]).toBeInTheDocument(); // Navigate to the components tab - fireEvent.click(getByRole('tab', { name: 'Components' })); + fireEvent.click(screen.getByRole('tab', { name: 'Components' })); // "Recently Modified" default sort shown - expect(getAllByText('Recently Modified').length).toEqual(1); - expect(queryByText('Collections (0)')).not.toBeInTheDocument(); - expect(queryByText('Components (6)')).not.toBeInTheDocument(); + expect(screen.getAllByText('Recently Modified').length).toEqual(1); + expect(screen.queryByText('Collections (6)')).not.toBeInTheDocument(); + expect(screen.queryByText('Components (10)')).not.toBeInTheDocument(); // Navigate to the collections tab - fireEvent.click(getByRole('tab', { name: 'Collections' })); + fireEvent.click(screen.getByRole('tab', { name: 'Collections' })); // "Recently Modified" default sort shown - expect(getAllByText('Recently Modified').length).toEqual(1); - expect(queryByText('Collections (0)')).not.toBeInTheDocument(); - expect(queryByText('Components (6)')).not.toBeInTheDocument(); - expect(queryByText('There are 6 components in this library')).not.toBeInTheDocument(); - expect(getByText('Coming soon!')).toBeInTheDocument(); + expect(screen.getAllByText('Recently Modified').length).toEqual(1); + expect(screen.queryByText('Collections (6)')).not.toBeInTheDocument(); + expect(screen.queryByText('Components (10)')).not.toBeInTheDocument(); + expect(screen.queryByText('There are 10 components in this library')).not.toBeInTheDocument(); + expect((await screen.findAllByText('Collection 1'))[0]).toBeInTheDocument(); // Go back to Home tab // This step is necessary to avoid the url change leak to other tests - fireEvent.click(getByRole('tab', { name: 'Home' })); + fireEvent.click(screen.getByRole('tab', { name: 'Home' })); // "Recently Modified" header + sort shown - expect(getAllByText('Recently Modified').length).toEqual(2); - expect(getByText('Collections (0)')).toBeInTheDocument(); - expect(getByText('Components (6)')).toBeInTheDocument(); + expect(screen.getAllByText('Recently Modified').length).toEqual(2); + expect(screen.getByText('Collections (6)')).toBeInTheDocument(); + expect(screen.getByText('Components (10)')).toBeInTheDocument(); }); - it('show library without components', async () => { - mockUseParams.mockReturnValue({ libraryId: libraryData.id }); - axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData); + it('shows a library without components and collections', async () => { fetchMock.post(searchEndpoint, returnEmptyResult, { overwriteRoutes: true }); + await renderLibraryPage(); - const { findByText, getByText, findAllByText } = render(); - - expect(await findByText('Content library')).toBeInTheDocument(); - expect((await findAllByText(libraryData.title))[0]).toBeInTheDocument(); - - await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); }); - - expect(getByText('You have not added any content to this library yet.')).toBeInTheDocument(); - }); + expect(await screen.findByText('Content library')).toBeInTheDocument(); + expect((await screen.findAllByText(libraryTitle))[0]).toBeInTheDocument(); - it('show library without components without permission', async () => { - const data = { - ...libraryData, - canEditLibrary: false, - }; - mockUseParams.mockReturnValue({ libraryId: libraryData.id }); - axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, data); - fetchMock.post(searchEndpoint, returnEmptyResult, { overwriteRoutes: true }); + fireEvent.click(screen.getByRole('tab', { name: 'Collections' })); + expect(screen.getByText('You have not added any collections to this library yet.')).toBeInTheDocument(); - render(); + // Open Create collection modal + const addCollectionButton = screen.getByRole('button', { name: /add collection/i }); + fireEvent.click(addCollectionButton); + const collectionModalHeading = await screen.findByRole('heading', { name: /new collection/i }); + expect(collectionModalHeading).toBeInTheDocument(); - expect(await screen.findByText('Content library')).toBeInTheDocument(); + // Click on Cancel button + const cancelButton = screen.getByRole('button', { name: /cancel/i }); + fireEvent.click(cancelButton); + expect(collectionModalHeading).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('tab', { name: 'Home' })); expect(screen.getByText('You have not added any content to this library yet.')).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: /add component/i })).not.toBeInTheDocument(); + + const addComponentButton = screen.getByRole('button', { name: /add component/i }); + fireEvent.click(addComponentButton); + expect(screen.getByText(/add content/i)).toBeInTheDocument(); }); - it('show new content button', async () => { + it('shows the new content button', async () => { await renderLibraryPage(); expect(await screen.findByRole('heading')).toBeInTheDocument(); expect(screen.getByRole('button', { name: /new/i })).toBeInTheDocument(); + expect(screen.queryByText('Read Only')).not.toBeInTheDocument(); }); - it('read only state of library', async () => { - const data = { - ...libraryData, - canEditLibrary: false, - }; - mockUseParams.mockReturnValue({ libraryId: libraryData.id }); - axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, data); - - render(); - expect(await screen.findByRole('heading')).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: /new/i })).not.toBeInTheDocument(); + it('shows an empty read-only library, without a "create component" button', async () => { + // Use a library mock that is read-only: + const libraryId = mockContentLibrary.libraryIdReadOnly; + // Update search mock so it returns no results: + fetchMock.post(searchEndpoint, returnEmptyResult, { overwriteRoutes: true }); + render(, { path, params: { libraryId } }); + await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); }); + expect(await screen.findByText('Content library')).toBeInTheDocument(); + expect(screen.getByText('You have not added any content to this library yet.')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /add component/i })).not.toBeInTheDocument(); expect(screen.getByText('Read Only')).toBeInTheDocument(); }); - it('show library without search results', async () => { - mockUseParams.mockReturnValue({ libraryId: libraryData.id }); - axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData); + it('show a library without search results', async () => { + // Update search mock so it returns no results: fetchMock.post(searchEndpoint, returnEmptyResult, { overwriteRoutes: true }); + await renderLibraryPage(); - const { - findByText, - getByRole, - getByText, - findAllByText, - } = render(); - - expect(await findByText('Content library')).toBeInTheDocument(); - expect((await findAllByText(libraryData.title))[0]).toBeInTheDocument(); + expect(await screen.findByText('Content library')).toBeInTheDocument(); + expect((await screen.findAllByText(libraryTitle))[0]).toBeInTheDocument(); await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); }); - fireEvent.change(getByRole('searchbox'), { target: { value: 'noresults' } }); + fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'noresults' } }); // Ensure the search endpoint is called again, only once more since the recently modified call // should not be impacted by the search await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); }); - expect(getByText('No matching components found in this library.')).toBeInTheDocument(); + expect(screen.getByText('No matching components found in this library.')).toBeInTheDocument(); // Navigate to the components tab - fireEvent.click(getByRole('tab', { name: 'Components' })); - expect(getByText('No matching components found in this library.')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('tab', { name: 'Components' })); + expect(screen.getByText('No matching components found in this library.')).toBeInTheDocument(); + + // Navigate to the collections tab + fireEvent.click(screen.getByRole('tab', { name: 'Collections' })); + expect(screen.getByText('No matching collections found in this library.')).toBeInTheDocument(); // Go back to Home tab // This step is necessary to avoid the url change leak to other tests - fireEvent.click(getByRole('tab', { name: 'Home' })); + fireEvent.click(screen.getByRole('tab', { name: 'Home' })); }); it('should open and close new content sidebar', async () => { @@ -358,15 +267,18 @@ describe('', () => { await renderLibraryPage(); expect(await screen.findByText('Content library')).toBeInTheDocument(); - expect((await screen.findAllByText(libraryData.title))[0]).toBeInTheDocument(); - expect((await screen.findAllByText(libraryData.title))[1]).toBeInTheDocument(); + expect((await screen.findAllByText(libraryTitle))[0]).toBeInTheDocument(); + expect((await screen.findAllByText(libraryTitle))[1]).toBeInTheDocument(); expect(screen.getByText('Draft')).toBeInTheDocument(); expect(screen.getByText('(Never Published)')).toBeInTheDocument(); + // Draft saved on date: expect(screen.getByText('July 22, 2024')).toBeInTheDocument(); - expect(screen.getByText('staff')).toBeInTheDocument(); - expect(screen.getByText(libraryData.org)).toBeInTheDocument(); + + expect(screen.getByText(mockContentLibrary.libraryData.org)).toBeInTheDocument(); + // Updated: expect(screen.getByText('July 20, 2024')).toBeInTheDocument(); + // Created: expect(screen.getByText('June 26, 2024')).toBeInTheDocument(); }); @@ -374,8 +286,8 @@ describe('', () => { await renderLibraryPage(); expect(await screen.findByText('Content library')).toBeInTheDocument(); - expect((await screen.findAllByText(libraryData.title))[0]).toBeInTheDocument(); - expect((await screen.findAllByText(libraryData.title))[1]).toBeInTheDocument(); + expect((await screen.findAllByText(libraryTitle))[0]).toBeInTheDocument(); + expect((await screen.findAllByText(libraryTitle))[1]).toBeInTheDocument(); // Open by default; close the library info sidebar const closeButton = screen.getByRole('button', { name: /close/i }); @@ -389,90 +301,106 @@ describe('', () => { expect(screen.getByText('Draft')).toBeInTheDocument(); expect(screen.getByText('(Never Published)')).toBeInTheDocument(); - // CLose library info sidebar with 'Library info' button + // Close library info sidebar with 'Library info' button fireEvent.click(libraryInfoButton); expect(screen.queryByText('Draft')).not.toBeInTheDocument(); expect(screen.queryByText('(Never Published)')).not.toBeInTheDocument(); }); + it('should show "Manage Access" button in Library Info that opens the Library Team modal', async () => { + await renderLibraryPage(); + const manageAccess = screen.getByRole('button', { name: /manage access/i }); + + expect(manageAccess).not.toBeDisabled(); + fireEvent.click(manageAccess); + + expect(await screen.findByText('Library Team')).toBeInTheDocument(); + }); + + it('should not show "Manage Access" button in Library Info to users who cannot edit the library', async () => { + const libraryId = mockContentLibrary.libraryIdReadOnly; + render(, { path, params: { libraryId } }); + + const manageAccess = screen.queryByRole('button', { name: /manage access/i }); + expect(manageAccess).not.toBeInTheDocument(); + }); + it('show the "View All" button when viewing library with many components', async () => { - const { - getByRole, getByText, queryByText, getAllByText, findAllByText, - } = await renderLibraryPage(); + await renderLibraryPage(); - expect(getByText('Content library')).toBeInTheDocument(); - expect((await findAllByText(libraryData.title))[0]).toBeInTheDocument(); + expect(screen.getByText('Content library')).toBeInTheDocument(); + expect((await screen.findAllByText(libraryTitle))[0]).toBeInTheDocument(); // "Recently Modified" header + sort shown - await waitFor(() => { expect(getAllByText('Recently Modified').length).toEqual(2); }); - expect(getByText('Collections (0)')).toBeInTheDocument(); - expect(getByText('Components (6)')).toBeInTheDocument(); - expect(getAllByText('Test HTML Block')[0]).toBeInTheDocument(); - expect(queryByText('You have not added any content to this library yet.')).not.toBeInTheDocument(); + await waitFor(() => { expect(screen.getAllByText('Recently Modified').length).toEqual(2); }); + expect(screen.getByText('Collections (6)')).toBeInTheDocument(); + expect(screen.getByText('Components (10)')).toBeInTheDocument(); + expect(screen.getAllByText('Introduction to Testing')[0]).toBeInTheDocument(); + expect(screen.queryByText('You have not added any content to this library yet.')).not.toBeInTheDocument(); - // There should only be one "View All" button, since the Components count + // There should be two "View All" button, since the Components and Collections count // are above the preview limit (4) - expect(getByText('View All')).toBeInTheDocument(); + expect(screen.getAllByText('View All').length).toEqual(2); - // Clicking on "View All" button should navigate to the Components tab - fireEvent.click(getByText('View All')); + // Clicking on first "View All" button should navigate to the Collections tab + fireEvent.click(screen.getAllByText('View All')[0]); + // "Recently Modified" default sort shown + expect(screen.getAllByText('Recently Modified').length).toEqual(1); + expect(screen.queryByText('Collections (6)')).not.toBeInTheDocument(); + expect(screen.queryByText('Components (10)')).not.toBeInTheDocument(); + expect(screen.getByText('Collection 1')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('tab', { name: 'Home' })); + // Clicking on second "View All" button should navigate to the Components tab + fireEvent.click(screen.getAllByText('View All')[1]); // "Recently Modified" default sort shown - expect(getAllByText('Recently Modified').length).toEqual(1); - expect(queryByText('Collections (0)')).not.toBeInTheDocument(); - expect(queryByText('Components (6)')).not.toBeInTheDocument(); - expect(getAllByText('Test HTML Block')[0]).toBeInTheDocument(); + expect(screen.getAllByText('Recently Modified').length).toEqual(1); + expect(screen.queryByText('Collections (6)')).not.toBeInTheDocument(); + expect(screen.queryByText('Components (10)')).not.toBeInTheDocument(); + expect(screen.getAllByText('Introduction to Testing')[0]).toBeInTheDocument(); // Go back to Home tab // This step is necessary to avoid the url change leak to other tests - fireEvent.click(getByRole('tab', { name: 'Home' })); + fireEvent.click(screen.getByRole('tab', { name: 'Home' })); // "Recently Modified" header + sort shown - expect(getAllByText('Recently Modified').length).toEqual(2); - expect(getByText('Collections (0)')).toBeInTheDocument(); - expect(getByText('Components (6)')).toBeInTheDocument(); + expect(screen.getAllByText('Recently Modified').length).toEqual(2); + expect(screen.getByText('Collections (6)')).toBeInTheDocument(); + expect(screen.getByText('Components (10)')).toBeInTheDocument(); }); it('should not show the "View All" button when viewing library with low number of components', async () => { - mockUseParams.mockReturnValue({ libraryId: libraryData.id }); - axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData); fetchMock.post(searchEndpoint, returnLowNumberResults, { overwriteRoutes: true }); + await renderLibraryPage(); - const { - getByText, queryByText, getAllByText, findAllByText, - } = render(); - - await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); }); - - expect(getByText('Content library')).toBeInTheDocument(); - expect((await findAllByText(libraryData.title))[0]).toBeInTheDocument(); + expect(screen.getByText('Content library')).toBeInTheDocument(); + expect((await screen.findAllByText(libraryTitle))[0]).toBeInTheDocument(); // "Recently Modified" header + sort shown - await waitFor(() => { expect(getAllByText('Recently Modified').length).toEqual(2); }); - expect(getByText('Collections (0)')).toBeInTheDocument(); - expect(getByText('Components (2)')).toBeInTheDocument(); - expect(getAllByText('Test HTML Block')[0]).toBeInTheDocument(); - expect(queryByText('You have not added any content to this library yet.')).not.toBeInTheDocument(); + await waitFor(() => { expect(screen.getAllByText('Recently Modified').length).toEqual(2); }); + expect(screen.getByText('Collections (2)')).toBeInTheDocument(); + expect(screen.getByText('Components (2)')).toBeInTheDocument(); + expect(screen.getAllByText('Introduction to Testing')[0]).toBeInTheDocument(); + expect(screen.queryByText('You have not added any content to this library yet.')).not.toBeInTheDocument(); // There should not be any "View All" button on page since Components count // is less than the preview limit (4) - expect(queryByText('View All')).not.toBeInTheDocument(); + expect(screen.queryByText('View All')).not.toBeInTheDocument(); }); - it('sort library components', async () => { - const { - findByTitle, getAllByText, getByRole, getByTitle, - } = await renderLibraryPage(); + it('sorts library components', async () => { + await renderLibraryPage(); - expect(await findByTitle('Sort search results')).toBeInTheDocument(); + expect(await screen.findByTitle('Sort search results')).toBeInTheDocument(); const testSortOption = (async (optionText, sortBy, isDefault) => { // Open the drop-down menu - fireEvent.click(getByTitle('Sort search results')); + fireEvent.click(screen.getByTitle('Sort search results')); // Click the option with the given text // Since the sort drop-down also shows the selected sort // option in its toggle button, we need to make sure we're // clicking on the last one found. - const options = getAllByText(optionText); + const options = screen.getAllByText(optionText); expect(options.length).toBeGreaterThan(0); fireEvent.click(options[options.length - 1]); @@ -487,12 +415,13 @@ describe('', () => { }); // Is the sort option stored in the query string? - const searchText = isDefault ? '' : `?sort=${encodeURIComponent(sortBy)}`; - expect(window.location.search).toEqual(searchText); + // Note: we can't easily check this at the moment with + // const searchText = isDefault ? '' : `?sort=${encodeURIComponent(sortBy)}`; + // expect(window.location.href).toEqual(searchText); // Is the selected sort option shown in the toggle button (if not default) // as well as in the drop-down menu? - expect(getAllByText(optionText).length).toEqual(isDefault ? 1 : 2); + expect(screen.getAllByText(optionText).length).toEqual(isDefault ? 1 : 2); }); await testSortOption('Title, A-Z', 'display_name:asc', false); @@ -512,14 +441,14 @@ describe('', () => { // Re-selecting the previous sort option resets sort to default "Recently Modified" await testSortOption('Recently Published', 'modified:desc', true); - expect(getAllByText('Recently Modified').length).toEqual(3); + expect(screen.getAllByText('Recently Modified').length).toEqual(3); // Enter a keyword into the search box - const searchBox = getByRole('searchbox'); + const searchBox = screen.getByRole('searchbox'); fireEvent.change(searchBox, { target: { value: 'words to find' } }); // Default sort option changes to "Most Relevant" - expect(getAllByText('Most Relevant').length).toEqual(2); + expect(screen.getAllByText('Most Relevant').length).toEqual(2); await waitFor(() => { expect(fetchMock).toHaveBeenLastCalledWith(searchEndpoint, { body: expect.stringContaining('"sort":[]'), @@ -530,30 +459,71 @@ describe('', () => { }); it('should open and close the component sidebar', async () => { - const usageKey = mockResult.results[0].hits[0].usage_key; - const { getAllByText, queryByTestId, queryByText } = await renderLibraryPage(); - axiosMock.onGet(getXBlockFieldsApiUrl(usageKey)).reply(200, xBlockFields); + const mockResult0 = { ...mockResult }.results[0].hits[0]; + const displayName = 'Introduction to Testing'; + expect(mockResult0.display_name).toStrictEqual(displayName); + await renderLibraryPage(); - // Click on the first component - waitFor(() => expect(queryByText('Test HTML Block')).toBeInTheDocument()); - fireEvent.click(getAllByText('Test HTML Block')[0]); + // Click on the first component. It should appear twice, in both "Recently Modified" and "Components" + fireEvent.click((await screen.findAllByText(displayName))[0]); const sidebar = screen.getByTestId('library-sidebar'); const { getByRole, getByText } = within(sidebar); - await waitFor(() => expect(getByText('Test HTML Block')).toBeInTheDocument()); + await waitFor(() => expect(getByText(displayName)).toBeInTheDocument()); const closeButton = getByRole('button', { name: /close/i }); fireEvent.click(closeButton); - await waitFor(() => expect(queryByTestId('library-sidebar')).not.toBeInTheDocument()); + await waitFor(() => expect(screen.queryByTestId('library-sidebar')).not.toBeInTheDocument()); }); - it('filter by capa problem type', async () => { - mockUseParams.mockReturnValue({ libraryId: libraryData.id }); - axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData); + it('should open component sidebar, showing manage tab on clicking add to collection menu item', async () => { + const mockResult0 = { ...mockResult }.results[0].hits[0]; + const displayName = 'Introduction to Testing'; + expect(mockResult0.display_name).toStrictEqual(displayName); + await renderLibraryPage(); + + waitFor(() => expect(screen.getAllByTestId('component-card-menu-toggle').length).toBeGreaterThan(0)); + + // Open menu + fireEvent.click((await screen.findAllByTestId('component-card-menu-toggle'))[0]); + // Click add to collection + fireEvent.click(screen.getByRole('button', { name: 'Add to collection' })); + + const sidebar = screen.getByTestId('library-sidebar'); + + const { getByRole, queryByText } = within(sidebar); + + await waitFor(() => expect(queryByText(displayName)).toBeInTheDocument()); + expect(getByRole('tab', { selected: true })).toHaveTextContent('Manage'); + const closeButton = getByRole('button', { name: /close/i }); + fireEvent.click(closeButton); + + await waitFor(() => expect(screen.queryByTestId('library-sidebar')).not.toBeInTheDocument()); + }); + it('should open and close the collection sidebar', async () => { + await renderLibraryPage(); + + // Click on the first component. It could appear twice, in both "Recently Modified" and "Collections" + fireEvent.click((await screen.findAllByText('Collection 1'))[0]); + + const sidebar = screen.getByTestId('library-sidebar'); + + const { getByRole, getByText } = within(sidebar); + + // The mock data for the sidebar has a title of "Test Collection" + await waitFor(() => expect(getByText('Test Collection')).toBeInTheDocument()); + + const closeButton = getByRole('button', { name: /close/i }); + fireEvent.click(closeButton); + + await waitFor(() => expect(screen.queryByTestId('library-sidebar')).not.toBeInTheDocument()); + }); + + it('can filter by capa problem type', async () => { const problemTypes = { 'Multiple Choice': 'choiceresponse', Checkboxes: 'multiplechoiceresponse', @@ -562,17 +532,20 @@ describe('', () => { 'Text Input': 'stringresponse', }; - render(); + await renderLibraryPage(); // Ensure the search endpoint is called await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); }); const filterButton = screen.getByRole('button', { name: /type/i }); fireEvent.click(filterButton); - const openProblemItem = screen.getByTestId('open-problem-item-button'); - fireEvent.click(openProblemItem); + const problemFilterCheckbox = screen.getByRole('checkbox', { name: /problem/i }); + const problemFilterMenuItem = problemFilterCheckbox.parentElement; // div.pgn__menu-item + const showProbTypesSubmenuBtn = problemFilterMenuItem!.querySelector('button[aria-label="Open problem types filters"]'); + expect(showProbTypesSubmenuBtn).not.toBeNull(); + fireEvent.click(showProbTypesSubmenuBtn!); - const validateSubmenu = async (submenuText : string) => { + const validateSubmenu = async (submenuText: string) => { const submenu = screen.getByText(submenuText); expect(submenu).toBeInTheDocument(); fireEvent.click(submenu); @@ -639,18 +612,179 @@ describe('', () => { }); }); - it('empty type filter', async () => { - mockUseParams.mockReturnValue({ libraryId: libraryData.id }); - axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData); + it('has an empty type filter when there are no results', async () => { fetchMock.post(searchEndpoint, returnEmptyResult, { overwriteRoutes: true }); - - render(); - - await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); }); + await renderLibraryPage(); const filterButton = screen.getByRole('button', { name: /type/i }); fireEvent.click(filterButton); expect(screen.getByText(/no matching components/i)).toBeInTheDocument(); }); + + it('should create a collection', async () => { + await renderLibraryPage(); + const title = 'This is a Test'; + const description = 'This is the description of the Test'; + const url = getLibraryCollectionsApiUrl(mockContentLibrary.libraryId); + const { axiosMock } = initializeMocks(); + axiosMock.onPost(url).reply(200, { + id: '1', + slug: 'this-is-a-test', + title, + description, + }); + + expect(await screen.findByRole('heading')).toBeInTheDocument(); + expect(screen.queryByText(/add content/i)).not.toBeInTheDocument(); + + // Open Add content sidebar + const newButton = screen.getByRole('button', { name: /new/i }); + fireEvent.click(newButton); + expect(screen.getByText(/add content/i)).toBeInTheDocument(); + + // Open New collection Modal + const newCollectionButton = screen.getAllByRole('button', { name: /collection/i })[4]; + fireEvent.click(newCollectionButton); + const collectionModalHeading = await screen.findByRole('heading', { name: /new collection/i }); + expect(collectionModalHeading).toBeInTheDocument(); + + // Click on Cancel button + const cancelButton = screen.getByRole('button', { name: /cancel/i }); + fireEvent.click(cancelButton); + expect(collectionModalHeading).not.toBeInTheDocument(); + + // Open new collection modal again and create a collection + fireEvent.click(newCollectionButton); + const createButton = screen.getByRole('button', { name: /create/i }); + const nameField = screen.getByRole('textbox', { name: /name your collection/i }); + const descriptionField = screen.getByRole('textbox', { name: /add a description \(optional\)/i }); + + fireEvent.change(nameField, { target: { value: title } }); + fireEvent.change(descriptionField, { target: { value: description } }); + fireEvent.click(createButton); + }); + + it('should show validations in create collection', async () => { + await renderLibraryPage(); + + const title = 'This is a Test'; + const description = 'This is the description of the Test'; + const url = getLibraryCollectionsApiUrl(mockContentLibrary.libraryId); + const { axiosMock } = initializeMocks(); + axiosMock.onPost(url).reply(200, { + id: '1', + slug: 'this-is-a-test', + title, + description, + }); + + expect(await screen.findByRole('heading')).toBeInTheDocument(); + expect(screen.queryByText(/add content/i)).not.toBeInTheDocument(); + + // Open Add content sidebar + const newButton = screen.getByRole('button', { name: /new/i }); + fireEvent.click(newButton); + expect(screen.getByText(/add content/i)).toBeInTheDocument(); + + // Open New collection Modal + const newCollectionButton = screen.getAllByRole('button', { name: /collection/i })[4]; + fireEvent.click(newCollectionButton); + const collectionModalHeading = await screen.findByRole('heading', { name: /new collection/i }); + expect(collectionModalHeading).toBeInTheDocument(); + + const nameField = screen.getByRole('textbox', { name: /name your collection/i }); + fireEvent.focus(nameField); + fireEvent.blur(nameField); + + // Click on create with an empty name + const createButton = screen.getByRole('button', { name: /create/i }); + fireEvent.click(createButton); + + expect(await screen.findByText(/collection name is required/i)).toBeInTheDocument(); + }); + + it('should show error on create collection', async () => { + await renderLibraryPage(); + const title = 'This is a Test'; + const description = 'This is the description of the Test'; + const url = getLibraryCollectionsApiUrl(mockContentLibrary.libraryId); + const { axiosMock } = initializeMocks(); + axiosMock.onPost(url).reply(500); + + expect(await screen.findByRole('heading')).toBeInTheDocument(); + expect(screen.queryByText(/add content/i)).not.toBeInTheDocument(); + + // Open Add content sidebar + const newButton = screen.getByRole('button', { name: /new/i }); + fireEvent.click(newButton); + expect(screen.getByText(/add content/i)).toBeInTheDocument(); + + // Open New collection Modal + const newCollectionButton = screen.getAllByRole('button', { name: /collection/i })[4]; + fireEvent.click(newCollectionButton); + const collectionModalHeading = await screen.findByRole('heading', { name: /new collection/i }); + expect(collectionModalHeading).toBeInTheDocument(); + + // Create a normal collection + const createButton = screen.getByRole('button', { name: /create/i }); + const nameField = screen.getByRole('textbox', { name: /name your collection/i }); + const descriptionField = screen.getByRole('textbox', { name: /add a description \(optional\)/i }); + + fireEvent.change(nameField, { target: { value: title } }); + fireEvent.change(descriptionField, { target: { value: description } }); + fireEvent.click(createButton); + }); + + it('shows both components and collections in recently modified section', async () => { + await renderLibraryPage(); + + expect(await screen.findByText('Content library')).toBeInTheDocument(); + expect((await screen.findAllByText(libraryTitle))[0]).toBeInTheDocument(); + + // "Recently Modified" header + sort shown + expect(screen.getAllByText('Recently Modified').length).toEqual(2); + const recentModifiedContainer = (await screen.findAllByText('Recently Modified'))[1].parentElement?.parentElement?.parentElement; + expect(recentModifiedContainer).toBeTruthy(); + + const container = within(recentModifiedContainer!); + expect(container.queryAllByText('Text').length).toBeGreaterThan(0); + expect(container.queryAllByText('Collection').length).toBeGreaterThan(0); + }); + + it('shows a single block when usageKey query param is set', async () => { + render(, { + path, + routerProps: { + initialEntries: [ + `/library/${mockContentLibrary.libraryId}/components?usageKey=${mockXBlockFields.usageKeyHtml}`, + ], + }, + }); + await waitFor(() => { + expect(fetchMock).toHaveBeenLastCalledWith(searchEndpoint, { + body: expect.stringContaining(mockXBlockFields.usageKeyHtml), + headers: expect.anything(), + method: 'POST', + }); + }); + expect(screen.queryByPlaceholderText('Displaying single block, clear filters to search')).toBeInTheDocument(); + const { displayName } = mockXBlockFields.dataHtml; + const sidebar = screen.getByTestId('library-sidebar'); + + const { getByText } = within(sidebar); + + // should display the component with passed param: usageKey in the sidebar + expect(getByText(displayName)).toBeInTheDocument(); + // clear usageKey filter + const clearFitlersButton = screen.getByRole('button', { name: /clear filters/i }); + fireEvent.click(clearFitlersButton); + await waitFor(() => { + expect(fetchMock).toHaveBeenLastCalledWith(searchEndpoint, { + body: expect.not.stringContaining(mockXBlockFields.usageKeyHtml), + method: 'POST', + headers: expect.anything(), + }); + }); + }); }); diff --git a/src/library-authoring/LibraryAuthoringPage.tsx b/src/library-authoring/LibraryAuthoringPage.tsx index 5f8c144d34..57ca633ab1 100644 --- a/src/library-authoring/LibraryAuthoringPage.tsx +++ b/src/library-authoring/LibraryAuthoringPage.tsx @@ -1,18 +1,24 @@ -import React, { useContext, useEffect } from 'react'; +import { useEffect, useState } from 'react'; +import { Helmet } from 'react-helmet'; import classNames from 'classnames'; import { StudioFooter } from '@edx/frontend-component-footer'; import { useIntl } from '@edx/frontend-platform/i18n'; import { Badge, + Breadcrumb, Button, Container, + Icon, Stack, Tab, Tabs, } from '@openedx/paragon'; -import { Add, InfoOutline } from '@openedx/paragon/icons'; +import { Add, ArrowBack, InfoOutline } from '@openedx/paragon/icons'; import { - Routes, Route, useLocation, useNavigate, useParams, useSearchParams, + Link, + useLocation, + useNavigate, + useSearchParams, } from 'react-router-dom'; import Loading from '../generic/Loading'; @@ -28,11 +34,10 @@ import { SearchSortWidget, } from '../search-manager'; import LibraryComponents from './components/LibraryComponents'; -import LibraryCollections from './LibraryCollections'; +import LibraryCollections from './collections/LibraryCollections'; import LibraryHome from './LibraryHome'; -import { useContentLibrary } from './data/apiHooks'; import { LibrarySidebar } from './library-sidebar'; -import { LibraryContext, SidebarBodyComponentId } from './common/context'; +import { SidebarBodyComponentId, useLibraryContext } from './common/context'; import messages from './messages'; enum TabList { @@ -41,25 +46,35 @@ enum TabList { collections = 'collections', } -interface HeaderActionsProps { - canEditLibrary: boolean; +interface TabContentProps { + eventKey: string; + handleTabChange: (key: string) => void; } -const HeaderActions = ({ canEditLibrary }: HeaderActionsProps) => { +const TabContent = ({ eventKey, handleTabChange }: TabContentProps) => { + switch (eventKey) { + case TabList.components: + return ; + case TabList.collections: + return ; + default: + return ; + } +}; + +const HeaderActions = () => { const intl = useIntl(); const { + componentPickerMode, openAddContentSidebar, openInfoSidebar, closeLibrarySidebar, - sidebarBodyComponent, - } = useContext(LibraryContext); - - if (!canEditLibrary) { - return null; - } + sidebarComponentInfo, + readOnly, + } = useLibraryContext(); const infoSidebarIsOpen = () => ( - sidebarBodyComponent === SidebarBodyComponentId.Info + sidebarComponentInfo?.type === SidebarBodyComponentId.Info ); const handleOnClickInfoSidebar = () => { @@ -83,26 +98,32 @@ const HeaderActions = ({ canEditLibrary }: HeaderActionsProps) => { > {intl.formatMessage(messages.libraryInfoButton)} - + {!componentPickerMode && ( + + )} ); }; -const SubHeaderTitle = ({ title, canEditLibrary }: { title: string, canEditLibrary: boolean }) => { +const SubHeaderTitle = ({ title }: { title: string }) => { const intl = useIntl(); + const { readOnly, componentPickerMode } = useLibraryContext(); + + const showReadOnlyBadge = readOnly && !componentPickerMode; + return ( {title} - { !canEditLibrary && ( + {showReadOnlyBadge && (
{intl.formatMessage(messages.readOnlyBadge)} @@ -113,60 +134,116 @@ const SubHeaderTitle = ({ title, canEditLibrary }: { title: string, canEditLibra ); }; -const LibraryAuthoringPage = () => { +interface LibraryAuthoringPageProps { + returnToLibrarySelection?: () => void, +} + +const LibraryAuthoringPage = ({ returnToLibrarySelection }: LibraryAuthoringPageProps) => { const intl = useIntl(); const location = useLocation(); const navigate = useNavigate(); - const { libraryId } = useParams(); - const { data: libraryData, isLoading } = useContentLibrary(libraryId); - - const currentPath = location.pathname.split('/').pop(); - const activeKey = (currentPath && currentPath in TabList) ? TabList[currentPath] : TabList.home; const { - sidebarBodyComponent, + libraryId, + libraryData, + isLoadingLibraryData, + componentPickerMode, + restrictToLibrary, + showOnlyPublished, + sidebarComponentInfo, openInfoSidebar, - } = useContext(LibraryContext); + } = useLibraryContext(); + + const [activeKey, setActiveKey] = useState(''); useEffect(() => { - openInfoSidebar(); + const currentPath = location.pathname.split('/').pop(); + + if (componentPickerMode || currentPath === libraryId || currentPath === '') { + setActiveKey(TabList.home); + } else if (currentPath && currentPath in TabList) { + setActiveKey(TabList[currentPath]); + } + }, [location.pathname]); + + useEffect(() => { + if (!componentPickerMode) { + openInfoSidebar(); + } }, []); const [searchParams] = useSearchParams(); - if (isLoading) { + if (isLoadingLibraryData) { return ; } - if (!libraryId || !libraryData) { + // istanbul ignore if: this should never happen + if (activeKey === undefined) { + return ; + } + + if (!libraryData) { return ; } const handleTabChange = (key: string) => { - navigate({ - pathname: key, - search: searchParams.toString(), - }); + setActiveKey(key); + if (!componentPickerMode) { + navigate({ + pathname: key, + search: searchParams.toString(), + }); + } }; + const breadcumbs = componentPickerMode && !restrictToLibrary ? ( + } + linkAs={Link} + /> + ) : undefined; + + const extraFilter = [`context_key = "${libraryId}"`]; + if (showOnlyPublished) { + extraFilter.push('last_published IS NOT NULL'); + } + return ( -
-
-
- +
+
+ {libraryData.title} | {process.env.SITE_NAME} + {!componentPickerMode && ( +
+ )} + } - subtitle={intl.formatMessage(messages.headingSubtitle)} - headerActions={} + title={} + subtitle={!componentPickerMode ? intl.formatMessage(messages.headingSubtitle) : undefined} + breadcrumbs={breadcumbs} + headerActions={} />
@@ -186,37 +263,14 @@ const LibraryAuthoringPage = () => { - - - )} - /> - } - /> - } - /> - } - /> - + - + {!componentPickerMode && }
- { !!sidebarBodyComponent && ( + {!!sidebarComponentInfo?.type && (
- +
)}
diff --git a/src/library-authoring/LibraryBlock/LibraryBlock.tsx b/src/library-authoring/LibraryBlock/LibraryBlock.tsx new file mode 100644 index 0000000000..b76e2b1aea --- /dev/null +++ b/src/library-authoring/LibraryBlock/LibraryBlock.tsx @@ -0,0 +1,96 @@ +import { useEffect, useRef, useState } from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { getConfig } from '@edx/frontend-platform'; + +import messages from './messages'; + +export type VersionSpec = 'published' | 'draft' | number; + +interface LibraryBlockProps { + onBlockNotification?: (event: { eventType: string; [key: string]: any }) => void; + usageKey: string; + version?: VersionSpec; +} +/** + * React component that displays an XBlock in a sandboxed IFrame. + * + * The IFrame is resized responsively so that it fits the content height. + * + * We use an IFrame so that the XBlock code, including user-authored HTML, + * cannot access things like the user's cookies, nor can it make GET/POST + * requests as the user. However, it is allowed to call any XBlock handlers. + */ +export const LibraryBlock = ({ onBlockNotification, usageKey, version }: LibraryBlockProps) => { + const iframeRef = useRef(null); + const [iFrameHeight, setIFrameHeight] = useState(600); + const studioBaseUrl = getConfig().STUDIO_BASE_URL; + + const intl = useIntl(); + + /** + * Handle any messages we receive from the XBlock Runtime code in the IFrame. + * See wrap.ts to see the code that sends these messages. + */ + /* istanbul ignore next */ + const receivedWindowMessage = async (event) => { + if (!iframeRef.current || event.source !== iframeRef.current.contentWindow) { + return; // This is some other random message. + } + + const { method, replyKey, ...args } = event.data; + + if (method === 'update_frame_height') { + setIFrameHeight(args.height); + } else if (method?.indexOf('xblock:') === 0) { + // This is a notification from the XBlock's frontend via 'runtime.notify(event, args)' + if (onBlockNotification) { + onBlockNotification({ + eventType: method.substr(7), // Remove the 'xblock:' prefix that we added in wrap.ts + ...args, + }); + } + } + }; + + /** + * Prepare to receive messages from the IFrame. + */ + useEffect(() => { + // Messages are the only way that the code in the IFrame can communicate + // with the surrounding UI. + window.addEventListener('message', receivedWindowMessage); + + return () => { + window.removeEventListener('message', receivedWindowMessage); + }; + }, []); + + const queryStr = version ? `?version=${version}` : ''; + + return ( +
+