Compare commits

..

14 Commits

Author SHA1 Message Date
Navin Karkera
fedb85577e feat: add temporary message alert in sections settings tab in libraries (#2734) (#2766)
- add temporary message alert in sections settings tab in libraries
- increase sidebar width to remove `More` option and display all tabs
together

(cherry picked from commit 3eeca244d7)
2025-12-19 16:36:31 -08:00
David Ormsbee
18e51db70a fix: support "in progress" status for lib upload
When uploading a library archive file during the creation of a new
library, the code prior to this commit did not properly handle the "In
Progress" state, which is when the celery task doing the archive
processing is actively running. Note that this is distinct from the
"Pending" state, which is when the task is waiting in the queue to be
run (which in practice should almost never happen unless there is an
operational issue).

Since celery tasks run in-process during local development, the task
was always finished by the time that the browser made a call to check
on the status. The problem only happened on slower sandboxes, where
processing truly runs asynchronously and might take a few seconds.
Because this case wasn't handled, the frontend would never poll for
updates either, so the upload was basically lost as far as the user
was concerned.
2025-12-12 21:37:59 -05:00
Rodrigo Mendez
4a1d0a2716 feat: Implement querying openedx-authz for publish permissions (#2685) (#2733) 2025-12-08 15:58:35 -05:00
Daniel Wong
2ba6f96142 feat: add support for origin server and user info (#2663) (#2710)
* feat: add support for origin server and user info

* test: add coverage for restore archive summary

* test: increase coverage for restore archive summary

* fix: address comments
2025-12-04 13:24:06 -06:00
Rômulo Penido
28f0c9943d fix: migrate library alert text (#2727)
Backport of #2651
2025-12-04 09:41:52 -05:00
Asad Ali
067806a0e6 fix: do not reload multiple tabs on block save (#2600) (#2705) 2025-12-01 18:13:45 -05:00
Kyle McCormick
7ebf349789 fix: "Back up" is two words when used as a verb (#2706)
There is a new menu item "Backup to local archive". Backup is the correct
spelling when using it as a noun or adjective, but the menu item uses as a
verb, so it should be two words, back up, i.e. "Back up to local archive"

Backports 70c19a3ffb
2025-11-26 12:18:57 -05:00
Navin Karkera
7a1bc3931a fix: don't revert to advanced editor if block contains copied_from fields (#2661) (#2695)
(cherry picked from commit 2215fc53cc)
2025-11-25 16:17:03 -05:00
Kyle McCormick
9bea56b3ae fix: Rename builtin discussion providers, "edX" -> "Open edX" (#2662)
Backports 5fadccabe2 to Ulmo
2025-11-18 10:46:38 -05:00
Muhammad Anas
c7a84a1a9c fix: unit button active state (#2617) (#2650) (backport) 2025-11-13 12:24:20 -05:00
Muhammad Arslan
ad0e1ae570 fix: broken Course Overview editor on Schedule & Details page (#2604) (backport) 2025-11-13 11:10:32 -05:00
Muhammad Arslan
bd00c3b271 fix: self-closing script tag fixed for TinyMceEditor (#2608) (backport) 2025-11-07 09:42:32 -08:00
Chris Chávez
de8b4b460b style: Update some texts in legacy libraries migration flow (#2601) (#2603) 2025-11-05 18:46:32 -05:00
Navin Karkera
fa2bd8a604 chore: backport latest bug fixes (#2602)
Backport of https://github.com/openedx/frontend-app-authoring/pull/2584 and https://github.com/openedx/frontend-app-authoring/pull/2587
2025-11-05 17:23:28 -05:00
963 changed files with 17421 additions and 27425 deletions

6
.env
View File

@@ -36,17 +36,15 @@ ENABLE_ASSETS_PAGE=false
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=false ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=false
ENABLE_TAGGING_TAXONOMY_PAGES=true ENABLE_TAGGING_TAXONOMY_PAGES=true
ENABLE_CERTIFICATE_PAGE=true ENABLE_CERTIFICATE_PAGE=true
ENABLE_COURSE_IMPORT_IN_LIBRARY=false
ENABLE_UNIT_PAGE_NEW_DESIGN=false
ENABLE_COURSE_OUTLINE_NEW_DESIGN=false
BBB_LEARN_MORE_URL='' BBB_LEARN_MORE_URL=''
HOTJAR_APP_ID='' HOTJAR_APP_ID=''
HOTJAR_VERSION=6 HOTJAR_VERSION=6
HOTJAR_DEBUG=false HOTJAR_DEBUG=false
INVITE_STUDENTS_EMAIL_TO='' INVITE_STUDENTS_EMAIL_TO=''
ENABLE_CHECKLIST_QUALITY='' ENABLE_CHECKLIST_QUALITY=''
ENABLE_GRADING_METHOD_IN_PROBLEMS=false
# "Multi-level" blocks are unsupported in libraries # "Multi-level" blocks are unsupported in libraries
LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder,library_content,itembank" LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder"
# Fallback in local style files # Fallback in local style files
PARAGON_THEME_URLS={} PARAGON_THEME_URLS={}
COURSE_TEAM_SUPPORT_EMAIL='' COURSE_TEAM_SUPPORT_EMAIL=''

View File

@@ -37,9 +37,6 @@ ENABLE_UNIT_PAGE=false
ENABLE_ASSETS_PAGE=false ENABLE_ASSETS_PAGE=false
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=true ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=true
ENABLE_CERTIFICATE_PAGE=true ENABLE_CERTIFICATE_PAGE=true
ENABLE_COURSE_IMPORT_IN_LIBRARY=true
ENABLE_COURSE_OUTLINE_NEW_DESIGN=true
ENABLE_UNIT_PAGE_NEW_DESIGN=true
ENABLE_NEW_VIDEO_UPLOAD_PAGE=true ENABLE_NEW_VIDEO_UPLOAD_PAGE=true
ENABLE_TAGGING_TAXONOMY_PAGES=true ENABLE_TAGGING_TAXONOMY_PAGES=true
BBB_LEARN_MORE_URL='' BBB_LEARN_MORE_URL=''
@@ -48,8 +45,9 @@ HOTJAR_VERSION=6
HOTJAR_DEBUG=true HOTJAR_DEBUG=true
INVITE_STUDENTS_EMAIL_TO="someone@domain.com" INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
ENABLE_CHECKLIST_QUALITY=true ENABLE_CHECKLIST_QUALITY=true
ENABLE_GRADING_METHOD_IN_PROBLEMS=false
# "Multi-level" blocks are unsupported in libraries # "Multi-level" blocks are unsupported in libraries
LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder,library_content,itembank" LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder"
# Fallback in local style files # Fallback in local style files
PARAGON_THEME_URLS={} PARAGON_THEME_URLS={}
COURSE_TEAM_SUPPORT_EMAIL='' COURSE_TEAM_SUPPORT_EMAIL=''

View File

@@ -33,14 +33,12 @@ ENABLE_UNIT_PAGE=true
ENABLE_ASSETS_PAGE=false ENABLE_ASSETS_PAGE=false
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=true ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=true
ENABLE_CERTIFICATE_PAGE=true ENABLE_CERTIFICATE_PAGE=true
ENABLE_COURSE_IMPORT_IN_LIBRARY=true
ENABLE_COURSE_OUTLINE_NEW_DESIGN=false
ENABLE_UNIT_PAGE_NEW_DESIGN=false
ENABLE_TAGGING_TAXONOMY_PAGES=true ENABLE_TAGGING_TAXONOMY_PAGES=true
BBB_LEARN_MORE_URL='' BBB_LEARN_MORE_URL=''
INVITE_STUDENTS_EMAIL_TO="someone@domain.com" INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
ENABLE_CHECKLIST_QUALITY=true ENABLE_CHECKLIST_QUALITY=true
ENABLE_GRADING_METHOD_IN_PROBLEMS=false
# "Multi-level" blocks are unsupported in libraries # "Multi-level" blocks are unsupported in libraries
LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder,library_content,itembank" LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder"
PARAGON_THEME_URLS= PARAGON_THEME_URLS=
COURSE_TEAM_SUPPORT_EMAIL='support@example.com' COURSE_TEAM_SUPPORT_EMAIL='support@example.com'

View File

@@ -14,15 +14,6 @@ module.exports = createConfig(
'no-restricted-exports': 'off', 'no-restricted-exports': 'off',
// There is no reason to disallow this syntax anymore; we don't use regenerator-runtime in new browsers // There is no reason to disallow this syntax anymore; we don't use regenerator-runtime in new browsers
'no-restricted-syntax': 'off', 'no-restricted-syntax': 'off',
'no-restricted-imports': ['error', {
patterns: [
{
group: ['@edx/frontend-platform/i18n'],
importNames: ['injectIntl'],
message: "Use 'useIntl' hook instead of injectIntl.",
},
],
}],
}, },
settings: { settings: {
// Import URLs should be resolved using aliases // Import URLs should be resolved using aliases

View File

@@ -30,9 +30,9 @@ We're trying to move away from some deprecated patterns in this codebase. Please
check if your PR meets these recommendations before asking for a review: check if your PR meets these recommendations before asking for a review:
- [ ] Any _new_ files are using TypeScript (`.ts`, `.tsx`). - [ ] Any _new_ files are using TypeScript (`.ts`, `.tsx`).
- [ ] Avoid `propTypes` and `defaultProps` in any new or modified code. - [ ] Deprecated `propTypes`, `defaultProps`, and `injectIntl` patterns are not used in any new or modified code.
- [ ] Tests should use the helpers in `src/testUtils.tsx` (specifically `initializeMocks`) - [ ] Tests should use the helpers in `src/testUtils.tsx` (specifically `initializeMocks`)
- [ ] Do not add new fields to the Redux state/store. Use React Context to share state among multiple components. - [ ] Do not add new fields to the Redux state/store. Use React Context to share state among multiple components.
- [ ] Use React Query to load data from REST APIs. See any `apiHooks.ts` in this repo for examples. - [ ] Use React Query to load data from REST APIs. See any `apiHooks.ts` in this repo for examples.
- [ ] All new i18n messages in `messages.ts` files have a `description` for translators to use. - [ ] All new i18n messages in `messages.ts` files have a `description` for translators to use.
- [ ] Avoid using `../` in import paths. To import from parent folders, use `@src`, e.g. `import { initializeMocks } from '@src/testUtils';` instead of `from '../../../../testUtils'` - [ ] Imports avoid using `../`. To import from parent folders, use `@src`, e.g. `import { initializeMocks } from '@src/testUtils';` instead of `from '../../../../testUtils'`

View File

@@ -11,13 +11,13 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v5
- uses: actions/setup-node@v6 - uses: actions/setup-node@v6
with: with:
node-version-file: '.nvmrc' node-version-file: '.nvmrc'
- run: make validate.ci - run: make validate.ci
- name: Archive code coverage results - name: Archive code coverage results
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v5
with: with:
name: code-coverage-report name: code-coverage-report
path: coverage/*.* path: coverage/*.*
@@ -25,9 +25,9 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: tests needs: tests
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v5
- name: Download code coverage results - name: Download code coverage results
uses: actions/download-artifact@v8 uses: actions/download-artifact@v6
with: with:
pattern: code-coverage-report pattern: code-coverage-report
path: coverage path: coverage

View File

@@ -1,23 +0,0 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"categories": {
"correctness": "warn"
},
"rules": {
"eslint/no-unused-vars": "off",
"typescript/unbound-method": "off", // 🛑 TEMPORARY
"typescript/no-floating-promises": ["error", {
"allowForKnownSafeCalls": [
// queryClient.invalidateQueries returns a promise that can be awaited
// if you want to do something after all the subsequent refetches are
// complete, but we rarely if ever want that; we usually want to
// continue invalidating more things immediately. So we don't usually
// want to await this.
"invalidateQueries",
]
}]
},
"ignorePatterns": [
"webpack.dev-tutor.config.js",
]
}

View File

@@ -51,9 +51,7 @@ validate-no-uncommitted-package-lock-changes:
validate: validate:
make validate-no-uncommitted-package-lock-changes make validate-no-uncommitted-package-lock-changes
npm run i18n_extract npm run i18n_extract
# We are trying out oxlint. Now that it's been working well for a while with both oxlint and eslint, we have disabled npm run lint -- --max-warnings 0
# eslint, and after a few weeks we'll evaluate whether any problems are slipping through if only oxlint is used.
npm run oxlint
npm run types npm run types
npm run test:ci npm run test:ci
npm run build npm run build

View File

@@ -40,7 +40,7 @@ Cloning and Setup
2. Use the version of Node specified in the ``.nvmrc`` file. 2. Use the version of Node specified in the ``.nvmrc`` file.
The current version of the micro-frontend build scripts supports the version of Node found in ``.nvmrc``. 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 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 convenience, this repository includes an ``.nvmrc`` file to help in setting the
correct node version via `nvm <https://github.com/nvm-sh/nvm>`_. correct node version via `nvm <https://github.com/nvm-sh/nvm>`_.
@@ -97,7 +97,7 @@ Troubleshooting
* If tutor-mfe is not starting the authoring MFE in development mode (eg. `tutor dev start authoring` fails), it may be due to * 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 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, 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>`__. `this forum post <https://discuss.openedx.org/t/repo-rename-frontend-app-course-authoring-frontend-app-authoring/13930/2>`__.
@@ -175,6 +175,10 @@ Feature: New Proctoring Exams View
Requirements Requirements
------------ ------------
* ``edx-platform`` Django settings:
* ``ZENDESK_*``: necessary if automatic ZenDesk ticket creation is desired
* `edx-exams <https://github.com/edx/edx-exams>`_: for this feature to work, the ``edx-exams`` IDA must be deployed and its API accessible by the browser * `edx-exams <https://github.com/edx/edx-exams>`_: for this feature to work, the ``edx-exams`` IDA must be deployed and its API accessible by the browser
Configuration Configuration
@@ -192,6 +196,7 @@ In Studio, a new item ("Proctored Exam Settings") is added to "Other Course Sett
* Enable proctored exams for the course * Enable proctored exams for the course
* Allow opting out of proctored exams * Allow opting out of proctored exams
* Select a proctoring provider * Select a proctoring provider
* Enable automatic creation of Zendesk tickets for "suspicious" proctored exam attempts
Feature: Advanced Settings Feature: Advanced Settings
========================== ==========================
@@ -234,7 +239,7 @@ Configuration
In additional to the standard settings, the following local configuration items are required: In additional to the standard settings, the following local configuration items are required:
* ``ENABLE_TAGGING_TAXONOMY_PAGES``: must be enabled (which it is by default) in order to actually enable/show the new * ``ENABLE_TAGGING_TAXONOMY_PAGES``: must be enabled (which it is by default) in order to actually enable/show the new
Tagging/Taxonomy functionality. Tagging/Taxonomy functionality.
@@ -268,7 +273,7 @@ Troubleshooting
======================== ========================
* ``npm ERR! gyp ERR! build error`` while running npm install on Macs with M1 processors: Probably due to a compatibility issue of node-canvas with M1. * ``npm ERR! gyp ERR! build error`` while running npm install on Macs with M1 processors: Probably due to a compatibility issue of node-canvas with M1.
Run ``brew install pkg-config pixman cairo pango libpng jpeg giflib librsvg`` before ``npm install`` to get the correct versions of the dependencies. Run ``brew install pkg-config pixman cairo pango libpng jpeg giflib librsvg`` before ``npm install`` to get the correct versions of the dependencies.
If there is still an error, look for "no package [...] found" in the error message and install missing package via brew. If there is still an error, look for "no package [...] found" in the error message and install missing package via brew.
(https://github.com/Automattic/node-canvas/issues/1733) (https://github.com/Automattic/node-canvas/issues/1733)

View File

@@ -1,80 +0,0 @@
Override External URLs
======================
What is getExternalLinkUrl?
---------------------------
The `getExternalLinkUrl` function is a utility from `@edx/frontend-platform` that allows for centralized management of external URLs. It enables the override of external links through configuration, making it possible to customize external references without modifying the source code directly.
URLs wrapped with getExternalLinkUrl
------------------------------------
Use cases:
1. **Accessibility Page** (`src/accessibility-page/AccessibilityPage.jsx`)
- `COMMUNITY_ACCESSIBILITY_LINK` - Points to community accessibility resources: https://www.edx.org/accessibility
2. **Course Outline** (if applicable)
- Documentation links
- Help resources
3. **Other pages** (search for `getExternalLinkUrl` usage across the codebase)
- Help documentation
- External tool integrations
Following external URLs are wrapped with `getExternalLinkUrl` in the authoring application:
- 'https://www.edx.org/accessibility'
- 'https://docs.openedx.org/en/latest/educators/concepts/exercise_tools/about_multi_select.html'
- 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/exercise_tools/add_multi_select.html'
- 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/exercise_tools/add_dropdown.html'
- 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/exercise_tools/manage_numerical_input_problem.html'
- 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/exercise_tools/add_text_input.html'
- 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html'
- 'https://docs.openedx.org/en/latest/educators/references/course_development/exercise_tools/guide_problem_types.html#advanced-problem-types'
- 'https://docs.openedx.org/en/latest/educators/references/course_development/parent_child_components.html'
- 'https://openai.com/api-data-privacy'
- 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/create_new_library.html'
- 'https://bigbluebutton.org/privacy-policy/'
- 'https://creativecommons.org/about'
Note: as new external URLs are added to the codebase, more URLs will be wrapped with `getExternalLinkUrl` and this list may not always be up to date.
How to Override External URLs
-----------------------------
To override external URLs, you can use the frontend platform's configuration system.
This object should be added to the config object defined in the env.config.[js,jsx,ts,tsx], and must be named externalLinkUrlOverrides.
1. **Environment Configuration**
Add the URL overrides to your environment configuration:
.. code-block:: javascript
const config = {
// Other config options...
externalLinkUrlOverrides: {
'https://www.edx.org/accessibility': 'https://your-custom-domain.com/accessibility',
// Add other URL overrides here
}
};
Examples
--------
**Original URL:** Default community accessibility link
**Override:** Your institution's accessibility policy page
.. code-block:: javascript
// In your app configuration
getExternalLinkUrl('https://www.edx.org/accessibility')
// Returns: 'https://your-custom-domain.com/accessibility'
// Instead of the default Open edX community link
Benefits
--------
- **Customization**: Institutions can point to their own resources
- **Maintainability**: URLs can be changed without code modifications
- **Consistency**: Centralized URL management across the application
- **Flexibility**: Different environments can have different external links

8949
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -14,7 +14,6 @@
"i18n_extract": "fedx-scripts formatjs extract --include=plugins", "i18n_extract": "fedx-scripts formatjs extract --include=plugins",
"stylelint": "stylelint \"plugins/**/*.scss\" \"src/**/*.scss\" \"scss/**/*.scss\" --config .stylelintrc.json", "stylelint": "stylelint \"plugins/**/*.scss\" \"src/**/*.scss\" \"scss/**/*.scss\" --config .stylelintrc.json",
"lint": "npm run stylelint && fedx-scripts eslint --ext .js --ext .jsx --ext .ts --ext .tsx .", "lint": "npm run stylelint && fedx-scripts eslint --ext .js --ext .jsx --ext .ts --ext .tsx .",
"oxlint": "oxlint --type-aware --deny-warnings",
"lint:fix": "npm run stylelint -- --fix && fedx-scripts eslint --fix --ext .js --ext .jsx --ext .ts --ext .tsx .", "lint:fix": "npm run stylelint -- --fix && fedx-scripts eslint --fix --ext .js --ext .jsx --ext .ts --ext .tsx .",
"start": "fedx-scripts webpack-dev-server --progress", "start": "fedx-scripts webpack-dev-server --progress",
"start:with-theme": "paragon install-theme && npm start && npm install", "start:with-theme": "paragon install-theme && npm start && npm install",
@@ -44,14 +43,13 @@
"@dnd-kit/sortable": "^8.0.0", "@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.3", "@edx/brand": "npm:@openedx/brand-openedx@^1.2.3",
"@edx/browserslist-config": "1.5.1", "@edx/browserslist-config": "1.5.0",
"@edx/frontend-component-footer": "^14.9.0", "@edx/frontend-component-footer": "^14.9.0",
"@edx/frontend-component-header": "^8.1.0", "@edx/frontend-component-header": "^8.1.0",
"@edx/frontend-enterprise-hotjar": "^7.2.0", "@edx/frontend-enterprise-hotjar": "^7.2.0",
"@edx/frontend-platform": "^8.4.0", "@edx/frontend-platform": "^8.4.0",
"@edx/openedx-atlas": "^0.7.0", "@edx/openedx-atlas": "^0.7.0",
"@openedx-plugins/course-app-calculator": "file:plugins/course-apps/calculator", "@openedx-plugins/course-app-calculator": "file:plugins/course-apps/calculator",
"@openedx-plugins/course-app-dates": "file:plugins/course-apps/dates",
"@openedx-plugins/course-app-edxnotes": "file:plugins/course-apps/edxnotes", "@openedx-plugins/course-app-edxnotes": "file:plugins/course-apps/edxnotes",
"@openedx-plugins/course-app-learning_assistant": "file:plugins/course-apps/learning_assistant", "@openedx-plugins/course-app-learning_assistant": "file:plugins/course-apps/learning_assistant",
"@openedx-plugins/course-app-live": "file:plugins/course-apps/live", "@openedx-plugins/course-app-live": "file:plugins/course-apps/live",
@@ -62,35 +60,35 @@
"@openedx-plugins/course-app-wiki": "file:plugins/course-apps/wiki", "@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-plugins/course-app-xpert_unit_summary": "file:plugins/course-apps/xpert_unit_summary",
"@openedx/frontend-build": "^14.6.2", "@openedx/frontend-build": "^14.6.2",
"@openedx/frontend-plugin-framework": "^1.8.0", "@openedx/frontend-plugin-framework": "^1.7.0",
"@openedx/paragon": "^23.5.0", "@openedx/paragon": "^23.5.0",
"@redux-devtools/extension": "^3.3.0", "@redux-devtools/extension": "^3.3.0",
"@reduxjs/toolkit": "2.11.2", "@reduxjs/toolkit": "1.9.7",
"@tanstack/react-query": "5.90.21", "@tanstack/react-query": "5.90.5",
"@tinymce/tinymce-react": "^6.0.0", "@tinymce/tinymce-react": "^6.0.0",
"classnames": "2.5.1", "classnames": "2.5.1",
"codemirror": "^6.0.0", "codemirror": "^6.0.0",
"email-validator": "2.0.4", "email-validator": "2.0.4",
"fast-xml-parser": "^5.0.0", "fast-xml-parser": "^5.0.0",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"formik": "2.4.9", "formik": "2.4.6",
"frontend-components-tinymce-advanced-plugins": "^1.0.3", "frontend-components-tinymce-advanced-plugins": "^1.0.3",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"lodash": "4.17.23", "lodash": "4.17.21",
"meilisearch": "^0.41.0", "meilisearch": "^0.41.0",
"moment": "2.30.1", "moment": "2.30.1",
"moment-shortformat": "^2.1.0", "moment-shortformat": "^2.1.0",
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
"react": "^18.3.1", "react": "^18.3.1",
"react-datepicker": "^8.10.0", "react-datepicker": "^4.13.0",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-error-boundary": "^4.0.13", "react-error-boundary": "^4.0.13",
"react-helmet": "^6.1.0", "react-helmet": "^6.1.0",
"react-onclickoutside": "^6.13.0", "react-onclickoutside": "^6.13.0",
"react-redux": "7.2.9", "react-redux": "7.2.9",
"react-responsive": "10.0.1", "react-responsive": "9.0.2",
"react-router": "6.30.3", "react-router": "6.30.1",
"react-router-dom": "6.30.3", "react-router-dom": "6.30.1",
"react-select": "5.10.2", "react-select": "5.10.2",
"react-textarea-autosize": "^8.5.3", "react-textarea-autosize": "^8.5.3",
"react-transition-group": "4.4.5", "react-transition-group": "4.4.5",
@@ -118,8 +116,6 @@
"fetch-mock-jest": "^1.5.1", "fetch-mock-jest": "^1.5.1",
"jest-canvas-mock": "^2.5.2", "jest-canvas-mock": "^2.5.2",
"jest-expect-message": "^1.1.3", "jest-expect-message": "^1.1.3",
"oxlint": "^1.42.0",
"oxlint-tsgolint": "^0.16.0",
"react-test-renderer": "^18.3.1", "react-test-renderer": "^18.3.1",
"redux-mock-store": "^1.5.4" "redux-mock-store": "^1.5.4"
} }

View File

@@ -1,29 +0,0 @@
import React from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
import messages from './messages';
type DatesSettingsProps = {
onClose: () => void;
};
const DatesSettings: React.FC<DatesSettingsProps> = ({ onClose }) => {
const intl = useIntl();
return (
<AppSettingsModal
appId="dates"
title={intl.formatMessage(messages.heading)}
enableAppHelp={intl.formatMessage(messages.enableAppHelp)}
enableAppLabel={intl.formatMessage(messages.enableAppLabel)}
learnMoreText={intl.formatMessage(messages.learnMore)}
onClose={onClose}
validationSchema={{}}
initialValues={{}}
onSettingsSave={async () => true}
/>
);
};
export default DatesSettings;

View File

@@ -1,26 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
heading: {
id: 'course-authoring.pages-resources.dates.heading',
defaultMessage: 'Configure dates',
description: 'Heading for the Dates settings modal shown in Pages & Resources.',
},
enableAppLabel: {
id: 'course-authoring.pages-resources.dates.enable-app.label',
defaultMessage: 'Dates',
description: 'Label for the toggle that enables the Dates experience.',
},
enableAppHelp: {
id: 'course-authoring.pages-resources.dates.enable-app.help',
defaultMessage: 'Show the Dates tab in course navigation, where learners can view important course dates.',
description: 'Helper text explaining what enabling the Dates experience does.',
},
learnMore: {
id: 'course-authoring.pages-resources.dates.learn-more',
defaultMessage: 'Learn more about dates',
description: 'Link text that leads to documentation about the Dates experience.',
},
});
export default messages;

View File

@@ -1,17 +0,0 @@
{
"name": "@openedx-plugins/course-app-dates",
"version": "0.1.0",
"description": "Dates configuration for courses using it",
"peerDependencies": {
"@edx/frontend-app-authoring": "*",
"@edx/frontend-platform": "*",
"@openedx/paragon": "*",
"prop-types": "*",
"react": "*"
},
"peerDependenciesMeta": {
"@edx/frontend-app-authoring": {
"optional": true
}
}
}

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { getConfig, getExternalLinkUrl } from '@edx/frontend-platform'; import { getConfig } from '@edx/frontend-platform';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import { Form, Hyperlink } from '@openedx/paragon'; import { Form, Hyperlink } from '@openedx/paragon';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
@@ -93,7 +93,7 @@ const BbbSettings = ({
<span data-testid="free-plan-message"> <span data-testid="free-plan-message">
{intl.formatMessage(messages.freePlanMessage)} {intl.formatMessage(messages.freePlanMessage)}
<Hyperlink <Hyperlink
destination={getExternalLinkUrl('https://bigbluebutton.org/privacy-policy/')} destination="https://bigbluebutton.org/privacy-policy/"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
showLaunchIcon showLaunchIcon

View File

@@ -4,16 +4,21 @@ import {
getByRole, getByRole,
getAllByRole, getAllByRole,
waitForElementToBeRemoved, waitForElementToBeRemoved,
initializeMocks, } from '@testing-library/react';
} from 'CourseAuthoring/testUtils';
import ReactDOM from 'react-dom'; 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 userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import initializeStore from 'CourseAuthoring/store';
import { executeThunk } from 'CourseAuthoring/utils'; import { executeThunk } from 'CourseAuthoring/utils';
import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider'; import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
import { CourseAuthoringProvider } from 'CourseAuthoring/CourseAuthoringContext';
import LiveSettings from './Settings'; import LiveSettings from './Settings';
import { import {
generateLiveConfigurationApiResponse, generateLiveConfigurationApiResponse,
@@ -35,20 +40,17 @@ ReactDOM.createPortal = jest.fn(node => node);
const renderComponent = () => { const renderComponent = () => {
const wrapper = render( const wrapper = render(
<CourseAuthoringProvider courseId={courseId}> <IntlProvider locale="en">
<PagesAndResourcesProvider courseId={courseId}> <AppProvider store={store} wrapWithRouter={false}>
<LiveSettings onClose={() => {}} /> <PagesAndResourcesProvider courseId={courseId}>
</PagesAndResourcesProvider> <MemoryRouter initialEntries={[liveSettingsUrl]}>
</CourseAuthoringProvider>, <Routes>
{ <Route path={liveSettingsUrl} element={<PageWrap><LiveSettings onClose={() => {}} /></PageWrap>} />
path: liveSettingsUrl, </Routes>
routerProps: { </MemoryRouter>
initialEntries: [liveSettingsUrl], </PagesAndResourcesProvider>
}, </AppProvider>
params: { </IntlProvider>,
courseId,
},
},
); );
container = wrapper.container; container = wrapper.container;
}; };
@@ -72,9 +74,16 @@ const mockStore = async ({
describe('BBB Settings', () => { describe('BBB Settings', () => {
beforeEach(async () => { beforeEach(async () => {
const mocks = initializeMocks({ initialState }); initializeMockApp({
store = mocks.reduxStore; authenticatedUser: {
axiosMock = mocks.axiosMock; userId: 3,
username: 'abc123',
administrator: false,
roles: [],
},
});
store = initializeStore(initialState);
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
}); });
test('Plan dropdown to be visible and enabled in UI', async () => { test('Plan dropdown to be visible and enabled in UI', async () => {

View File

@@ -1,4 +1,3 @@
// oxlint-disable unicorn/no-thenable
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { camelCase } from 'lodash'; import { camelCase } from 'lodash';
@@ -12,7 +11,6 @@ import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-m
import { useModel } from 'CourseAuthoring/generic/model-store'; import { useModel } from 'CourseAuthoring/generic/model-store';
import Loading from 'CourseAuthoring/generic/Loading'; import Loading from 'CourseAuthoring/generic/Loading';
import { RequestStatus } from 'CourseAuthoring/data/constants'; import { RequestStatus } from 'CourseAuthoring/data/constants';
import { useCourseAuthoringContext } from 'CourseAuthoring/CourseAuthoringContext';
import { fetchLiveData, saveLiveConfiguration, saveLiveConfigurationAsDraft } from './data/thunks'; import { fetchLiveData, saveLiveConfiguration, saveLiveConfigurationAsDraft } from './data/thunks';
import { selectApp } from './data/slice'; import { selectApp } from './data/slice';
@@ -27,7 +25,7 @@ const LiveSettings = ({
const intl = useIntl(); const intl = useIntl();
const navigate = useNavigate(); const navigate = useNavigate();
const dispatch = useDispatch(); const dispatch = useDispatch();
const { courseId } = useCourseAuthoringContext(); const courseId = useSelector(state => state.courseDetail.courseId);
const availableProviders = useSelector((state) => state.live.appIds); const availableProviders = useSelector((state) => state.live.appIds);
const { const {
piiSharingAllowed, selectedAppId, enabled, status, piiSharingAllowed, selectedAppId, enabled, status,
@@ -73,7 +71,6 @@ const LiveSettings = ({
}; };
const handleSettingsSave = async (values) => { const handleSettingsSave = async (values) => {
// oxlint-disable-next-line @typescript-eslint/await-thenable - this dispatch() IS returning a promise.
await dispatch(saveLiveConfiguration(courseId, values, navigate)); await dispatch(saveLiveConfiguration(courseId, values, navigate));
}; };

View File

@@ -8,14 +8,20 @@ import {
queryByText, queryByText,
getByRole, getByRole,
waitForElementToBeRemoved, waitForElementToBeRemoved,
initializeMocks, } from '@testing-library/react';
} from 'CourseAuthoring/testUtils';
import ReactDOM from 'react-dom'; 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 { executeThunk } from 'CourseAuthoring/utils';
import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider'; import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
import { CourseAuthoringProvider } from 'CourseAuthoring/CourseAuthoringContext';
import LiveSettings from './Settings'; import LiveSettings from './Settings';
import { import {
generateLiveConfigurationApiResponse, generateLiveConfigurationApiResponse,
@@ -38,20 +44,17 @@ ReactDOM.createPortal = jest.fn(node => node);
const renderComponent = () => { const renderComponent = () => {
const wrapper = render( const wrapper = render(
<PagesAndResourcesProvider courseId={courseId}> <IntlProvider locale="en">
<CourseAuthoringProvider> <AppProvider store={store} wrapWithRouter={false}>
<LiveSettings onClose={() => {}} /> <PagesAndResourcesProvider courseId={courseId}>
</CourseAuthoringProvider> <MemoryRouter initialEntries={[liveSettingsUrl]}>
</PagesAndResourcesProvider>, <Routes>
{ <Route path={liveSettingsUrl} element={<PageWrap><LiveSettings onClose={() => {}} /></PageWrap>} />
path: liveSettingsUrl, </Routes>
routerProps: { </MemoryRouter>
initialEntries: [liveSettingsUrl], </PagesAndResourcesProvider>
}, </AppProvider>
params: { </IntlProvider>,
courseId,
},
},
); );
container = wrapper.container; container = wrapper.container;
}; };
@@ -74,11 +77,16 @@ const mockStore = async ({
describe('LiveSettings', () => { describe('LiveSettings', () => {
beforeEach(async () => { beforeEach(async () => {
const mocks = initializeMocks({ initializeMockApp({
initialState, authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: false,
roles: [],
},
}); });
store = mocks.reduxStore; store = initializeStore(initialState);
axiosMock = mocks.axiosMock; axiosMock = new MockAdapter(getAuthenticatedHttpClient());
}); });
test('Live Configuration modal is visible', async () => { test('Live Configuration modal is visible', async () => {

View File

@@ -3,14 +3,19 @@ import {
queryByTestId, queryByTestId,
getByRole, getByRole,
waitForElementToBeRemoved, waitForElementToBeRemoved,
initializeMocks, } from '@testing-library/react';
} from 'CourseAuthoring/testUtils';
import ReactDOM from 'react-dom'; 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 { executeThunk } from 'CourseAuthoring/utils';
import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider'; import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
import { CourseAuthoringProvider } from 'CourseAuthoring/CourseAuthoringContext';
import LiveSettings from './Settings'; import LiveSettings from './Settings';
import { import {
generateLiveConfigurationApiResponse, generateLiveConfigurationApiResponse,
@@ -33,20 +38,17 @@ ReactDOM.createPortal = jest.fn(node => node);
const renderComponent = () => { const renderComponent = () => {
const wrapper = render( const wrapper = render(
<CourseAuthoringProvider courseId={courseId}> <IntlProvider locale="en">
<PagesAndResourcesProvider courseId={courseId}> <AppProvider store={store} wrapWithRouter={false}>
<LiveSettings onClose={() => {}} /> <PagesAndResourcesProvider courseId={courseId}>
</PagesAndResourcesProvider> <MemoryRouter initialEntries={[liveSettingsUrl]}>
</CourseAuthoringProvider>, <Routes>
{ <Route path={liveSettingsUrl} element={<PageWrap><LiveSettings onClose={() => {}} /></PageWrap>} />
path: liveSettingsUrl, </Routes>
routerProps: { </MemoryRouter>
initialEntries: [liveSettingsUrl], </PagesAndResourcesProvider>
}, </AppProvider>
params: { </IntlProvider>,
courseId,
},
},
); );
container = wrapper.container; container = wrapper.container;
}; };
@@ -69,9 +71,16 @@ const mockStore = async ({
describe('Zoom Settings', () => { describe('Zoom Settings', () => {
beforeEach(async () => { beforeEach(async () => {
const mocks = initializeMocks({ initialState }); initializeMockApp({
store = mocks.reduxStore; authenticatedUser: {
axiosMock = mocks.axiosMock; userId: 3,
username: 'abc123',
administrator: false,
roles: [],
},
});
store = initializeStore(initialState);
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
}); });
test('LTI fields are visible when pii sharing is enabled', async () => { test('LTI fields are visible when pii sharing is enabled', async () => {

View File

@@ -48,9 +48,8 @@ const ORASettings = ({ onClose }) => {
event.preventDefault(); event.preventDefault();
success = success && await handleSettingsSave(formValues); success = success && await handleSettingsSave(formValues);
setSaveError(!success); await setSaveError(!success);
if ((initialFormValues.enableFlexiblePeerGrade !== formValues.enableFlexiblePeerGrade) && success) { if ((initialFormValues.enableFlexiblePeerGrade !== formValues.enableFlexiblePeerGrade) && success) {
// oxlint-disable-next-line @typescript-eslint/await-thenable - this dispatch() IS returning a promise.
success = await dispatch(updateModel({ success = await dispatch(updateModel({
modelType: 'courseApps', modelType: 'courseApps',
model: { model: {

View File

@@ -128,7 +128,7 @@ describe('ORASettings', () => {
await mockStore({ apiStatus: 200, enabled: true }); await mockStore({ apiStatus: 200, enabled: true });
renderComponent(); renderComponent();
const checkbox = screen.getByRole('checkbox', { name: /Flex Peer Grading/ }); const checkbox = await screen.getByRole('checkbox', { name: /Flex Peer Grading/ });
expect(checkbox).toBeChecked(); expect(checkbox).toBeChecked();
await waitFor(() => { await waitFor(() => {

View File

@@ -22,7 +22,6 @@ import { useModel } from 'CourseAuthoring/generic/model-store';
import PermissionDeniedAlert from 'CourseAuthoring/generic/PermissionDeniedAlert'; import PermissionDeniedAlert from 'CourseAuthoring/generic/PermissionDeniedAlert';
import { useIsMobile } from 'CourseAuthoring/utils'; import { useIsMobile } from 'CourseAuthoring/utils';
import { PagesAndResourcesContext } from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider'; import { PagesAndResourcesContext } from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
import { useCourseAuthoringContext } from 'CourseAuthoring/CourseAuthoringContext';
import messages from './messages'; import messages from './messages';
@@ -33,6 +32,7 @@ const ProctoringSettings = ({ onClose }) => {
proctoringProvider: false, proctoringProvider: false,
escalationEmail: '', escalationEmail: '',
allowOptingOut: false, allowOptingOut: false,
createZendeskTickets: false,
}; };
const [formValues, setFormValues] = useState(initialFormValues); const [formValues, setFormValues] = useState(initialFormValues);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -41,7 +41,6 @@ const ProctoringSettings = ({ onClose }) => {
const [loadingPermissionError, setLoadingPermissionError] = useState(false); const [loadingPermissionError, setLoadingPermissionError] = useState(false);
const [allowLtiProviders, setAllowLtiProviders] = useState(false); const [allowLtiProviders, setAllowLtiProviders] = useState(false);
const [availableProctoringProviders, setAvailableProctoringProviders] = useState([]); const [availableProctoringProviders, setAvailableProctoringProviders] = useState([]);
const [requiresEscalationEmailProviders, setRequiresEscalationEmailProviders] = useState([]);
const [ltiProctoringProviders, setLtiProctoringProviders] = useState([]); const [ltiProctoringProviders, setLtiProctoringProviders] = useState([]);
const [courseStartDate, setCourseStartDate] = useState(''); const [courseStartDate, setCourseStartDate] = useState('');
const [saveSuccess, setSaveSuccess] = useState(false); const [saveSuccess, setSaveSuccess] = useState(false);
@@ -66,7 +65,7 @@ const ProctoringSettings = ({ onClose }) => {
} }
const { courseId } = useContext(PagesAndResourcesContext); const { courseId } = useContext(PagesAndResourcesContext);
const { courseDetails } = useCourseAuthoringContext(); const courseDetails = useModel('courseDetails', courseId);
const org = courseDetails?.org; const org = courseDetails?.org;
const appInfo = useModel('courseApps', 'proctoring'); const appInfo = useModel('courseApps', 'proctoring');
const alertRef = React.createRef(); const alertRef = React.createRef();
@@ -79,14 +78,18 @@ const ProctoringSettings = ({ onClose }) => {
const value = target.type === 'checkbox' ? target.checked : target.value; const value = target.type === 'checkbox' ? target.checked : target.value;
const { name } = target; const { name } = target;
if (['allowOptingOut'].includes(name)) { if (['allowOptingOut', 'createZendeskTickets'].includes(name)) {
// Form.Radio expects string values, so convert back to a boolean here // Form.Radio expects string values, so convert back to a boolean here
setFormValues({ ...formValues, [name]: value === 'true' }); setFormValues({ ...formValues, [name]: value === 'true' });
} else if (name === 'proctoringProvider') { } else if (name === 'proctoringProvider') {
const newFormValues = { ...formValues, proctoringProvider: value }; const newFormValues = { ...formValues, proctoringProvider: value };
if (requiresEscalationEmailProviders.includes(value)) {
setFormValues({ ...newFormValues }); if (value === 'proctortrack') {
setFormValues({ ...newFormValues, createZendeskTickets: false });
setShowEscalationEmail(true); setShowEscalationEmail(true);
} else if (value === 'software_secure') {
setFormValues({ ...newFormValues, createZendeskTickets: true });
setShowEscalationEmail(false);
} else if (isLtiProvider(value)) { } else if (isLtiProvider(value)) {
setFormValues(newFormValues); setFormValues(newFormValues);
setShowEscalationEmail(true); setShowEscalationEmail(true);
@@ -113,13 +116,14 @@ const ProctoringSettings = ({ onClose }) => {
enable_proctored_exams: formValues.enableProctoredExams, enable_proctored_exams: formValues.enableProctoredExams,
// lti providers are managed outside edx-platform, lti_external indicates this // lti providers are managed outside edx-platform, lti_external indicates this
proctoring_provider: isLtiProviderSelected ? 'lti_external' : selectedProvider, proctoring_provider: isLtiProviderSelected ? 'lti_external' : selectedProvider,
create_zendesk_tickets: formValues.createZendeskTickets,
}, },
}; };
if (isEdxStaff) { if (isEdxStaff) {
studioDataToPostBack.proctored_exam_settings.allow_proctoring_opt_out = formValues.allowOptingOut; studioDataToPostBack.proctored_exam_settings.allow_proctoring_opt_out = formValues.allowOptingOut;
} }
if (requiresEscalationEmailProviders.includes(formValues.proctoringProvider)) { if (formValues.proctoringProvider === 'proctortrack') {
studioDataToPostBack.proctored_exam_settings.proctoring_escalation_email = formValues.escalationEmail === '' ? null : formValues.escalationEmail; studioDataToPostBack.proctored_exam_settings.proctoring_escalation_email = formValues.escalationEmail === '' ? null : formValues.escalationEmail;
} }
@@ -156,7 +160,7 @@ const ProctoringSettings = ({ onClose }) => {
event.preventDefault(); event.preventDefault();
const isLtiProviderSelected = isLtiProvider(formValues.proctoringProvider); const isLtiProviderSelected = isLtiProvider(formValues.proctoringProvider);
if ( if (
(requiresEscalationEmailProviders.includes(formValues.proctoringProvider) || isLtiProviderSelected) (formValues.proctoringProvider === 'proctortrack' || isLtiProviderSelected)
&& !EmailValidator.validate(formValues.escalationEmail) && !EmailValidator.validate(formValues.escalationEmail)
&& !(formValues.escalationEmail === '' && !formValues.enableProctoredExams) && !(formValues.escalationEmail === '' && !formValues.enableProctoredExams)
) { ) {
@@ -383,6 +387,29 @@ const ProctoringSettings = ({ onClose }) => {
</Form.Group> </Form.Group>
</fieldset> </fieldset>
)} )}
{/* CREATE ZENDESK TICKETS */}
{ isEdxStaff && formValues.enableProctoredExams && !isLtiProviderSelected && (
<fieldset aria-describedby="createZendeskTicketsText">
<Form.Group controlId="formCreateZendeskTickets">
<Form.Label as="legend" className="font-weight-bold">
{intl.formatMessage(messages['authoring.proctoring.createzendesk.label'])}
</Form.Label>
<Form.RadioSet
name="createZendeskTickets"
value={formValues.createZendeskTickets.toString()}
onChange={handleChange}
>
<Form.Radio value="true" data-testid="createZendeskTicketsYes">
{intl.formatMessage(messages['authoring.proctoring.yes'])}
</Form.Radio>
<Form.Radio value="false" data-testid="createZendeskTicketsNo">
{intl.formatMessage(messages['authoring.proctoring.no'])}
</Form.Radio>
</Form.RadioSet>
</Form.Group>
</fieldset>
)}
</> </>
); );
} }
@@ -500,7 +527,6 @@ const ProctoringSettings = ({ onClose }) => {
setSubmissionInProgress(false); setSubmissionInProgress(false);
setCourseStartDate(settingsResponse.data.course_start_date); setCourseStartDate(settingsResponse.data.course_start_date);
setAvailableProctoringProviders(settingsResponse.data.available_proctoring_providers); setAvailableProctoringProviders(settingsResponse.data.available_proctoring_providers);
setRequiresEscalationEmailProviders(settingsResponse.data.requires_escalation_email_providers);
// The list of providers returned by studio settings are the default behavior. If lti_external // The list of providers returned by studio settings are the default behavior. If lti_external
// is available as an option display the list of LTI providers returned by the exam service. // is available as an option display the list of LTI providers returned by the exam service.
@@ -528,11 +554,10 @@ const ProctoringSettings = ({ onClose }) => {
selectedProvider = proctoredExamSettings.proctoring_provider; selectedProvider = proctoredExamSettings.proctoring_provider;
} }
const requiresEscalationEmailProvidersList = settingsResponse.data.requires_escalation_email_providers; const isProctortrack = selectedProvider === 'proctortrack';
const isEscalationEmailRequired = requiresEscalationEmailProvidersList.includes(selectedProvider);
const ltiProviderSelected = proctoringProvidersLti.some(p => p.name === selectedProvider); const ltiProviderSelected = proctoringProvidersLti.some(p => p.name === selectedProvider);
if (isEscalationEmailRequired || ltiProviderSelected) { if (isProctortrack || ltiProviderSelected) {
setShowEscalationEmail(true); setShowEscalationEmail(true);
} }
@@ -545,6 +570,7 @@ const ProctoringSettings = ({ onClose }) => {
proctoringProvider: selectedProvider, proctoringProvider: selectedProvider,
enableProctoredExams: proctoredExamSettings.enable_proctored_exams, enableProctoredExams: proctoredExamSettings.enable_proctored_exams,
allowOptingOut: proctoredExamSettings.allow_proctoring_opt_out, allowOptingOut: proctoredExamSettings.allow_proctoring_opt_out,
createZendeskTickets: proctoredExamSettings.create_zendesk_tickets,
// The backend API may return null for the proctoringEscalationEmail value, which is the default. // The backend API may return null for the proctoringEscalationEmail value, which is the default.
// In order to keep our email input component controlled, we use the empty string as the default // In order to keep our email input component controlled, we use the empty string as the default
// and perform this conversion during GETs and POSTs. // and perform this conversion during GETs and POSTs.

View File

@@ -1,15 +1,18 @@
import React from 'react';
import { import {
render, screen, cleanup, waitFor, fireEvent, act, render, screen, cleanup, waitFor, fireEvent, act,
initializeMocks, } from '@testing-library/react';
} from 'CourseAuthoring/testUtils'; import MockAdapter from 'axios-mock-adapter';
import { mergeConfig } from '@edx/frontend-platform'; import { initializeMockApp, mergeConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
import { AppProvider } from '@edx/frontend-platform/react';
import StudioApiService from 'CourseAuthoring/data/services/StudioApiService'; import StudioApiService from 'CourseAuthoring/data/services/StudioApiService';
import ExamsApiService from 'CourseAuthoring/data/services/ExamsApiService'; import ExamsApiService from 'CourseAuthoring/data/services/ExamsApiService';
import initializeStore from 'CourseAuthoring/store';
import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider'; import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
import { CourseAuthoringProvider } from 'CourseAuthoring/CourseAuthoringContext';
import { getCourseDetailsUrl } from 'CourseAuthoring/data/api';
import ProctoredExamSettings from './Settings'; import ProctoredExamSettings from './Settings';
const courseId = 'course-v1%3AedX%2BDemoX%2BDemo_Course'; const courseId = 'course-v1%3AedX%2BDemoX%2BDemo_Course';
@@ -17,57 +20,57 @@ const defaultProps = {
courseId, courseId,
onClose: () => {}, onClose: () => {},
}; };
const IntlProctoredExamSettings = injectIntl(ProctoredExamSettings);
let store;
const renderComponent = children => ( const intlWrapper = children => (
<CourseAuthoringProvider courseId={defaultProps.courseId}> <AppProvider store={store}>
<PagesAndResourcesProvider courseId={defaultProps.courseId}> <PagesAndResourcesProvider courseId={defaultProps.courseId}>
{children} <IntlProvider locale="en">
{children}
</IntlProvider>
</PagesAndResourcesProvider> </PagesAndResourcesProvider>
</CourseAuthoringProvider> </AppProvider>
); );
let axiosMock; let axiosMock;
describe('ProctoredExamSettings', () => { describe('ProctoredExamSettings', () => {
/**
* @param {boolean} isAdmin
* @param {string | undefined} org
*/
function setupApp(isAdmin = true, org = undefined) { function setupApp(isAdmin = true, org = undefined) {
mergeConfig({ mergeConfig({
EXAMS_BASE_URL: 'http://exams.testing.co', EXAMS_BASE_URL: 'http://exams.testing.co',
}, 'CourseAuthoringConfig'); }, 'CourseAuthoringConfig');
const user = {
userId: 3, initializeMockApp({
username: 'abc123', authenticatedUser: {
administrator: isAdmin, userId: 3,
roles: [], username: 'abc123',
}; administrator: isAdmin,
const mocks = initializeMocks({ roles: [],
user, },
initialState: { });
models: { store = initializeStore({
courseApps: { models: {
proctoring: {}, courseApps: {
proctoring: {},
},
courseDetails: {
[courseId]: {
start: Date(),
}, },
}, },
...(org ? { courseDetails: { [courseId]: { org } } } : {}),
}, },
}); });
axiosMock = mocks.axiosMock; axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock axiosMock.onGet(
.onGet(getCourseDetailsUrl(courseId, user.username)) `${ExamsApiService.getExamsBaseUrl()}/api/v1/providers${org ? `?org=${org}` : ''}`,
.reply(200, { ).reply(200, [
courseId, {
name: 'Course Test', name: 'test_lti',
start: Date(), verbose_name: 'LTI Provider',
...(org ? { org } : {}), },
}); ]);
axiosMock.onGet(`${ExamsApiService.getExamsBaseUrl()}/api/v1/providers`)
.reply(200, [{ name: 'test_lti', verbose_name: 'LTI Provider' }]);
if (org) {
axiosMock.onGet(`${ExamsApiService.getExamsBaseUrl()}/api/v1/providers?org=${org}`)
.reply(200, [{ name: 'test_lti', verbose_name: 'LTI Provider' }]);
}
axiosMock.onGet( axiosMock.onGet(
`${ExamsApiService.getExamsBaseUrl()}/api/v1/configs/course_id/${defaultProps.courseId}`, `${ExamsApiService.getExamsBaseUrl()}/api/v1/configs/course_id/${defaultProps.courseId}`,
).reply(200, { ).reply(200, {
@@ -82,20 +85,54 @@ describe('ProctoredExamSettings', () => {
allow_proctoring_opt_out: false, allow_proctoring_opt_out: false,
proctoring_provider: 'mockproc', proctoring_provider: 'mockproc',
proctoring_escalation_email: 'test@example.com', proctoring_escalation_email: 'test@example.com',
create_zendesk_tickets: true,
}, },
available_proctoring_providers: ['software_secure', 'mockproc', 'lti_external'], available_proctoring_providers: ['software_secure', 'proctortrack', 'mockproc', 'lti_external'],
requires_escalation_email_providers: ['test_lti'],
course_start_date: '2070-01-01T00:00:00Z', course_start_date: '2070-01-01T00:00:00Z',
}); });
} }
afterEach(() => {
cleanup();
axiosMock.reset();
});
beforeEach(async () => { beforeEach(async () => {
setupApp(); setupApp();
}); });
describe('Field dependencies', () => { describe('Field dependencies', () => {
beforeEach(async () => { beforeEach(async () => {
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />))); await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
});
it('Updates Zendesk ticket field if proctortrack is provider', async () => {
await waitFor(() => {
screen.getByDisplayValue('mockproc');
});
const selectElement = screen.getByDisplayValue('mockproc');
fireEvent.change(selectElement, { target: { value: 'proctortrack' } });
const zendeskTicketInput = screen.getByTestId('createZendeskTicketsNo');
expect(zendeskTicketInput.checked).toEqual(true);
});
it('Updates Zendesk ticket field if software_secure is provider', async () => {
await waitFor(() => {
screen.getByDisplayValue('mockproc');
});
const selectElement = screen.getByDisplayValue('mockproc');
fireEvent.change(selectElement, { target: { value: 'software_secure' } });
const zendeskTicketInput = screen.getByTestId('createZendeskTicketsYes');
expect(zendeskTicketInput.checked).toEqual(true);
});
it('Does not update zendesk ticket field for any other provider', async () => {
await waitFor(() => {
screen.getByDisplayValue('mockproc');
});
const selectElement = screen.getByDisplayValue('mockproc');
fireEvent.change(selectElement, { target: { value: 'mockproc' } });
const zendeskTicketInput = screen.getByTestId('createZendeskTicketsYes');
expect(zendeskTicketInput.checked).toEqual(true);
}); });
it('Hides all other fields when enabledProctorExam is false when first loaded', async () => { it('Hides all other fields when enabledProctorExam is false when first loaded', async () => {
@@ -109,13 +146,13 @@ describe('ProctoredExamSettings', () => {
allow_proctoring_opt_out: false, allow_proctoring_opt_out: false,
proctoring_provider: 'mockproc', proctoring_provider: 'mockproc',
proctoring_escalation_email: 'test@example.com', proctoring_escalation_email: 'test@example.com',
create_zendesk_tickets: true,
}, },
available_proctoring_providers: ['software_secure', 'mockproc'], available_proctoring_providers: ['software_secure', 'proctortrack', 'mockproc'],
requires_escalation_email_providers: [],
course_start_date: '2070-01-01T00:00:00Z', course_start_date: '2070-01-01T00:00:00Z',
}); });
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />))); await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
await waitFor(() => { await waitFor(() => {
screen.getByText('Proctored exams'); screen.getByText('Proctored exams');
}); });
@@ -124,6 +161,8 @@ describe('ProctoredExamSettings', () => {
expect(screen.queryByText('Allow Opting Out of Proctored Exams')).toBeNull(); expect(screen.queryByText('Allow Opting Out of Proctored Exams')).toBeNull();
expect(screen.queryByDisplayValue('mockproc')).toBeNull(); expect(screen.queryByDisplayValue('mockproc')).toBeNull();
expect(screen.queryByTestId('escalationEmail')).toBeNull(); expect(screen.queryByTestId('escalationEmail')).toBeNull();
expect(screen.queryByTestId('createZendeskTicketsYes')).toBeNull();
expect(screen.queryByTestId('createZendeskTicketsNo')).toBeNull();
}); });
it('Hides all other fields when enableProctoredExams toggled to false', async () => { it('Hides all other fields when enableProctoredExams toggled to false', async () => {
@@ -133,6 +172,8 @@ describe('ProctoredExamSettings', () => {
expect(screen.queryByText('Allow opting out of proctored exams')).toBeDefined(); expect(screen.queryByText('Allow opting out of proctored exams')).toBeDefined();
expect(screen.queryByDisplayValue('mockproc')).toBeDefined(); expect(screen.queryByDisplayValue('mockproc')).toBeDefined();
expect(screen.queryByTestId('escalationEmail')).toBeDefined(); expect(screen.queryByTestId('escalationEmail')).toBeDefined();
expect(screen.queryByTestId('createZendeskTicketsYes')).toBeDefined();
expect(screen.queryByTestId('createZendeskTicketsNo')).toBeDefined();
let enabledProctoredExamCheck = screen.getAllByLabelText('Proctored exams', { exact: false })[0]; let enabledProctoredExamCheck = screen.getAllByLabelText('Proctored exams', { exact: false })[0];
expect(enabledProctoredExamCheck.checked).toEqual(true); expect(enabledProctoredExamCheck.checked).toEqual(true);
@@ -142,6 +183,8 @@ describe('ProctoredExamSettings', () => {
expect(screen.queryByText('Allow opting out of proctored exams')).toBeNull(); expect(screen.queryByText('Allow opting out of proctored exams')).toBeNull();
expect(screen.queryByDisplayValue('mockproc')).toBeNull(); expect(screen.queryByDisplayValue('mockproc')).toBeNull();
expect(screen.queryByTestId('escalationEmail')).toBeNull(); expect(screen.queryByTestId('escalationEmail')).toBeNull();
expect(screen.queryByTestId('createZendeskTicketsYes')).toBeNull();
expect(screen.queryByTestId('createZendeskTicketsNo')).toBeNull();
}); });
it('Hides unsupported fields when lti provider is selected', async () => { it('Hides unsupported fields when lti provider is selected', async () => {
@@ -151,11 +194,13 @@ describe('ProctoredExamSettings', () => {
const selectElement = screen.getByDisplayValue('mockproc'); const selectElement = screen.getByDisplayValue('mockproc');
fireEvent.change(selectElement, { target: { value: 'test_lti' } }); fireEvent.change(selectElement, { target: { value: 'test_lti' } });
expect(screen.queryByTestId('allowOptingOutRadio')).toBeNull(); expect(screen.queryByTestId('allowOptingOutRadio')).toBeNull();
expect(screen.queryByTestId('createZendeskTicketsYes')).toBeNull();
expect(screen.queryByTestId('createZendeskTicketsNo')).toBeNull();
}); });
}); });
describe('Validation with invalid escalation email', () => { describe('Validation with invalid escalation email', () => {
const proctoringProvidersRequiringEscalationEmail = ['test_lti']; const proctoringProvidersRequiringEscalationEmail = ['proctortrack', 'test_lti'];
beforeEach(async () => { beforeEach(async () => {
axiosMock.onGet( axiosMock.onGet(
@@ -164,21 +209,14 @@ describe('ProctoredExamSettings', () => {
proctored_exam_settings: { proctored_exam_settings: {
enable_proctored_exams: true, enable_proctored_exams: true,
allow_proctoring_opt_out: false, allow_proctoring_opt_out: false,
proctoring_provider: 'lti_external', proctoring_provider: 'proctortrack',
proctoring_escalation_email: 'test@example.com', proctoring_escalation_email: 'test@example.com',
create_zendesk_tickets: true,
}, },
available_proctoring_providers: ['software_secure', 'mockproc', 'lti_external'], available_proctoring_providers: ['software_secure', 'proctortrack', 'mockproc', 'lti_external'],
requires_escalation_email_providers: ['test_lti'],
course_start_date: '2070-01-01T00:00:00Z', course_start_date: '2070-01-01T00:00:00Z',
}); });
axiosMock.onGet(
`${ExamsApiService.getExamsBaseUrl()}/api/v1/configs/course_id/${defaultProps.courseId}`,
).reply(200, {
provider: 'test_lti',
escalation_email: 'test@example.com',
});
axiosMock.onPatch( axiosMock.onPatch(
ExamsApiService.getExamConfigurationUrl(defaultProps.courseId), ExamsApiService.getExamConfigurationUrl(defaultProps.courseId),
).reply(204, {}); ).reply(204, {});
@@ -187,13 +225,13 @@ describe('ProctoredExamSettings', () => {
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId), StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).reply(200, {}); ).reply(200, {});
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />))); await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
}); });
proctoringProvidersRequiringEscalationEmail.forEach(provider => { proctoringProvidersRequiringEscalationEmail.forEach(provider => {
it(`Creates an alert when no proctoring escalation email is provided with ${provider} selected`, async () => { it(`Creates an alert when no proctoring escalation email is provided with ${provider} selected`, async () => {
await waitFor(() => { await waitFor(() => {
screen.getByDisplayValue('LTI Provider'); screen.getByDisplayValue('proctortrack');
}); });
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com'); const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
fireEvent.change(selectEscalationEmailElement, { target: { value: '' } }); fireEvent.change(selectEscalationEmailElement, { target: { value: '' } });
@@ -214,10 +252,10 @@ describe('ProctoredExamSettings', () => {
it(`Creates an alert when invalid proctoring escalation email is provided with ${provider} selected`, async () => { it(`Creates an alert when invalid proctoring escalation email is provided with ${provider} selected`, async () => {
await waitFor(() => { await waitFor(() => {
screen.getByDisplayValue('LTI Provider'); screen.getByDisplayValue('proctortrack');
}); });
const selectElement = screen.getByDisplayValue('LTI Provider'); const selectElement = screen.getByDisplayValue('proctortrack');
fireEvent.change(selectElement, { target: { value: provider } }); fireEvent.change(selectElement, { target: { value: provider } });
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com'); const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
@@ -240,7 +278,7 @@ describe('ProctoredExamSettings', () => {
it('Creates an alert when invalid proctoring escalation email is provided with proctoring disabled', async () => { it('Creates an alert when invalid proctoring escalation email is provided with proctoring disabled', async () => {
await waitFor(() => { await waitFor(() => {
screen.getByDisplayValue('LTI Provider'); screen.getByDisplayValue('proctortrack');
}); });
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com'); const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
fireEvent.change(selectEscalationEmailElement, { target: { value: 'foo.bar' } }); fireEvent.change(selectEscalationEmailElement, { target: { value: 'foo.bar' } });
@@ -258,7 +296,7 @@ describe('ProctoredExamSettings', () => {
it('Has no error when empty proctoring escalation email is provided with proctoring disabled', async () => { it('Has no error when empty proctoring escalation email is provided with proctoring disabled', async () => {
await waitFor(() => { await waitFor(() => {
screen.getByDisplayValue('LTI Provider'); screen.getByDisplayValue('proctortrack');
}); });
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com'); const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
fireEvent.change(selectEscalationEmailElement, { target: { value: '' } }); fireEvent.change(selectEscalationEmailElement, { target: { value: '' } });
@@ -281,7 +319,7 @@ describe('ProctoredExamSettings', () => {
it(`Has no error when valid proctoring escalation email is provided with ${provider} selected`, async () => { it(`Has no error when valid proctoring escalation email is provided with ${provider} selected`, async () => {
await waitFor(() => { await waitFor(() => {
screen.getByDisplayValue('LTI Provider'); screen.getByDisplayValue('proctortrack');
}); });
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com'); const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
fireEvent.change(selectEscalationEmailElement, { target: { value: 'foo@bar.com' } }); fireEvent.change(selectEscalationEmailElement, { target: { value: 'foo@bar.com' } });
@@ -302,9 +340,9 @@ describe('ProctoredExamSettings', () => {
it(`Escalation email field hidden when proctoring backend is not ${provider}`, async () => { it(`Escalation email field hidden when proctoring backend is not ${provider}`, async () => {
await waitFor(() => { await waitFor(() => {
screen.getByDisplayValue('LTI Provider'); screen.getByDisplayValue('proctortrack');
}); });
const proctoringBackendSelect = screen.getByDisplayValue('LTI Provider'); const proctoringBackendSelect = screen.getByDisplayValue('proctortrack');
const selectEscalationEmailElement = screen.getByTestId('escalationEmail'); const selectEscalationEmailElement = screen.getByTestId('escalationEmail');
expect(selectEscalationEmailElement.value).toEqual('test@example.com'); expect(selectEscalationEmailElement.value).toEqual('test@example.com');
fireEvent.change(proctoringBackendSelect, { target: { value: 'software_secure' } }); fireEvent.change(proctoringBackendSelect, { target: { value: 'software_secure' } });
@@ -313,13 +351,13 @@ describe('ProctoredExamSettings', () => {
it(`Escalation email Field Show when proctoring backend is switched back to ${provider}`, async () => { it(`Escalation email Field Show when proctoring backend is switched back to ${provider}`, async () => {
await waitFor(() => { await waitFor(() => {
screen.getByDisplayValue('LTI Provider'); screen.getByDisplayValue('proctortrack');
}); });
const proctoringBackendSelect = screen.getByDisplayValue('LTI Provider'); const proctoringBackendSelect = screen.getByDisplayValue('proctortrack');
let selectEscalationEmailElement = screen.getByTestId('escalationEmail'); let selectEscalationEmailElement = screen.getByTestId('escalationEmail');
fireEvent.change(proctoringBackendSelect, { target: { value: 'software_secure' } }); fireEvent.change(proctoringBackendSelect, { target: { value: 'software_secure' } });
expect(screen.queryByTestId('escalationEmail')).toBeNull(); expect(screen.queryByTestId('escalationEmail')).toBeNull();
fireEvent.change(proctoringBackendSelect, { target: { value: provider } }); fireEvent.change(proctoringBackendSelect, { target: { value: 'proctortrack' } });
expect(screen.queryByTestId('escalationEmail')).toBeDefined(); expect(screen.queryByTestId('escalationEmail')).toBeDefined();
selectEscalationEmailElement = screen.getByTestId('escalationEmail'); selectEscalationEmailElement = screen.getByTestId('escalationEmail');
expect(selectEscalationEmailElement.value).toEqual('test@example.com'); expect(selectEscalationEmailElement.value).toEqual('test@example.com');
@@ -327,7 +365,7 @@ describe('ProctoredExamSettings', () => {
it('Submits form when "Enter" key is hit in the escalation email field', async () => { it('Submits form when "Enter" key is hit in the escalation email field', async () => {
await waitFor(() => { await waitFor(() => {
screen.getByDisplayValue('LTI Provider'); screen.getByDisplayValue('proctortrack');
}); });
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com'); const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
fireEvent.change(selectEscalationEmailElement, { target: { value: '' } }); fireEvent.change(selectEscalationEmailElement, { target: { value: '' } });
@@ -345,9 +383,9 @@ describe('ProctoredExamSettings', () => {
allow_proctoring_opt_out: false, allow_proctoring_opt_out: false,
proctoring_provider: 'mockproc', proctoring_provider: 'mockproc',
proctoring_escalation_email: 'test@example.com', proctoring_escalation_email: 'test@example.com',
create_zendesk_tickets: true,
}, },
available_proctoring_providers: ['software_secure', 'mockproc'], available_proctoring_providers: ['software_secure', 'proctortrack', 'mockproc'],
requires_escalation_email_providers: [],
course_start_date: '2099-01-01T00:00:00Z', course_start_date: '2099-01-01T00:00:00Z',
}; };
@@ -357,9 +395,9 @@ describe('ProctoredExamSettings', () => {
allow_proctoring_opt_out: false, allow_proctoring_opt_out: false,
proctoring_provider: 'mockproc', proctoring_provider: 'mockproc',
proctoring_escalation_email: 'test@example.com', proctoring_escalation_email: 'test@example.com',
create_zendesk_tickets: true,
}, },
available_proctoring_providers: ['software_secure', 'mockproc'], available_proctoring_providers: ['software_secure', 'proctortrack', 'mockproc'],
requires_escalation_email_providers: [],
course_start_date: '2013-01-01T00:00:00Z', course_start_date: '2013-01-01T00:00:00Z',
}; };
@@ -371,8 +409,8 @@ describe('ProctoredExamSettings', () => {
const isAdmin = false; const isAdmin = false;
setupApp(isAdmin); setupApp(isAdmin);
mockCourseData(mockGetPastCourseData); mockCourseData(mockGetPastCourseData);
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />))); await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const providerOption = screen.getByTestId('software_secure'); const providerOption = screen.getByTestId('proctortrack');
expect(providerOption.hasAttribute('disabled')).toEqual(true); expect(providerOption.hasAttribute('disabled')).toEqual(true);
}); });
@@ -380,8 +418,8 @@ describe('ProctoredExamSettings', () => {
const isAdmin = false; const isAdmin = false;
setupApp(isAdmin); setupApp(isAdmin);
mockCourseData(mockGetFutureCourseData); mockCourseData(mockGetFutureCourseData);
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />))); await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const providerOption = screen.getByTestId('software_secure'); const providerOption = screen.getByTestId('proctortrack');
expect(providerOption.hasAttribute('disabled')).toEqual(false); expect(providerOption.hasAttribute('disabled')).toEqual(false);
}); });
@@ -390,8 +428,8 @@ describe('ProctoredExamSettings', () => {
const org = 'test-org'; const org = 'test-org';
setupApp(isAdmin, org); setupApp(isAdmin, org);
mockCourseData(mockGetFutureCourseData); mockCourseData(mockGetFutureCourseData);
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />))); await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const providerOption = screen.getByTestId('software_secure'); const providerOption = screen.getByTestId('proctortrack');
expect(providerOption.hasAttribute('disabled')).toEqual(false); expect(providerOption.hasAttribute('disabled')).toEqual(false);
}); });
@@ -399,8 +437,8 @@ describe('ProctoredExamSettings', () => {
const isAdmin = true; const isAdmin = true;
setupApp(isAdmin); setupApp(isAdmin);
mockCourseData(mockGetPastCourseData); mockCourseData(mockGetPastCourseData);
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />))); await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const providerOption = screen.getByTestId('software_secure'); const providerOption = screen.getByTestId('proctortrack');
expect(providerOption.hasAttribute('disabled')).toEqual(false); expect(providerOption.hasAttribute('disabled')).toEqual(false);
}); });
@@ -408,18 +446,18 @@ describe('ProctoredExamSettings', () => {
const isAdmin = true; const isAdmin = true;
setupApp(isAdmin); setupApp(isAdmin);
mockCourseData(mockGetFutureCourseData); mockCourseData(mockGetFutureCourseData);
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />))); await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const providerOption = screen.getByTestId('software_secure'); const providerOption = screen.getByTestId('proctortrack');
expect(providerOption.hasAttribute('disabled')).toEqual(false); expect(providerOption.hasAttribute('disabled')).toEqual(false);
}); });
it('Does not include lti_external as a selectable option', async () => { it('Does not include lti_external as a selectable option', async () => {
const courseData = { const courseData = {
...mockGetFutureCourseData, ...mockGetFutureCourseData,
available_proctoring_providers: ['lti_external', 'mockproc'], available_proctoring_providers: ['lti_external', 'proctortrack', 'mockproc'],
}; };
mockCourseData(courseData); mockCourseData(courseData);
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />))); await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
await waitFor(() => { await waitFor(() => {
screen.getByDisplayValue('mockproc'); screen.getByDisplayValue('mockproc');
}); });
@@ -429,10 +467,10 @@ describe('ProctoredExamSettings', () => {
it('Includes lti proctoring provider options when lti_external is allowed by studio', async () => { it('Includes lti proctoring provider options when lti_external is allowed by studio', async () => {
const courseData = { const courseData = {
...mockGetFutureCourseData, ...mockGetFutureCourseData,
available_proctoring_providers: ['lti_external', 'mockproc'], available_proctoring_providers: ['lti_external', 'proctortrack', 'mockproc'],
}; };
mockCourseData(courseData); mockCourseData(courseData);
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />))); await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
await waitFor(() => { await waitFor(() => {
screen.getByDisplayValue('mockproc'); screen.getByDisplayValue('mockproc');
}); });
@@ -445,7 +483,7 @@ describe('ProctoredExamSettings', () => {
const isAdmin = true; const isAdmin = true;
setupApp(isAdmin); setupApp(isAdmin);
mockCourseData(mockGetFutureCourseData); mockCourseData(mockGetFutureCourseData);
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />))); await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
await waitFor(() => { await waitFor(() => {
screen.getByDisplayValue('mockproc'); screen.getByDisplayValue('mockproc');
}); });
@@ -459,20 +497,18 @@ describe('ProctoredExamSettings', () => {
EXAMS_BASE_URL: null, EXAMS_BASE_URL: null,
}, 'CourseAuthoringConfig'); }, 'CourseAuthoringConfig');
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />))); await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
await waitFor(() => { await waitFor(() => {
screen.getByDisplayValue('mockproc'); screen.getByDisplayValue('mockproc');
}); });
// (1) for studio settings // only outgoing request should be for studio settings
// (2) waffle flags expect(axiosMock.history.get.length).toBe(1);
// (3) for course details
expect(axiosMock.history.get.length).toBe(3);
expect(axiosMock.history.get[0].url.includes('proctored_exam_settings')).toEqual(true); expect(axiosMock.history.get[0].url.includes('proctored_exam_settings')).toEqual(true);
}); });
it('Selected LTI proctoring provider is shown on page load', async () => { it('Selected LTI proctoring provider is shown on page load', async () => {
const courseData = { ...mockGetFutureCourseData }; const courseData = { ...mockGetFutureCourseData };
courseData.available_proctoring_providers = ['lti_external', 'mockproc']; courseData.available_proctoring_providers = ['lti_external', 'proctortrack', 'mockproc'];
courseData.proctored_exam_settings.proctoring_provider = 'lti_external'; courseData.proctored_exam_settings.proctoring_provider = 'lti_external';
mockCourseData(courseData); mockCourseData(courseData);
axiosMock.onGet( axiosMock.onGet(
@@ -480,7 +516,7 @@ describe('ProctoredExamSettings', () => {
).reply(200, { ).reply(200, {
provider: 'test_lti', provider: 'test_lti',
}); });
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />))); await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
await waitFor(() => { await waitFor(() => {
screen.getByText('Proctoring provider'); screen.getByText('Proctoring provider');
}); });
@@ -491,22 +527,24 @@ describe('ProctoredExamSettings', () => {
}); });
describe('Toggles field visibility based on user permissions', () => { describe('Toggles field visibility based on user permissions', () => {
it('Hides opting out for non edX staff', async () => { it('Hides opting out and zendesk tickets for non edX staff', async () => {
setupApp(false); setupApp(false);
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />))); await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
expect(screen.queryByTestId('allowOptingOutYes')).toBeNull(); expect(screen.queryByTestId('allowOptingOutYes')).toBeNull();
expect(screen.queryByTestId('createZendeskTicketsYes')).toBeNull();
}); });
it('Shows opting out for edX staff', async () => { it('Shows opting out and zendesk tickets for edX staff', async () => {
setupApp(true); setupApp(true);
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />))); await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
expect(screen.queryByTestId('allowOptingOutYes')).not.toBeNull(); expect(screen.queryByTestId('allowOptingOutYes')).not.toBeNull();
expect(screen.queryByTestId('createZendeskTicketsYes')).not.toBeNull();
}); });
}); });
describe('Connection states', () => { describe('Connection states', () => {
it('Shows the spinner before the connection is complete', async () => { it('Shows the spinner before the connection is complete', async () => {
render(renderComponent(<ProctoredExamSettings {...defaultProps} />)); render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />));
const spinner = await screen.findByRole('status'); const spinner = await screen.findByRole('status');
expect(spinner.textContent).toEqual('Loading...'); expect(spinner.textContent).toEqual('Loading...');
}); });
@@ -516,7 +554,7 @@ describe('ProctoredExamSettings', () => {
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId), StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).reply(500); ).reply(500);
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />))); await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const connectionError = screen.getByTestId('connectionErrorAlert'); const connectionError = screen.getByTestId('connectionErrorAlert');
expect(connectionError.textContent).toEqual( expect(connectionError.textContent).toEqual(
expect.stringContaining('We encountered a technical error when loading this page.'), expect.stringContaining('We encountered a technical error when loading this page.'),
@@ -528,7 +566,7 @@ describe('ProctoredExamSettings', () => {
`${ExamsApiService.getExamsBaseUrl()}/api/v1/providers`, `${ExamsApiService.getExamsBaseUrl()}/api/v1/providers`,
).reply(500); ).reply(500);
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />))); await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const connectionError = screen.getByTestId('connectionErrorAlert'); const connectionError = screen.getByTestId('connectionErrorAlert');
expect(connectionError.textContent).toEqual( expect(connectionError.textContent).toEqual(
expect.stringContaining('We encountered a technical error when loading this page.'), expect.stringContaining('We encountered a technical error when loading this page.'),
@@ -540,7 +578,7 @@ describe('ProctoredExamSettings', () => {
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId), StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).reply(403); ).reply(403);
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />))); await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const permissionError = screen.getByTestId('permissionDeniedAlert'); const permissionError = screen.getByTestId('permissionDeniedAlert');
expect(permissionError.textContent).toEqual( expect(permissionError.textContent).toEqual(
expect.stringContaining('You are not authorized to view this page'), expect.stringContaining('You are not authorized to view this page'),
@@ -559,7 +597,7 @@ describe('ProctoredExamSettings', () => {
}); });
it('Disable button while submitting', async () => { it('Disable button while submitting', async () => {
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />))); await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
let submitButton = screen.getByTestId('submissionButton'); let submitButton = screen.getByTestId('submissionButton');
expect(screen.queryByTestId('saveInProgress')).toBeFalsy(); expect(screen.queryByTestId('saveInProgress')).toBeFalsy();
fireEvent.click(submitButton); fireEvent.click(submitButton);
@@ -568,30 +606,15 @@ describe('ProctoredExamSettings', () => {
expect(submitButton).toHaveAttribute('disabled'); expect(submitButton).toHaveAttribute('disabled');
}); });
it('Makes API call successfully with proctoring_escalation_email if test_lti', async () => { it('Makes API call successfully with proctoring_escalation_email if proctortrack', async () => {
// Setup mock to include test_lti as available provider await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
axiosMock.onGet( // Make a change to the provider to proctortrack and set the email
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).reply(200, {
proctored_exam_settings: {
enable_proctored_exams: true,
allow_proctoring_opt_out: false,
proctoring_provider: 'mockproc',
proctoring_escalation_email: 'test@example.com',
},
available_proctoring_providers: ['software_secure', 'mockproc', 'lti_external'],
requires_escalation_email_providers: ['test_lti'],
course_start_date: '2070-01-01T00:00:00Z',
});
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
// Make a change to the provider to test_lti and set the email
const selectElement = screen.getByDisplayValue('mockproc'); const selectElement = screen.getByDisplayValue('mockproc');
fireEvent.change(selectElement, { target: { value: 'test_lti' } }); fireEvent.change(selectElement, { target: { value: 'proctortrack' } });
const escalationEmail = screen.getByTestId('escalationEmail'); const escalationEmail = screen.getByTestId('escalationEmail');
expect(escalationEmail.value).toEqual('test@example.com'); expect(escalationEmail.value).toEqual('test@example.com');
fireEvent.change(escalationEmail, { target: { value: 'test_lti@example.com' } }); fireEvent.change(escalationEmail, { target: { value: 'proctortrack@example.com' } });
expect(escalationEmail.value).toEqual('test_lti@example.com'); expect(escalationEmail.value).toEqual('proctortrack@example.com');
const submitButton = screen.getByTestId('submissionButton'); const submitButton = screen.getByTestId('submissionButton');
fireEvent.click(submitButton); fireEvent.click(submitButton);
expect(axiosMock.history.post.length).toBe(1); expect(axiosMock.history.post.length).toBe(1);
@@ -599,15 +622,11 @@ describe('ProctoredExamSettings', () => {
proctored_exam_settings: { proctored_exam_settings: {
enable_proctored_exams: true, enable_proctored_exams: true,
allow_proctoring_opt_out: false, allow_proctoring_opt_out: false,
proctoring_provider: 'lti_external', proctoring_provider: 'proctortrack',
proctoring_escalation_email: 'test_lti@example.com', proctoring_escalation_email: 'proctortrack@example.com',
create_zendesk_tickets: false,
}, },
}); });
expect(axiosMock.history.patch.length).toBe(1);
expect(JSON.parse(axiosMock.history.patch[0].data)).toEqual({
provider: 'test_lti',
escalation_email: 'test_lti@example.com',
});
await waitFor(() => { await waitFor(() => {
const errorAlert = screen.getByTestId('saveSuccess'); const errorAlert = screen.getByTestId('saveSuccess');
@@ -618,10 +637,10 @@ describe('ProctoredExamSettings', () => {
}); });
}); });
it('Makes API call successfully without proctoring_escalation_email if not requiring escalation email', async () => { it('Makes API call successfully without proctoring_escalation_email if not proctortrack', async () => {
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />))); await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
// make sure we have not selected a provider requiring escalation email // make sure we have not selected proctortrack as the proctoring provider
expect(screen.getByDisplayValue('mockproc')).toBeDefined(); expect(screen.getByDisplayValue('mockproc')).toBeDefined();
const submitButton = screen.getByTestId('submissionButton'); const submitButton = screen.getByTestId('submissionButton');
@@ -632,6 +651,7 @@ describe('ProctoredExamSettings', () => {
enable_proctored_exams: true, enable_proctored_exams: true,
allow_proctoring_opt_out: false, allow_proctoring_opt_out: false,
proctoring_provider: 'mockproc', proctoring_provider: 'mockproc',
create_zendesk_tickets: true,
}, },
}); });
@@ -645,7 +665,7 @@ describe('ProctoredExamSettings', () => {
}); });
it('Successfully updates exam configuration and studio provider is set to "lti_external" for lti providers', async () => { it('Successfully updates exam configuration and studio provider is set to "lti_external" for lti providers', async () => {
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />))); await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
// Make a change to the provider to test_lti and set the email // Make a change to the provider to test_lti and set the email
const selectElement = screen.getByDisplayValue('mockproc'); const selectElement = screen.getByDisplayValue('mockproc');
fireEvent.change(selectElement, { target: { value: 'test_lti' } }); fireEvent.change(selectElement, { target: { value: 'test_lti' } });
@@ -672,7 +692,7 @@ describe('ProctoredExamSettings', () => {
enable_proctored_exams: true, enable_proctored_exams: true,
allow_proctoring_opt_out: false, allow_proctoring_opt_out: false,
proctoring_provider: 'lti_external', proctoring_provider: 'lti_external',
proctoring_escalation_email: 'test_lti@example.com', create_zendesk_tickets: true,
}, },
}); });
@@ -686,7 +706,7 @@ describe('ProctoredExamSettings', () => {
}); });
it('Sets exam service provider to null if a non-lti provider is selected', async () => { it('Sets exam service provider to null if a non-lti provider is selected', async () => {
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />))); await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const submitButton = screen.getByTestId('submissionButton'); const submitButton = screen.getByTestId('submissionButton');
fireEvent.click(submitButton); fireEvent.click(submitButton);
// update exam service config // update exam service config
@@ -702,6 +722,7 @@ describe('ProctoredExamSettings', () => {
enable_proctored_exams: true, enable_proctored_exams: true,
allow_proctoring_opt_out: false, allow_proctoring_opt_out: false,
proctoring_provider: 'mockproc', proctoring_provider: 'mockproc',
create_zendesk_tickets: true,
}, },
}); });
@@ -723,13 +744,13 @@ describe('ProctoredExamSettings', () => {
allow_proctoring_opt_out: false, allow_proctoring_opt_out: false,
proctoring_provider: 'mockproc', proctoring_provider: 'mockproc',
proctoring_escalation_email: 'test@example.com', proctoring_escalation_email: 'test@example.com',
create_zendesk_tickets: true,
}, },
available_proctoring_providers: ['software_secure', 'mockproc'], available_proctoring_providers: ['software_secure', 'proctortrack', 'mockproc'],
requires_escalation_email_providers: [],
course_start_date: '2070-01-01T00:00:00Z', course_start_date: '2070-01-01T00:00:00Z',
}); });
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />))); await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const submitButton = screen.getByTestId('submissionButton'); const submitButton = screen.getByTestId('submissionButton');
fireEvent.click(submitButton); fireEvent.click(submitButton);
// does not update exam service config // does not update exam service config
@@ -741,6 +762,7 @@ describe('ProctoredExamSettings', () => {
enable_proctored_exams: true, enable_proctored_exams: true,
allow_proctoring_opt_out: false, allow_proctoring_opt_out: false,
proctoring_provider: 'mockproc', proctoring_provider: 'mockproc',
create_zendesk_tickets: true,
}, },
}); });
@@ -758,7 +780,7 @@ describe('ProctoredExamSettings', () => {
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId), StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).reply(500); ).reply(500);
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />))); await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const submitButton = screen.getByTestId('submissionButton'); const submitButton = screen.getByTestId('submissionButton');
fireEvent.click(submitButton); fireEvent.click(submitButton);
expect(axiosMock.history.post.length).toBe(1); expect(axiosMock.history.post.length).toBe(1);
@@ -776,7 +798,7 @@ describe('ProctoredExamSettings', () => {
`${ExamsApiService.getExamsBaseUrl()}/api/v1/configs/course_id/${defaultProps.courseId}`, `${ExamsApiService.getExamsBaseUrl()}/api/v1/configs/course_id/${defaultProps.courseId}`,
).reply(500, 'error'); ).reply(500, 'error');
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />))); await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const submitButton = screen.getByTestId('submissionButton'); const submitButton = screen.getByTestId('submissionButton');
fireEvent.click(submitButton); fireEvent.click(submitButton);
expect(axiosMock.history.post.length).toBe(1); expect(axiosMock.history.post.length).toBe(1);
@@ -794,7 +816,7 @@ describe('ProctoredExamSettings', () => {
`${ExamsApiService.getExamsBaseUrl()}/api/v1/configs/course_id/${defaultProps.courseId}`, `${ExamsApiService.getExamsBaseUrl()}/api/v1/configs/course_id/${defaultProps.courseId}`,
).reply(403, 'error'); ).reply(403, 'error');
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />))); await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const submitButton = screen.getByTestId('submissionButton'); const submitButton = screen.getByTestId('submissionButton');
fireEvent.click(submitButton); fireEvent.click(submitButton);
expect(axiosMock.history.post.length).toBe(1); expect(axiosMock.history.post.length).toBe(1);
@@ -813,7 +835,7 @@ describe('ProctoredExamSettings', () => {
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId), StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).reply(500); ).reply(500);
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />))); await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const submitButton = screen.getByTestId('submissionButton'); const submitButton = screen.getByTestId('submissionButton');
fireEvent.click(submitButton); fireEvent.click(submitButton);
expect(axiosMock.history.post.length).toBe(1); expect(axiosMock.history.post.length).toBe(1);
@@ -840,5 +862,27 @@ describe('ProctoredExamSettings', () => {
expect(document.activeElement).toEqual(successAlert); expect(document.activeElement).toEqual(successAlert);
}); });
}); });
it('Include Zendesk ticket in post request if user is not an admin', async () => {
// use non-admin user for test
const isAdmin = false;
setupApp(isAdmin);
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
// Make a change to the proctoring provider
const selectElement = screen.getByDisplayValue('mockproc');
fireEvent.change(selectElement, { target: { value: 'proctortrack' } });
const submitButton = screen.getByTestId('submissionButton');
fireEvent.click(submitButton);
expect(axiosMock.history.post.length).toBe(1);
expect(JSON.parse(axiosMock.history.post[0].data)).toEqual({
proctored_exam_settings: {
enable_proctored_exams: true,
proctoring_provider: 'proctortrack',
proctoring_escalation_email: 'test@example.com',
create_zendesk_tickets: false,
},
});
});
}); });
}); });

View File

@@ -81,6 +81,11 @@ const messages = defineMessages({
defaultMessage: 'Allow learners to opt out of proctoring on proctored exams', defaultMessage: 'Allow learners to opt out of proctoring on proctored exams',
description: 'Label for radio selection allowing proctored exam opt out', description: 'Label for radio selection allowing proctored exam opt out',
}, },
'authoring.proctoring.createzendesk.label': {
id: 'authoring.proctoring.createzendesk.label',
defaultMessage: 'Create Zendesk tickets for suspicious attempts',
description: 'Label for Zendesk ticket creation radio select.',
},
'authoring.proctoring.error.single': { 'authoring.proctoring.error.single': {
id: 'authoring.proctoring.error.single', id: 'authoring.proctoring.error.single',
defaultMessage: 'There is 1 error in this form.', defaultMessage: 'There is 1 error in this form.',

View File

@@ -107,7 +107,6 @@ const TeamSettings = ({
) )
.when('enabled', { .when('enabled', {
is: true, is: true,
// oxlint-disable-next-line unicorn/no-thenable
then: Yup.array().min(1), then: Yup.array().min(1),
}) })
.default([]) .default([])

View File

@@ -19,7 +19,7 @@ export function updateXpertSettings(courseId, state) {
} }
dispatch(updateSavingStatus({ status: RequestStatus.FAILED })); dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
return false; return false;
} catch { } catch (error) {
dispatch(updateSavingStatus({ status: RequestStatus.FAILED })); dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
return false; return false;
} }
@@ -33,7 +33,7 @@ export function fetchXpertPluginConfigurable(courseId) {
try { try {
const { response } = await getXpertPluginConfigurable(courseId); const { response } = await getXpertPluginConfigurable(courseId);
enabled = response?.enabled; enabled = response?.enabled;
} catch { } catch (e) {
enabled = undefined; enabled = undefined;
} }
@@ -55,7 +55,7 @@ export function fetchXpertSettings(courseId) {
try { try {
const { response } = await getXpertSettings(courseId); const { response } = await getXpertSettings(courseId);
enabled = response?.enabled; enabled = response?.enabled;
} catch { } catch (e) {
enabled = undefined; enabled = undefined;
} }
@@ -86,7 +86,7 @@ export function removeXpertSettings(courseId) {
} }
dispatch(updateSavingStatus({ status: RequestStatus.FAILED })); dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
return false; return false;
} catch { } catch (error) {
dispatch(updateSavingStatus({ status: RequestStatus.FAILED })); dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
return false; return false;
} }
@@ -105,7 +105,7 @@ export function resetXpertSettings(courseId, state) {
} }
dispatch(updateResetStatus({ status: RequestStatus.FAILED })); dispatch(updateResetStatus({ status: RequestStatus.FAILED }));
return false; return false;
} catch { } catch (error) {
dispatch(updateResetStatus({ status: RequestStatus.FAILED })); dispatch(updateResetStatus({ status: RequestStatus.FAILED }));
return false; return false;
} }

View File

@@ -1,5 +1,4 @@
import { useIntl } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { getExternalLinkUrl } from '@edx/frontend-platform';
import { import {
ActionRow, ActionRow,
Alert, Alert,
@@ -239,10 +238,8 @@ const SettingsModal = ({
const values = { ...rest, enabled: enabled ? checked === 'true' : undefined }; const values = { ...rest, enabled: enabled ? checked === 'true' : undefined };
if (enabled) { if (enabled) {
// oxlint-disable-next-line @typescript-eslint/await-thenable - this dispatch() IS returning a promise.
success = await dispatch(updateXpertSettings(courseId, values)); success = await dispatch(updateXpertSettings(courseId, values));
} else { } else {
// oxlint-disable-next-line @typescript-eslint/await-thenable - this dispatch() IS returning a promise.
success = await dispatch(removeXpertSettings(courseId)); success = await dispatch(removeXpertSettings(courseId));
} }
@@ -279,7 +276,7 @@ const SettingsModal = ({
<div className="py-1"> <div className="py-1">
<Hyperlink <Hyperlink
className="text-primary-500" className="text-primary-500"
destination={getExternalLinkUrl('https://openai.com/api-data-privacy')} destination="https://openai.com/api-data-privacy"
target="_blank" target="_blank"
rel="noreferrer noopener" rel="noreferrer noopener"
> >

View File

@@ -1,172 +0,0 @@
import { getConfig } from '@edx/frontend-platform';
import {
createContext, useContext, useMemo, useState,
} from 'react';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { useCreateCourseBlock } from '@src/course-outline/data/apiHooks';
import { useSelector } from 'react-redux';
import { useNavigate } from 'react-router';
import { getOutlineIndexData } from '@src/course-outline/data/selectors';
import { useToggleWithValue } from '@src/hooks';
import { SelectionState, type UnitXBlock, type XBlock } from '@src/data/types';
import { CourseDetailsData } from './data/api';
import { useCourseDetails, useWaffleFlags } from './data/apiHooks';
import { RequestStatusType } from './data/constants';
type ModalState = {
value?: XBlock | UnitXBlock;
subsectionId?: string;
sectionId?: string;
};
export type CourseAuthoringContextData = {
/** The ID of the current course */
courseId: string;
courseUsageKey: string;
courseDetails?: CourseDetailsData;
courseDetailStatus: RequestStatusType;
canChangeProviders: boolean;
handleAddAndOpenUnit: ReturnType<typeof useCreateCourseBlock>;
handleAddBlock: ReturnType<typeof useCreateCourseBlock>;
openUnitPage: (locator: string) => void;
getUnitUrl: (locator: string) => string;
isUnlinkModalOpen: boolean;
currentUnlinkModalData?: ModalState;
openUnlinkModal: (value: ModalState) => void;
closeUnlinkModal: () => void;
isPublishModalOpen: boolean;
currentPublishModalData?: ModalState;
openPublishModal: (value: ModalState) => void;
closePublishModal: () => void;
currentSelection?: SelectionState;
setCurrentSelection: React.Dispatch<React.SetStateAction<SelectionState | undefined>>;
};
/**
* Course Authoring Context.
* Always available when we're in the context of a single course.
*
* Get this using `useCourseAuthoringContext()`
*
*/
const CourseAuthoringContext = createContext<CourseAuthoringContextData | undefined>(undefined);
type CourseAuthoringProviderProps = {
children?: React.ReactNode;
courseId: string;
};
export const CourseAuthoringProvider = ({
children,
courseId,
}: CourseAuthoringProviderProps) => {
const navigate = useNavigate();
const waffleFlags = useWaffleFlags();
const { data: courseDetails, status: courseDetailStatus } = useCourseDetails(courseId);
const canChangeProviders = getAuthenticatedUser().administrator || new Date(courseDetails?.start ?? 0) > new Date();
const { courseStructure } = useSelector(getOutlineIndexData);
const { id: courseUsageKey } = courseStructure || {};
const [
isUnlinkModalOpen,
currentUnlinkModalData,
openUnlinkModal,
closeUnlinkModal,
] = useToggleWithValue<ModalState>();
const [
isPublishModalOpen,
currentPublishModalData,
openPublishModal,
closePublishModal,
] = useToggleWithValue<ModalState>();
/**
* This will hold the state of current item that is being operated on,
* For example:
* - the details of container that is being edited.
* - the details of container of which see more dropdown is open.
* It is mostly used in modals which should be soon be replaced with its equivalent in sidebar.
*/
const [currentSelection, setCurrentSelection] = useState<SelectionState | undefined>();
const getUnitUrl = (locator: string) => {
if (getConfig().ENABLE_UNIT_PAGE === 'true' && waffleFlags.useNewUnitPage) {
// instanbul ignore next
return `/course/${courseId}/container/${locator}`;
}
return `${getConfig().STUDIO_BASE_URL}/container/${locator}`;
};
/**
* Open the unit page for a given locator.
*/
const openUnitPage = async (locator: string) => {
const url = getUnitUrl(locator);
if (getConfig().ENABLE_UNIT_PAGE === 'true' && waffleFlags.useNewUnitPage) {
// instanbul ignore next
navigate(url);
} else {
window.location.assign(url);
}
};
/**
* import a unit block from library and redirect user to this unit page.
*/
const handleAddAndOpenUnit = useCreateCourseBlock(courseId, openUnitPage);
const handleAddBlock = useCreateCourseBlock(courseId);
const context = useMemo<CourseAuthoringContextData>(() => ({
courseId,
courseUsageKey,
courseDetails,
courseDetailStatus,
canChangeProviders,
handleAddBlock,
handleAddAndOpenUnit,
getUnitUrl,
openUnitPage,
isUnlinkModalOpen,
openUnlinkModal,
closeUnlinkModal,
currentUnlinkModalData,
isPublishModalOpen,
currentPublishModalData,
openPublishModal,
closePublishModal,
currentSelection,
setCurrentSelection,
}), [
courseId,
courseUsageKey,
courseDetails,
courseDetailStatus,
canChangeProviders,
handleAddBlock,
handleAddAndOpenUnit,
getUnitUrl,
openUnitPage,
isUnlinkModalOpen,
openUnlinkModal,
closeUnlinkModal,
currentUnlinkModalData,
isPublishModalOpen,
currentPublishModalData,
openPublishModal,
closePublishModal,
currentSelection,
setCurrentSelection,
]);
return (
<CourseAuthoringContext.Provider value={context}>
{children}
</CourseAuthoringContext.Provider>
);
};
export function useCourseAuthoringContext(): CourseAuthoringContextData {
const ctx = useContext(CourseAuthoringContext);
if (ctx === undefined) {
/* istanbul ignore next */
throw new Error('useCourseAuthoringContext() was used in a component without a <CourseAuthoringProvider> ancestor.');
}
return ctx;
}

View File

@@ -1,4 +1,5 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { import {
@@ -6,31 +7,34 @@ import {
} from 'react-router-dom'; } from 'react-router-dom';
import { StudioFooterSlot } from '@edx/frontend-component-footer'; import { StudioFooterSlot } from '@edx/frontend-component-footer';
import Header from './header'; import Header from './header';
import { fetchCourseDetail } from './data/thunks';
import { useModel } from './generic/model-store';
import NotFoundAlert from './generic/NotFoundAlert'; import NotFoundAlert from './generic/NotFoundAlert';
import PermissionDeniedAlert from './generic/PermissionDeniedAlert'; import PermissionDeniedAlert from './generic/PermissionDeniedAlert';
import { fetchOnlyStudioHomeData } from './studio-home/data/thunks'; import { fetchOnlyStudioHomeData } from './studio-home/data/thunks';
import { getCourseAppsApiStatus } from './pages-and-resources/data/selectors'; import { getCourseAppsApiStatus } from './pages-and-resources/data/selectors';
import { RequestStatus } from './data/constants'; import { RequestStatus } from './data/constants';
import Loading from './generic/Loading'; import Loading from './generic/Loading';
import { useCourseAuthoringContext } from './CourseAuthoringContext';
interface Props { const CourseAuthoringPage = ({ courseId, children }) => {
children?: React.ReactNode;
}
const CourseAuthoringPage = ({ children }: Props) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
useEffect(() => {
dispatch(fetchCourseDetail(courseId));
}, [courseId]);
useEffect(() => { useEffect(() => {
dispatch(fetchOnlyStudioHomeData()); dispatch(fetchOnlyStudioHomeData());
}, []); }, []);
const { courseId, courseDetails, courseDetailStatus } = useCourseAuthoringContext(); const courseDetail = useModel('courseDetails', courseId);
const courseNumber = courseDetails?.number;
const courseOrg = courseDetails?.org; const courseNumber = courseDetail ? courseDetail.number : null;
const courseTitle = courseDetails?.name; const courseOrg = courseDetail ? courseDetail.org : null;
const inProgress = courseDetailStatus === RequestStatus.IN_PROGRESS || courseDetailStatus === RequestStatus.PENDING; const courseTitle = courseDetail ? courseDetail.name : courseId;
const courseAppsApiStatus = useSelector(getCourseAppsApiStatus); const courseAppsApiStatus = useSelector(getCourseAppsApiStatus);
const courseDetailStatus = useSelector(state => state.courseDetail.status);
const inProgress = courseDetailStatus === RequestStatus.IN_PROGRESS;
const { pathname } = useLocation(); const { pathname } = useLocation();
const isEditor = pathname.includes('/editor'); const isEditor = pathname.includes('/editor');
@@ -57,9 +61,6 @@ const CourseAuthoringPage = ({ children }: Props) => {
org={courseOrg} org={courseOrg}
title={courseTitle} title={courseTitle}
contextId={courseId} contextId={courseId}
containerProps={{
size: 'fluid',
}}
/> />
) )
)} )}
@@ -69,4 +70,13 @@ const CourseAuthoringPage = ({ children }: Props) => {
); );
}; };
CourseAuthoringPage.propTypes = {
children: PropTypes.node,
courseId: PropTypes.string.isRequired,
};
CourseAuthoringPage.defaultProps = {
children: null,
};
export default CourseAuthoringPage; export default CourseAuthoringPage;

View File

@@ -4,9 +4,9 @@ import CourseAuthoringPage from './CourseAuthoringPage';
import PagesAndResources from './pages-and-resources/PagesAndResources'; import PagesAndResources from './pages-and-resources/PagesAndResources';
import { executeThunk } from './utils'; import { executeThunk } from './utils';
import { fetchCourseApps } from './pages-and-resources/data/thunks'; import { fetchCourseApps } from './pages-and-resources/data/thunks';
import { fetchCourseDetail } from './data/thunks';
import { getApiWaffleFlagsUrl } from './data/api'; import { getApiWaffleFlagsUrl } from './data/api';
import { initializeMocks, render } from './testUtils'; import { initializeMocks, render } from './testUtils';
import { CourseAuthoringProvider } from './CourseAuthoringContext';
const courseId = 'course-v1:edX+TestX+Test_Course'; const courseId = 'course-v1:edX+TestX+Test_Course';
let mockPathname = '/evilguy/'; let mockPathname = '/evilguy/';
@@ -19,12 +19,6 @@ jest.mock('react-router-dom', () => ({
let axiosMock; let axiosMock;
let store; let store;
const renderComponent = children => render(
<CourseAuthoringProvider courseId={courseId}>
{children}
</CourseAuthoringProvider>,
);
beforeEach(async () => { beforeEach(async () => {
const mocks = initializeMocks(); const mocks = initializeMocks();
store = mocks.reduxStore; store = mocks.reduxStore;
@@ -41,13 +35,14 @@ describe('Editor Pages Load no header', () => {
axiosMock.onGet(`${courseAppsApiUrl}/${courseId}`).reply(200, { axiosMock.onGet(`${courseAppsApiUrl}/${courseId}`).reply(200, {
response: { status: 200 }, response: { status: 200 },
}); });
await executeThunk(fetchCourseApps(courseId), store.dispatch);
}; };
test('renders no loading wheel on editor pages', async () => { test('renders no loading wheel on editor pages', async () => {
mockPathname = '/editor/'; mockPathname = '/editor/';
await mockStoreSuccess(); await mockStoreSuccess();
const wrapper = renderComponent( const wrapper = render(
<CourseAuthoringPage> <CourseAuthoringPage courseId={courseId}>
<PagesAndResources /> <PagesAndResources courseId={courseId} />
</CourseAuthoringPage> </CourseAuthoringPage>
, ,
); );
@@ -56,9 +51,9 @@ describe('Editor Pages Load no header', () => {
test('renders loading wheel on non editor pages', async () => { test('renders loading wheel on non editor pages', async () => {
mockPathname = '/evilguy/'; mockPathname = '/evilguy/';
await mockStoreSuccess(); await mockStoreSuccess();
const wrapper = renderComponent( const wrapper = render(
<CourseAuthoringPage> <CourseAuthoringPage courseId={courseId}>
<PagesAndResources /> <PagesAndResources courseId={courseId} />
</CourseAuthoringPage> </CourseAuthoringPage>
, ,
); );
@@ -75,6 +70,7 @@ describe('Course authoring page', () => {
).reply(404, { ).reply(404, {
response: { status: 404 }, response: { status: 404 },
}); });
await executeThunk(fetchCourseDetail(courseId), store.dispatch);
}; };
const mockStoreError = async () => { const mockStoreError = async () => {
axiosMock.onGet( axiosMock.onGet(
@@ -82,10 +78,11 @@ describe('Course authoring page', () => {
).reply(500, { ).reply(500, {
response: { status: 500 }, response: { status: 500 },
}); });
await executeThunk(fetchCourseDetail(courseId), store.dispatch);
}; };
test('renders not found page on non-existent course key', async () => { test('renders not found page on non-existent course key', async () => {
await mockStoreNotFound(); await mockStoreNotFound();
const wrapper = renderComponent(<CourseAuthoringPage />); const wrapper = render(<CourseAuthoringPage courseId={courseId} />);
expect(await wrapper.findByTestId('notFoundAlert')).toBeInTheDocument(); expect(await wrapper.findByTestId('notFoundAlert')).toBeInTheDocument();
}); });
test('does not render not found page on other kinds of error', async () => { test('does not render not found page on other kinds of error', async () => {
@@ -95,8 +92,8 @@ describe('Course authoring page', () => {
// IN_PROGRESS but also not NOT_FOUND or DENIED- then check that the not // IN_PROGRESS but also not NOT_FOUND or DENIED- then check that the not
// found alert is not present. // found alert is not present.
const contentTestId = 'courseAuthoringPageContent'; const contentTestId = 'courseAuthoringPageContent';
const wrapper = renderComponent( const wrapper = render(
<CourseAuthoringPage> <CourseAuthoringPage courseId={courseId}>
<div data-testid={contentTestId} /> <div data-testid={contentTestId} />
</CourseAuthoringPage> </CourseAuthoringPage>
, ,
@@ -117,7 +114,7 @@ describe('Course authoring page', () => {
mockPathname = '/editor/'; mockPathname = '/editor/';
await mockStoreDenied(); await mockStoreDenied();
const wrapper = renderComponent(<CourseAuthoringPage />); const wrapper = render(<CourseAuthoringPage courseId={courseId} />);
expect(await wrapper.findByTestId('permissionDeniedAlert')).toBeInTheDocument(); expect(await wrapper.findByTestId('permissionDeniedAlert')).toBeInTheDocument();
}); });
}); });

View File

@@ -0,0 +1,153 @@
import React from 'react';
import {
Navigate, Routes, Route, useParams,
} from 'react-router-dom';
import { getConfig } from '@edx/frontend-platform';
import { PageWrap } from '@edx/frontend-platform/react';
import { Textbooks } from './textbooks';
import CourseAuthoringPage from './CourseAuthoringPage';
import { PagesAndResources } from './pages-and-resources';
import EditorContainer from './editors/EditorContainer';
import VideoSelectorContainer from './selectors/VideoSelectorContainer';
import CustomPages from './custom-pages';
import { FilesPage, VideosPage } from './files-and-videos';
import { AdvancedSettings } from './advanced-settings';
import { CourseOutline } from './course-outline';
import ScheduleAndDetails from './schedule-and-details';
import { GradingSettings } from './grading-settings';
import CourseTeam from './course-team/CourseTeam';
import { CourseUpdates } from './course-updates';
import { CourseUnit, SubsectionUnitRedirect } from './course-unit';
import { Certificates } from './certificates';
import CourseExportPage from './export-page/CourseExportPage';
import CourseOptimizerPage from './optimizer-page/CourseOptimizerPage';
import CourseImportPage from './import-page/CourseImportPage';
import { DECODED_ROUTES } from './constants';
import CourseChecklist from './course-checklist';
import GroupConfigurations from './group-configurations';
import { CourseLibraries } from './course-libraries';
import { IframeProvider } from './generic/hooks/context/iFrameContext';
/**
* As of this writing, these routes are mounted at a path prefixed with the following:
*
* /course/:courseId
*
* Meaning that their absolute paths look like:
*
* /course/:courseId/course-pages
* /course/:courseId/proctored-exam-settings
* /course/:courseId/editor/:blockType/:blockId
*
* This component and CourseAuthoringPage should maybe be combined once we no longer need to have
* CourseAuthoringPage split out for use in LegacyProctoringRoute. Once that route is removed, we
* can move the Header/Footer rendering to this component and likely pull the course detail loading
* in as well, and it'd feel a bit better-factored and the roles would feel more clear.
*/
const CourseAuthoringRoutes = () => {
const { courseId } = useParams();
return (
<CourseAuthoringPage courseId={courseId}>
<Routes>
<Route
path="/"
element={<PageWrap><CourseOutline courseId={courseId} /></PageWrap>}
/>
<Route
path="course_info"
element={<PageWrap><CourseUpdates courseId={courseId} /></PageWrap>}
/>
<Route
path="libraries"
element={<PageWrap><CourseLibraries courseId={courseId} /></PageWrap>}
/>
<Route
path="assets"
element={<PageWrap><FilesPage courseId={courseId} /></PageWrap>}
/>
<Route
path="videos"
element={getConfig().ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN === 'true' ? <PageWrap><VideosPage courseId={courseId} /></PageWrap> : null}
/>
<Route
path="pages-and-resources/*"
element={<PageWrap><PagesAndResources courseId={courseId} /></PageWrap>}
/>
<Route
path="proctored-exam-settings"
element={<Navigate replace to={`/course/${courseId}/pages-and-resources`} />}
/>
<Route
path="custom-pages/*"
element={<PageWrap><CustomPages courseId={courseId} /></PageWrap>}
/>
<Route
path="/subsection/:subsectionId"
element={<PageWrap><SubsectionUnitRedirect courseId={courseId} /></PageWrap>}
/>
{DECODED_ROUTES.COURSE_UNIT.map((path) => (
<Route
key={path}
path={path}
element={<PageWrap><IframeProvider><CourseUnit courseId={courseId} /></IframeProvider></PageWrap>}
/>
))}
<Route
path="editor/course-videos/:blockId"
element={<PageWrap><VideoSelectorContainer courseId={courseId} /></PageWrap>}
/>
<Route
path="editor/:blockType/:blockId?"
element={<PageWrap><EditorContainer learningContextId={courseId} /></PageWrap>}
/>
<Route
path="settings/details"
element={<PageWrap><ScheduleAndDetails courseId={courseId} /></PageWrap>}
/>
<Route
path="settings/grading"
element={<PageWrap><GradingSettings courseId={courseId} /></PageWrap>}
/>
<Route
path="course_team"
element={<PageWrap><CourseTeam courseId={courseId} /></PageWrap>}
/>
<Route
path="group_configurations"
element={<PageWrap><GroupConfigurations courseId={courseId} /></PageWrap>}
/>
<Route
path="settings/advanced"
element={<PageWrap><AdvancedSettings courseId={courseId} /></PageWrap>}
/>
<Route
path="import"
element={<PageWrap><CourseImportPage courseId={courseId} /></PageWrap>}
/>
<Route
path="export"
element={<PageWrap><CourseExportPage courseId={courseId} /></PageWrap>}
/>
<Route
path="optimizer"
element={<PageWrap><CourseOptimizerPage courseId={courseId} /></PageWrap>}
/>
<Route
path="checklists"
element={<PageWrap><CourseChecklist courseId={courseId} /></PageWrap>}
/>
<Route
path="certificates"
element={getConfig().ENABLE_CERTIFICATE_PAGE === 'true' ? <PageWrap><Certificates courseId={courseId} /></PageWrap> : null}
/>
<Route
path="textbooks"
element={<PageWrap><Textbooks courseId={courseId} /></PageWrap>}
/>
</Routes>
</CourseAuthoringPage>
);
};
export default CourseAuthoringRoutes;

View File

@@ -48,11 +48,7 @@ jest.mock('./custom-pages/CustomPages', () => (props) => {
describe('<CourseAuthoringRoutes>', () => { describe('<CourseAuthoringRoutes>', () => {
beforeEach(async () => { beforeEach(async () => {
const user = { const { axiosMock } = initializeMocks();
userId: 1,
username: 'username',
};
const { axiosMock } = initializeMocks({ user });
axiosMock axiosMock
.onGet(getApiWaffleFlagsUrl(courseId)) .onGet(getApiWaffleFlagsUrl(courseId))
.reply(200, {}); .reply(200, {});
@@ -65,7 +61,11 @@ describe('<CourseAuthoringRoutes>', () => {
); );
await waitFor(() => { await waitFor(() => {
expect(screen.getByText(pagesAndResourcesMockText)).toBeVisible(); expect(screen.getByText(pagesAndResourcesMockText)).toBeVisible();
expect(mockComponentFn).toHaveBeenCalled(); expect(mockComponentFn).toHaveBeenCalledWith(
expect.objectContaining({
courseId,
}),
);
}); });
}); });
@@ -93,7 +93,11 @@ describe('<CourseAuthoringRoutes>', () => {
await waitFor(() => { await waitFor(() => {
expect(screen.queryByText(videoSelectorContainerMockText)).toBeInTheDocument(); expect(screen.queryByText(videoSelectorContainerMockText)).toBeInTheDocument();
expect(screen.queryByText(pagesAndResourcesMockText)).not.toBeInTheDocument(); expect(screen.queryByText(pagesAndResourcesMockText)).not.toBeInTheDocument();
expect(mockComponentFn).toHaveBeenCalled(); expect(mockComponentFn).toHaveBeenCalledWith(
expect.objectContaining({
courseId,
}),
);
}); });
}); });
}); });

View File

@@ -1,186 +0,0 @@
import {
Navigate, Routes, Route, useParams,
} from 'react-router-dom';
import { getConfig } from '@edx/frontend-platform';
import { PageWrap } from '@edx/frontend-platform/react';
import { Textbooks } from './textbooks';
import CourseAuthoringPage from './CourseAuthoringPage';
import { PagesAndResources } from './pages-and-resources';
import EditorContainer from './editors/EditorContainer';
import VideoSelectorContainer from './selectors/VideoSelectorContainer';
import CustomPages from './custom-pages';
import { FilesPage, VideosPage } from './files-and-videos';
import { AdvancedSettings } from './advanced-settings';
import {
CourseOutline,
OutlineSidebarProvider,
OutlineSidebarPagesProvider,
} from './course-outline';
import ScheduleAndDetails from './schedule-and-details';
import { GradingSettings } from './grading-settings';
import CourseTeam from './course-team/CourseTeam';
import { CourseUpdates } from './course-updates';
import { CourseUnit, SubsectionUnitRedirect } from './course-unit';
import { Certificates } from './certificates';
import CourseExportPage from './export-page/CourseExportPage';
import CourseOptimizerPage from './optimizer-page/CourseOptimizerPage';
import CourseImportPage from './import-page/CourseImportPage';
import { DECODED_ROUTES } from './constants';
import CourseChecklist from './course-checklist';
import GroupConfigurations from './group-configurations';
import { CourseLibraries } from './course-libraries';
import { IframeProvider } from './generic/hooks/context/iFrameContext';
import { CourseAuthoringProvider } from './CourseAuthoringContext';
import { CourseImportProvider } from './import-page/CourseImportContext';
import { CourseExportProvider } from './export-page/CourseExportContext';
/**
* As of this writing, these routes are mounted at a path prefixed with the following:
*
* /course/:courseId
*
* Meaning that their absolute paths look like:
*
* /course/:courseId/course-pages
* /course/:courseId/proctored-exam-settings
* /course/:courseId/editor/:blockType/:blockId
*
* This component and CourseAuthoringPage should maybe be combined once we no longer need to have
* CourseAuthoringPage split out for use in LegacyProctoringRoute. Once that route is removed, we
* can move the Header/Footer rendering to this component and likely pull the course detail loading
* in as well, and it'd feel a bit better-factored and the roles would feel more clear.
*/
const CourseAuthoringRoutes = () => {
const { courseId } = useParams();
if (courseId === undefined) {
// istanbul ignore next - This shouldn't be possible; it's just here to satisfy the type checker.
throw new Error('Error: route is missing courseId.');
}
return (
<CourseAuthoringProvider courseId={courseId}>
<CourseAuthoringPage>
<Routes>
<Route
path="/"
element={(
<PageWrap>
<OutlineSidebarPagesProvider>
<OutlineSidebarProvider>
<CourseOutline />
</OutlineSidebarProvider>
</OutlineSidebarPagesProvider>
</PageWrap>
)}
/>
<Route
path="course_info"
element={<PageWrap><CourseUpdates /></PageWrap>}
/>
<Route
path="libraries"
element={<PageWrap><CourseLibraries /></PageWrap>}
/>
<Route
path="assets"
element={<PageWrap><FilesPage /></PageWrap>}
/>
<Route
path="videos"
element={getConfig().ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN === 'true' ? <PageWrap><VideosPage /></PageWrap> : null}
/>
<Route
path="pages-and-resources/*"
element={<PageWrap><PagesAndResources /></PageWrap>}
/>
<Route
path="proctored-exam-settings"
element={<Navigate replace to={`/course/${courseId}/pages-and-resources`} />}
/>
<Route
path="custom-pages/*"
element={<PageWrap><CustomPages /></PageWrap>}
/>
<Route
path="/subsection/:subsectionId"
element={<PageWrap><SubsectionUnitRedirect /></PageWrap>}
/>
{DECODED_ROUTES.COURSE_UNIT.map((path) => (
<Route
key={path}
path={path}
element={<PageWrap><IframeProvider><CourseUnit /></IframeProvider></PageWrap>}
/>
))}
<Route
path="editor/course-videos/:blockId"
element={<PageWrap><VideoSelectorContainer /></PageWrap>}
/>
<Route
path="editor/:blockType/:blockId?"
element={<PageWrap><EditorContainer learningContextId={courseId} /></PageWrap>}
/>
<Route
path="settings/details"
element={<PageWrap><ScheduleAndDetails /></PageWrap>}
/>
<Route
path="settings/grading"
element={<PageWrap><GradingSettings /></PageWrap>}
/>
<Route
path="course_team"
element={<PageWrap><CourseTeam /></PageWrap>}
/>
<Route
path="group_configurations"
element={<PageWrap><GroupConfigurations /></PageWrap>}
/>
<Route
path="settings/advanced"
element={<PageWrap><AdvancedSettings /></PageWrap>}
/>
<Route
path="import"
element={(
<PageWrap>
<CourseImportProvider>
<CourseImportPage />
</CourseImportProvider>
</PageWrap>
)}
/>
<Route
path="export"
element={(
<PageWrap>
<CourseExportProvider>
<CourseExportPage />
</CourseExportProvider>
</PageWrap>
)}
/>
<Route
path="optimizer"
element={<PageWrap><CourseOptimizerPage /></PageWrap>}
/>
<Route
path="checklists"
element={<PageWrap><CourseChecklist /></PageWrap>}
/>
<Route
path="certificates"
element={getConfig().ENABLE_CERTIFICATE_PAGE === 'true' ? <PageWrap><Certificates /></PageWrap> : null}
/>
<Route
path="textbooks"
element={<PageWrap><Textbooks /></PageWrap>}
/>
</Routes>
</CourseAuthoringPage>
</CourseAuthoringProvider>
);
};
export default CourseAuthoringRoutes;

View File

@@ -1,3 +1,5 @@
import React from 'react';
import PropTypes from 'prop-types';
import { FormattedMessage } from '@edx/frontend-platform/i18n'; import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { Hyperlink, MailtoLink, Stack } from '@openedx/paragon'; import { Hyperlink, MailtoLink, Stack } from '@openedx/paragon';
@@ -6,9 +8,6 @@ import messages from './messages';
const AccessibilityBody = ({ const AccessibilityBody = ({
communityAccessibilityLink, communityAccessibilityLink,
email, email,
}: {
communityAccessibilityLink: string,
email: string,
}) => ( }) => (
<div className="mt-5"> <div className="mt-5">
<header> <header>
@@ -91,4 +90,9 @@ const AccessibilityBody = ({
</div> </div>
); );
AccessibilityBody.propTypes = {
communityAccessibilityLink: PropTypes.string.isRequired,
email: PropTypes.string.isRequired,
};
export default AccessibilityBody; export default AccessibilityBody;

View File

@@ -0,0 +1,46 @@
import {
render,
screen,
} from '@testing-library/react';
import { AppProvider } from '@edx/frontend-platform/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { initializeMockApp } from '@edx/frontend-platform';
import initializeStore from '../../store';
import AccessibilityBody from './index';
let store;
const renderComponent = () => {
render(
<IntlProvider locale="en">
<AppProvider store={store}>
<AccessibilityBody
communityAccessibilityLink="http://example.com"
email="example@example.com"
/>
</AppProvider>
</IntlProvider>,
);
};
describe('<AccessibilityBody />', () => {
describe('renders', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: false,
roles: [],
},
});
store = initializeStore({});
});
it('contains links', () => {
renderComponent();
expect(screen.getAllByTestId('email-element')).toHaveLength(2);
expect(screen.getAllByTestId('accessibility-page-link')).toHaveLength(1);
});
});
});

View File

@@ -1,29 +0,0 @@
import {
initializeMocks,
render,
screen,
} from '@src/testUtils';
import AccessibilityBody from './index';
const renderComponent = () => {
render(
<AccessibilityBody
communityAccessibilityLink="http://example.com"
email="example@example.com"
/>,
);
};
describe('<AccessibilityBody />', () => {
describe('renders', () => {
beforeEach(async () => {
initializeMocks();
});
it('contains links', () => {
renderComponent();
expect(screen.getAllByTestId('email-element')).toHaveLength(2);
expect(screen.getAllByTestId('accessibility-page-link')).toHaveLength(1);
});
});
});

View File

@@ -1,3 +1,5 @@
import React from 'react';
import PropTypes from 'prop-types';
import { import {
FormattedMessage, FormattedDate, FormattedTime, useIntl, FormattedMessage, FormattedDate, FormattedTime, useIntl,
} from '@edx/frontend-platform/i18n'; } from '@edx/frontend-platform/i18n';
@@ -5,22 +7,26 @@ import {
ActionRow, Alert, Form, Stack, StatefulButton, ActionRow, Alert, Form, Stack, StatefulButton,
} from '@openedx/paragon'; } from '@openedx/paragon';
import { STATEFUL_BUTTON_STATES } from '@src/constants'; import { RequestStatus } from '../../data/constants';
import { STATEFUL_BUTTON_STATES } from '../../constants';
import submitAccessibilityForm from '../data/thunks';
import useAccessibility from './hooks'; import useAccessibility from './hooks';
import messages from './messages'; import messages from './messages';
const AccessibilityForm = ({ accessibilityEmail }: { accessibilityEmail: string }) => { const AccessibilityForm = ({
accessibilityEmail,
}) => {
const intl = useIntl(); const intl = useIntl();
const { const {
errors, errors,
values, values,
isFormFilled, isFormFilled,
mutation, dispatch,
handleBlur, handleBlur,
handleChange, handleChange,
hasErrorField, hasErrorField,
savingStatus, savingStatus,
} = useAccessibility({ name: '', email: '', message: '' }); } = useAccessibility({ name: '', email: '', message: '' }, intl);
const formFields = [ const formFields = [
{ {
@@ -49,7 +55,7 @@ const AccessibilityForm = ({ accessibilityEmail }: { accessibilityEmail: string
}; };
const handleSubmit = () => { const handleSubmit = () => {
mutation.mutateAsync(values).catch(() => {}); dispatch(submitAccessibilityForm(values));
}; };
const start = new Date('Mon Jan 29 2018 13:00:00 GMT (UTC)'); const start = new Date('Mon Jan 29 2018 13:00:00 GMT (UTC)');
@@ -60,7 +66,7 @@ const AccessibilityForm = ({ accessibilityEmail }: { accessibilityEmail: string
<h2 className="my-4"> <h2 className="my-4">
<FormattedMessage {...messages.accessibilityPolicyFormHeader} /> <FormattedMessage {...messages.accessibilityPolicyFormHeader} />
</h2> </h2>
{savingStatus === 'success' && ( {savingStatus === RequestStatus.SUCCESSFUL && (
<Alert variant="success"> <Alert variant="success">
<Stack gap={2}> <Stack gap={2}>
<div className="mb-2"> <div className="mb-2">
@@ -80,7 +86,7 @@ const AccessibilityForm = ({ accessibilityEmail }: { accessibilityEmail: string
</Stack> </Stack>
</Alert> </Alert>
)} )}
{savingStatus === 'error' && ( {savingStatus === RequestStatus.FAILED && (
<Alert variant="danger"> <Alert variant="danger">
<div data-testid="rate-limit-alert"> <div data-testid="rate-limit-alert">
<FormattedMessage <FormattedMessage
@@ -119,7 +125,7 @@ const AccessibilityForm = ({ accessibilityEmail }: { accessibilityEmail: string
onClick={handleSubmit} onClick={handleSubmit}
disabled={!isFormFilled} disabled={!isFormFilled}
state={ state={
savingStatus === 'pending' savingStatus === RequestStatus.IN_PROGRESS
? STATEFUL_BUTTON_STATES.pending ? STATEFUL_BUTTON_STATES.pending
: STATEFUL_BUTTON_STATES.default : STATEFUL_BUTTON_STATES.default
} }
@@ -130,4 +136,8 @@ const AccessibilityForm = ({ accessibilityEmail }: { accessibilityEmail: string
); );
}; };
AccessibilityForm.propTypes = {
accessibilityEmail: PropTypes.string.isRequired,
};
export default AccessibilityForm; export default AccessibilityForm;

View File

@@ -1,31 +1,56 @@
import { import {
initializeMocks,
render, render,
screen, screen,
} from '@src/testUtils'; } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { initializeMockApp } from '@edx/frontend-platform';
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import initializeStore from '../../store';
import { RequestStatus } from '../../data/constants';
import AccessibilityForm from './index'; import AccessibilityForm from './index';
import { getZendeskrUrl } from '../data/api'; import { getZendeskrUrl } from '../data/api';
import messages from './messages'; import messages from './messages';
let axiosMock; let axiosMock;
let store;
const defaultProps = { const defaultProps = {
accessibilityEmail: 'accessibilityTest@test.com', accessibilityEmail: 'accessibilityTest@test.com',
}; };
const initialState = {
accessibilityPage: {
savingStatus: '',
},
};
const renderComponent = () => { const renderComponent = () => {
render( render(
<AccessibilityForm {...defaultProps} />, <IntlProvider locale="en">
<AppProvider store={store}>
<AccessibilityForm {...defaultProps} />
</AppProvider>
</IntlProvider>,
); );
}; };
describe('<AccessibilityPolicyForm />', () => { describe('<AccessibilityPolicyForm />', () => {
beforeEach(async () => { beforeEach(async () => {
const mocks = initializeMocks(); initializeMockApp({
authenticatedUser: {
axiosMock = mocks.axiosMock; userId: 3,
username: 'abc123',
administrator: false,
roles: [],
},
});
store = initializeStore(initialState);
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
}); });
describe('renders', () => { describe('renders', () => {
@@ -61,23 +86,14 @@ describe('<AccessibilityPolicyForm />', () => {
submitButton = screen.getByText(messages.accessibilityPolicyFormSubmitLabel.defaultMessage); submitButton = screen.getByText(messages.accessibilityPolicyFormSubmitLabel.defaultMessage);
}); });
it('renders in progress state', async () => {
axiosMock.onPost(getZendeskrUrl()).reply(
() => new Promise(() => {
// always in pending
}),
);
await user.click(submitButton);
expect(screen.getByRole('button', { name: /submitting/i })).toBeInTheDocument();
});
it('shows correct success message', async () => { it('shows correct success message', async () => {
axiosMock.onPost(getZendeskrUrl()).reply(200); axiosMock.onPost(getZendeskrUrl()).reply(200);
await user.click(submitButton); await user.click(submitButton);
const { savingStatus } = store.getState().accessibilityPage;
expect(savingStatus).toEqual(RequestStatus.SUCCESSFUL);
expect(screen.getAllByRole('alert')).toHaveLength(1); expect(screen.getAllByRole('alert')).toHaveLength(1);
expect(screen.getByText(messages.accessibilityPolicyFormSuccess.defaultMessage)).toBeVisible(); expect(screen.getByText(messages.accessibilityPolicyFormSuccess.defaultMessage)).toBeVisible();
@@ -92,6 +108,9 @@ describe('<AccessibilityPolicyForm />', () => {
await user.click(submitButton); await user.click(submitButton);
const { savingStatus } = store.getState().accessibilityPage;
expect(savingStatus).toEqual(RequestStatus.FAILED);
expect(screen.getAllByRole('alert')).toHaveLength(1); expect(screen.getAllByRole('alert')).toHaveLength(1);
expect(screen.getByTestId('rate-limit-alert')).toBeVisible(); expect(screen.getByTestId('rate-limit-alert')).toBeVisible();

View File

@@ -1,14 +1,14 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useIntl } from '@edx/frontend-platform/i18n'; import { useDispatch, useSelector } from 'react-redux';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
import * as Yup from 'yup'; import * as Yup from 'yup';
import { RequestStatus } from '../../data/constants';
import messages from './messages'; import messages from './messages';
import { useSubmitAccessibilityForm } from '../data/apiHooks';
import { AccessibilityFormData } from '../data/api';
const useAccessibility = (initialValues: AccessibilityFormData) => { const useAccessibility = (initialValues, intl) => {
const intl = useIntl(); const dispatch = useDispatch();
const savingStatus = useSelector(state => state.accessibilityPage.savingStatus);
const [isFormFilled, setFormFilled] = useState(false); const [isFormFilled, setFormFilled] = useState(false);
const validationSchema = Yup.object().shape({ const validationSchema = Yup.object().shape({
name: Yup.string().required( name: Yup.string().required(
@@ -29,27 +29,29 @@ const useAccessibility = (initialValues: AccessibilityFormData) => {
enableReinitialize: true, enableReinitialize: true,
validateOnBlur: false, validateOnBlur: false,
validationSchema, validationSchema,
/* istanbul ignore next */
onSubmit: () => {},
}); });
const mutation = useSubmitAccessibilityForm(handleReset);
useEffect(() => { useEffect(() => {
setFormFilled(Object.values(values).every((i) => i)); setFormFilled(Object.values(values).every((i) => i));
}, [values]); }, [values]);
useEffect(() => {
if (savingStatus === RequestStatus.SUCCESSFUL) {
handleReset();
}
}, [savingStatus]);
const hasErrorField = (fieldName) => !!errors[fieldName] && !!touched[fieldName]; const hasErrorField = (fieldName) => !!errors[fieldName] && !!touched[fieldName];
return { return {
errors, errors,
values, values,
isFormFilled, isFormFilled,
mutation, dispatch,
handleBlur, handleBlur,
handleChange, handleChange,
hasErrorField, hasErrorField,
savingStatus: mutation.status, savingStatus,
}; };
}; };

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { useIntl } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { getExternalLinkUrl } from '@edx/frontend-platform';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import { Container } from '@openedx/paragon'; import { Container } from '@openedx/paragon';
import { StudioFooterSlot } from '@edx/frontend-component-footer'; import { StudioFooterSlot } from '@edx/frontend-component-footer';
@@ -23,12 +23,9 @@ const AccessibilityPage = () => {
</title> </title>
</Helmet> </Helmet>
<Header isHiddenMainMenu /> <Header isHiddenMainMenu />
<Container size="xl" className="px-4"> <Container size="xl" classNamae="px-4">
<AccessibilityBody <AccessibilityBody
{...{ {...{ email: ACCESSIBILITY_EMAIL, communityAccessibilityLink: COMMUNITY_ACCESSIBILITY_LINK }}
email: ACCESSIBILITY_EMAIL,
communityAccessibilityLink: getExternalLinkUrl(COMMUNITY_ACCESSIBILITY_LINK),
}}
/> />
<AccessibilityForm accessibilityEmail={ACCESSIBILITY_EMAIL} /> <AccessibilityForm accessibilityEmail={ACCESSIBILITY_EMAIL} />
</Container> </Container>
@@ -37,4 +34,6 @@ const AccessibilityPage = () => {
); );
}; };
AccessibilityPage.propTypes = {};
export default AccessibilityPage; export default AccessibilityPage;

View File

@@ -1,3 +1,4 @@
// @ts-check
import { initializeMocks, render, screen } from '../testUtils'; import { initializeMocks, render, screen } from '../testUtils';
import AccessibilityPage from './index'; import AccessibilityPage from './index';

View File

@@ -8,20 +8,12 @@ ensureConfig([
export const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL; export const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
export const getZendeskrUrl = () => `${getApiBaseUrl()}/zendesk_proxy/v0`; export const getZendeskrUrl = () => `${getApiBaseUrl()}/zendesk_proxy/v0`;
export interface AccessibilityFormData {
name: string;
email: string;
message: string;
}
/** /**
* Posts the form data to zendesk endpoint * Posts the form data to zendesk endpoint
* @param {string} courseId
* @returns {Promise<[{}]>}
*/ */
export async function postAccessibilityForm({ export async function postAccessibilityForm({ name, email, message }) {
name,
email,
message,
}: AccessibilityFormData) {
const data = { const data = {
name, name,
tags: ['studio_a11y'], tags: ['studio_a11y'],

View File

@@ -1,12 +0,0 @@
import { useMutation } from '@tanstack/react-query';
import { AccessibilityFormData, postAccessibilityForm } from './api';
/**
* Mutation to submit accessibility form
*/
export const useSubmitAccessibilityForm = (handleSuccess: (e: any) => void) => (
useMutation({
mutationFn: (formData: AccessibilityFormData) => postAccessibilityForm(formData),
onSuccess: handleSuccess,
})
);

View File

@@ -0,0 +1,23 @@
/* eslint-disable no-param-reassign */
import { createSlice } from '@reduxjs/toolkit';
const slice = createSlice({
name: 'accessibilityPage',
initialState: {
savingStatus: '',
},
reducers: {
updateSavingStatus: (state, { payload }) => {
state.savingStatus = payload.status;
},
},
});
export const {
updateLoadingStatus,
updateSavingStatus,
} = slice.actions;
export const {
reducer,
} = slice;

View File

@@ -0,0 +1,24 @@
import { RequestStatus } from '../../data/constants';
import { postAccessibilityForm } from './api';
import { updateSavingStatus } from './slice';
function submitAccessibilityForm({ email, name, message }) {
return async (dispatch) => {
dispatch(updateSavingStatus({ status: RequestStatus.IN_PROGRESS }));
try {
await postAccessibilityForm({ email, name, message });
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
} catch (error) {
/* istanbul ignore else */
if (error.response && error.response.status === 429) {
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
} else {
/* istanbul ignore next */
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
}
}
};
}
export default submitAccessibilityForm;

View File

@@ -1,73 +1,59 @@
import { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Helmet } from 'react-helmet'; import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import { import {
Container, Button, Layout, StatefulButton, TransitionReplace, Container, Button, Layout, StatefulButton, TransitionReplace,
} from '@openedx/paragon'; } from '@openedx/paragon';
import { CheckCircle, Info, Warning } from '@openedx/paragon/icons'; import { CheckCircle, Info, Warning } from '@openedx/paragon/icons';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; import Placeholder from '../editors/Placeholder';
import { useWaffleFlags } from '@src/data/apiHooks';
import { useUserPermissions } from '@src/authz/data/apiHooks';
import { COURSE_PERMISSIONS } from '@src/authz/constants';
import PermissionDeniedAlert from 'CourseAuthoring/generic/PermissionDeniedAlert';
import AlertProctoringError from '@src/generic/AlertProctoringError';
import { LoadingSpinner } from '@src/generic/Loading';
import InternetConnectionAlert from '@src/generic/internet-connection-alert';
import { parseArrayOrObjectValues } from '@src/utils';
import { RequestStatus } from '@src/data/constants';
import SubHeader from '@src/generic/sub-header/SubHeader';
import AlertMessage from '@src/generic/alert-message';
import getPageHeadTitle from '@src/generic/utils';
import Placeholder from '@src/editors/Placeholder';
import AlertProctoringError from '../generic/AlertProctoringError';
import { useModel } from '../generic/model-store';
import InternetConnectionAlert from '../generic/internet-connection-alert';
import { parseArrayOrObjectValues } from '../utils';
import { RequestStatus } from '../data/constants';
import SubHeader from '../generic/sub-header/SubHeader';
import AlertMessage from '../generic/alert-message';
import { fetchCourseAppSettings, updateCourseAppSetting, fetchProctoringExamErrors } from './data/thunks';
import {
getCourseAppSettings, getSavingStatus, getProctoringExamErrors, getSendRequestErrors, getLoadingStatus,
} from './data/selectors';
import SettingCard from './setting-card/SettingCard'; import SettingCard from './setting-card/SettingCard';
import SettingsSidebar from './settings-sidebar/SettingsSidebar'; import SettingsSidebar from './settings-sidebar/SettingsSidebar';
import validateAdvancedSettingsData from './utils'; import validateAdvancedSettingsData from './utils';
import messages from './messages'; import messages from './messages';
import ModalError from './modal-error/ModalError'; import ModalError from './modal-error/ModalError';
import { useCourseAdvancedSettings, useProctoringExamErrors, useUpdateCourseAdvancedSettings } from './data/apiHooks'; import getPageHeadTitle from '../generic/utils';
const AdvancedSettings = () => { const AdvancedSettings = ({ courseId }) => {
const intl = useIntl(); const intl = useIntl();
const dispatch = useDispatch();
const [saveSettingsPrompt, showSaveSettingsPrompt] = useState(false); const [saveSettingsPrompt, showSaveSettingsPrompt] = useState(false);
const [showDeprecated, setShowDeprecated] = useState(false); const [showDeprecated, setShowDeprecated] = useState(false);
const [errorModal, showErrorModal] = useState(false); const [errorModal, showErrorModal] = useState(false);
const [editedSettings, setEditedSettings] = useState({}); const [editedSettings, setEditedSettings] = useState({});
const [errorFields, setErrorFields] = useState([]); const [errorFields, setErrorFields] = useState([]);
const [showSuccessAlert, setShowSuccessAlert] = useState(false); const [showSuccessAlert, setShowSuccessAlert] = useState(false);
const [isQueryPending, setIsQueryPending] = useState(false);
const [isEditableState, setIsEditableState] = useState(false); const [isEditableState, setIsEditableState] = useState(false);
const [hasInternetConnectionError, setInternetConnectionError] = useState(false); const [hasInternetConnectionError, setInternetConnectionError] = useState(false);
const { courseId, courseDetails } = useCourseAuthoringContext(); const courseDetails = useModel('courseDetails', courseId);
document.title = getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.headingTitle));
const waffleFlags = useWaffleFlags(courseId); useEffect(() => {
const isAuthzEnabled = waffleFlags.enableAuthzCourseAuthoring; dispatch(fetchCourseAppSettings(courseId));
const { isLoading: isLoadingUserPermissions, data: userPermissions } = useUserPermissions({ dispatch(fetchProctoringExamErrors(courseId));
canManageAdvancedSettings: { }, [courseId]);
action: COURSE_PERMISSIONS.MANAGE_ADVANCED_SETTINGS,
scope: courseId,
},
}, isAuthzEnabled);
const { const advancedSettingsData = useSelector(getCourseAppSettings);
data: advancedSettingsData = {}, const savingStatus = useSelector(getSavingStatus);
isPending: isPendingSettingsStatus, const proctoringExamErrors = useSelector(getProctoringExamErrors);
failureReason: settingsStatusError, const settingsWithSendErrors = useSelector(getSendRequestErrors) || {};
} = useCourseAdvancedSettings(courseId); const loadingSettingsStatus = useSelector(getLoadingStatus);
const { const isLoading = loadingSettingsStatus === RequestStatus.IN_PROGRESS;
data: proctoringExamErrors = {},
} = useProctoringExamErrors(courseId);
const updateMutation = useUpdateCourseAdvancedSettings(courseId);
const {
isPending: isQueryPending,
isSuccess: isQuerySuccess,
error: queryError,
} = updateMutation;
const isLoading = isPendingSettingsStatus || (isAuthzEnabled && isLoadingUserPermissions);
const updateSettingsButtonState = { const updateSettingsButtonState = {
labels: { labels: {
default: intl.formatMessage(messages.buttonSaveText), default: intl.formatMessage(messages.buttonSaveText),
@@ -75,34 +61,30 @@ const AdvancedSettings = () => {
}, },
disabledStates: ['pending'], disabledStates: ['pending'],
}; };
const { const {
proctoringErrors, proctoringErrors,
mfeProctoredExamSettingsUrl, mfeProctoredExamSettingsUrl,
} = proctoringExamErrors; } = proctoringExamErrors;
useEffect(() => { useEffect(() => {
if (isQuerySuccess) { if (savingStatus === RequestStatus.SUCCESSFUL) {
setIsQueryPending(false);
setShowSuccessAlert(true); setShowSuccessAlert(true);
setIsEditableState(false); setIsEditableState(false);
setTimeout(() => setShowSuccessAlert(false), 15000); setTimeout(() => setShowSuccessAlert(false), 15000);
window.scrollTo({ top: 0, behavior: 'smooth' }); window.scrollTo({ top: 0, behavior: 'smooth' });
showSaveSettingsPrompt(false); showSaveSettingsPrompt(false);
} else if (queryError && !hasInternetConnectionError) { } else if (savingStatus === RequestStatus.FAILED && !hasInternetConnectionError) {
// @ts-ignore setErrorFields(settingsWithSendErrors);
setErrorFields(queryError?.response?.data ?? []);
showErrorModal(true); showErrorModal(true);
} }
}, [isQuerySuccess, queryError]); }, [savingStatus]);
if (isLoading) { if (isLoading) {
return ( // eslint-disable-next-line react/jsx-no-useless-fragment
<div className="row justify-content-center m-6"> return <></>;
<LoadingSpinner />
</div>
);
} }
if (settingsStatusError?.response?.status === 403) { if (loadingSettingsStatus === RequestStatus.DENIED) {
return ( return (
<div className="row justify-content-center m-6"> <div className="row justify-content-center m-6">
<Placeholder /> <Placeholder />
@@ -124,42 +106,31 @@ const AdvancedSettings = () => {
const handleUpdateAdvancedSettingsData = () => { const handleUpdateAdvancedSettingsData = () => {
const isValid = validateAdvancedSettingsData(editedSettings, setErrorFields, setEditedSettings); const isValid = validateAdvancedSettingsData(editedSettings, setErrorFields, setEditedSettings);
if (isValid) { if (isValid) {
setShowSuccessAlert(false); setIsQueryPending(true);
updateMutation.mutate(parseArrayOrObjectValues(editedSettings));
} else { } else {
showSaveSettingsPrompt(false); showSaveSettingsPrompt(false);
showErrorModal(!errorModal); showErrorModal(!errorModal);
} }
}; };
/* istanbul ignore next */
const handleInternetConnectionFailed = () => { const handleInternetConnectionFailed = () => {
setInternetConnectionError(true); setInternetConnectionError(true);
showSaveSettingsPrompt(false); showSaveSettingsPrompt(false);
setShowSuccessAlert(false); setShowSuccessAlert(false);
}; };
const handleQueryProcessing = () => {
setShowSuccessAlert(false);
dispatch(updateCourseAppSetting(courseId, parseArrayOrObjectValues(editedSettings)));
};
const handleManuallyChangeClick = (setToState) => { const handleManuallyChangeClick = (setToState) => {
showErrorModal(setToState); showErrorModal(setToState);
showSaveSettingsPrompt(true); showSaveSettingsPrompt(true);
}; };
// Show permission denied alert when authz is enabled and user doesn't have permission
const authzIsEnabledAndNoPermission = isAuthzEnabled
&& !isLoadingUserPermissions
&& !userPermissions?.canManageAdvancedSettings;
if (authzIsEnabledAndNoPermission) {
return <PermissionDeniedAlert />;
}
return ( return (
<> <>
<Helmet>
<title>
{getPageHeadTitle(courseDetails?.name ?? '', intl.formatMessage(messages.headingTitle))}
</title>
</Helmet>
<Container size="xl" className="advanced-settings px-4"> <Container size="xl" className="advanced-settings px-4">
<div className="setting-header mt-5"> <div className="setting-header mt-5">
{(proctoringErrors?.length > 0) && ( {(proctoringErrors?.length > 0) && (
@@ -169,11 +140,7 @@ const AdvancedSettings = () => {
aria-hidden="true" aria-hidden="true"
aria-labelledby={intl.formatMessage(messages.alertProctoringAriaLabelledby)} aria-labelledby={intl.formatMessage(messages.alertProctoringAriaLabelledby)}
aria-describedby={intl.formatMessage(messages.alertProctoringDescribedby)} aria-describedby={intl.formatMessage(messages.alertProctoringDescribedby)}
> />
{/* Empty children to satisfy the type checker */}
{/* eslint-disable-next-line react/jsx-no-useless-fragment */}
<></>
</AlertProctoringError>
)} )}
<TransitionReplace> <TransitionReplace>
{showSuccessAlert ? ( {showSuccessAlert ? (
@@ -226,8 +193,8 @@ const AdvancedSettings = () => {
defaultMessage="{visibility} deprecated settings" defaultMessage="{visibility} deprecated settings"
values={{ values={{
visibility: visibility:
showDeprecated ? intl.formatMessage(messages.deprecatedButtonHideText) showDeprecated ? intl.formatMessage(messages.deprecatedButtonHideText)
: intl.formatMessage(messages.deprecatedButtonShowText), : intl.formatMessage(messages.deprecatedButtonShowText),
}} }}
/> />
</Button> </Button>
@@ -269,8 +236,9 @@ const AdvancedSettings = () => {
<div className="alert-toast"> <div className="alert-toast">
{isQueryPending && ( {isQueryPending && (
<InternetConnectionAlert <InternetConnectionAlert
isFailed={Boolean(queryError)} isFailed={savingStatus === RequestStatus.FAILED}
isQueryPending={isQueryPending} isQueryPending={isQueryPending}
onQueryProcessing={handleQueryProcessing}
onInternetConnectionFailed={handleInternetConnectionFailed} onInternetConnectionFailed={handleInternetConnectionFailed}
/> />
)} )}
@@ -281,18 +249,18 @@ const AdvancedSettings = () => {
aria-describedby={intl.formatMessage(messages.alertWarningAriaDescribedby)} aria-describedby={intl.formatMessage(messages.alertWarningAriaDescribedby)}
role="dialog" role="dialog"
actions={[ actions={[
!isQueryPending ? ( !isQueryPending && (
<Button variant="tertiary" onClick={handleResetSettingsValues}> <Button variant="tertiary" onClick={handleResetSettingsValues}>
{intl.formatMessage(messages.buttonCancelText)} {intl.formatMessage(messages.buttonCancelText)}
</Button> </Button>
) : /* istanbul ignore next */ null, ),
<StatefulButton <StatefulButton
key="statefulBtn" key="statefulBtn"
onClick={handleUpdateAdvancedSettingsData} onClick={handleUpdateAdvancedSettingsData}
state={isQueryPending ? RequestStatus.PENDING : 'default'} state={isQueryPending ? RequestStatus.PENDING : 'default'}
{...updateSettingsButtonState} {...updateSettingsButtonState}
/>, />,
].filter((action): action is JSX.Element => action !== null)} ].filter(Boolean)}
variant="warning" variant="warning"
icon={Warning} icon={Warning}
title={intl.formatMessage(messages.alertWarning)} title={intl.formatMessage(messages.alertWarning)}
@@ -310,4 +278,8 @@ const AdvancedSettings = () => {
); );
}; };
AdvancedSettings.propTypes = {
courseId: PropTypes.string.isRequired,
};
export default AdvancedSettings; export default AdvancedSettings;

View File

@@ -0,0 +1,144 @@
import {
render as baseRender,
fireEvent,
initializeMocks,
waitFor,
} from '../testUtils';
import { executeThunk } from '../utils';
import { advancedSettingsMock } from './__mocks__';
import { getCourseAdvancedSettingsApiUrl } from './data/api';
import { updateCourseAppSetting } from './data/thunks';
import AdvancedSettings from './AdvancedSettings';
import messages from './messages';
let axiosMock;
let store;
const mockPathname = '/foo-bar';
const courseId = '123';
// Mock the TextareaAutosize component
jest.mock('react-textarea-autosize', () => jest.fn((props) => (
<textarea
{...props}
onFocus={() => {}}
onBlur={() => {}}
/>
)));
const render = () => baseRender(
<AdvancedSettings courseId={courseId} />,
{ path: mockPathname },
);
describe('<AdvancedSettings />', () => {
beforeEach(() => {
const mocks = initializeMocks();
store = mocks.reduxStore;
axiosMock = mocks.axiosMock;
axiosMock
.onGet(`${getCourseAdvancedSettingsApiUrl(courseId)}?fetch_all=0`)
.reply(200, advancedSettingsMock);
});
it('should render without errors', async () => {
const { getByText } = render();
await waitFor(() => {
expect(getByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
const advancedSettingsElement = getByText(messages.headingTitle.defaultMessage, {
selector: 'h2.sub-header-title',
});
expect(advancedSettingsElement).toBeInTheDocument();
expect(getByText(messages.policy.defaultMessage)).toBeInTheDocument();
expect(getByText(/Do not modify these policies unless you are familiar with their purpose./i)).toBeInTheDocument();
});
});
it('should render setting element', async () => {
const { getByText, queryByText } = render();
await waitFor(() => {
const advancedModuleListTitle = getByText(/Advanced Module List/i);
expect(advancedModuleListTitle).toBeInTheDocument();
expect(queryByText('Certificate web/html view enabled')).toBeNull();
});
});
it('should change to onСhange', async () => {
const { getByLabelText } = render();
await waitFor(() => {
const textarea = getByLabelText(/Advanced Module List/i);
expect(textarea).toBeInTheDocument();
fireEvent.change(textarea, { target: { value: '[1, 2, 3]' } });
expect(textarea.value).toBe('[1, 2, 3]');
});
});
it('should display a warning alert', async () => {
const { getByLabelText, getByText } = render();
await waitFor(() => {
const textarea = getByLabelText(/Advanced Module List/i);
fireEvent.change(textarea, { target: { value: '[3, 2, 1]' } });
expect(getByText(messages.buttonCancelText.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.buttonSaveText.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.alertWarning.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.alertWarningDescriptions.defaultMessage)).toBeInTheDocument();
});
});
it('should display a tooltip on clicking on the icon', async () => {
const { getByLabelText, getByText } = render();
await waitFor(() => {
const button = getByLabelText(/Show help text/i);
fireEvent.click(button);
expect(getByText(/Enter the names of the advanced modules to use in your course./i)).toBeInTheDocument();
});
});
it('should change deprecated button text ', async () => {
const { getByText } = render();
await waitFor(() => {
const showDeprecatedItemsBtn = getByText(/Show Deprecated Settings/i);
expect(showDeprecatedItemsBtn).toBeInTheDocument();
fireEvent.click(showDeprecatedItemsBtn);
expect(getByText(/Hide Deprecated Settings/i)).toBeInTheDocument();
});
expect(getByText('Certificate web/html view enabled')).toBeInTheDocument();
});
it('should reset to default value on click on Cancel button', async () => {
const { getByLabelText, getByText } = render();
let textarea;
await waitFor(() => {
textarea = getByLabelText(/Advanced Module List/i);
});
fireEvent.change(textarea, { target: { value: '[3, 2, 1]' } });
expect(textarea.value).toBe('[3, 2, 1]');
fireEvent.click(getByText(messages.buttonCancelText.defaultMessage));
expect(textarea.value).toBe('[]');
});
it('should update the textarea value and display the updated value after clicking "Change manually"', async () => {
const { getByLabelText, getByText } = render();
let textarea;
await waitFor(() => {
textarea = getByLabelText(/Advanced Module List/i);
});
fireEvent.change(textarea, { target: { value: '[3, 2, 1,' } });
expect(textarea.value).toBe('[3, 2, 1,');
fireEvent.click(getByText('Save changes'));
fireEvent.click(getByText('Change manually'));
expect(textarea.value).toBe('[3, 2, 1,');
});
it('should show success alert after save', async () => {
const { getByLabelText, getByText } = render();
let textarea;
await waitFor(() => {
textarea = getByLabelText(/Advanced Module List/i);
});
fireEvent.change(textarea, { target: { value: '[3, 2, 1]' } });
expect(textarea.value).toBe('[3, 2, 1]');
axiosMock
.onPatch(`${getCourseAdvancedSettingsApiUrl(courseId)}`)
.reply(200, {
...advancedSettingsMock,
advancedModules: {
...advancedSettingsMock.advancedModules,
value: [3, 2, 1],
},
});
fireEvent.click(getByText('Save changes'));
await executeThunk(updateCourseAppSetting(courseId, [3, 2, 1]), store.dispatch);
expect(getByText('Your policy changes have been saved.')).toBeInTheDocument();
});
});

View File

@@ -1,190 +0,0 @@
import userEvent from '@testing-library/user-event';
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
import { useUserPermissions } from '@src/authz/data/apiHooks';
import { mockWaffleFlags } from '@src/data/apiHooks.mock';
import {
render as baseRender,
fireEvent,
initializeMocks,
screen,
} from '@src/testUtils';
import { advancedSettingsMock } from './__mocks__';
import { getCourseAdvancedSettingsApiUrl } from './data/api';
import AdvancedSettings from './AdvancedSettings';
import messages from './messages';
let axiosMock;
const mockPathname = '/foo-bar';
const courseId = '123';
// Mock the TextareaAutosize component
jest.mock('react-textarea-autosize', () => jest.fn((props) => (
<textarea
{...props}
onFocus={() => { }}
/>
)));
jest.mock('@src/authz/data/apiHooks', () => ({
useUserPermissions: jest.fn(),
}));
const render = () => baseRender(
<CourseAuthoringProvider courseId={courseId}>
<AdvancedSettings />
</CourseAuthoringProvider>,
{ path: mockPathname },
);
describe('<AdvancedSettings />', () => {
beforeEach(() => {
const mocks = initializeMocks();
axiosMock = mocks.axiosMock;
axiosMock
.onGet(`${getCourseAdvancedSettingsApiUrl(courseId)}?fetch_all=0`)
.reply(200, advancedSettingsMock);
jest.mocked(useUserPermissions).mockReturnValue({
isLoading: false,
data: { canManageAdvancedSettings: true },
} as unknown as ReturnType<typeof useUserPermissions>);
});
it('should render placeholder when settings fetch returns 403', async () => {
axiosMock
.onGet(`${getCourseAdvancedSettingsApiUrl(courseId)}?fetch_all=0`)
.reply(403);
render();
expect(await screen.findByText(/Under Construction/i)).toBeInTheDocument();
});
it('should render without errors', async () => {
render();
expect(await screen.findByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(messages.headingTitle.defaultMessage, {
selector: 'h2.sub-header-title',
})).toBeInTheDocument();
expect(screen.getByText(messages.policy.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(/Do not modify these policies unless you are familiar with their purpose./i)).toBeInTheDocument();
});
it('should render setting element', async () => {
render();
expect(await screen.findByText(/Advanced Module List/i)).toBeInTheDocument();
expect(screen.queryByText('Certificate web/html view enabled')).toBeNull();
});
it('should change to onСhange', async () => {
render();
const textarea = await screen.findByLabelText(/Advanced Module List/i);
expect(textarea).toBeInTheDocument();
fireEvent.change(textarea, { target: { value: '[1, 2, 3]' } });
expect(textarea).toHaveValue('[1, 2, 3]');
});
it('should display a warning alert', async () => {
render();
const textarea = await screen.findByLabelText(/Advanced Module List/i);
fireEvent.change(textarea, { target: { value: '[3, 2, 1]' } });
expect(screen.getByText(messages.buttonCancelText.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(messages.buttonSaveText.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(messages.alertWarning.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(messages.alertWarningDescriptions.defaultMessage)).toBeInTheDocument();
});
it('should display a tooltip on clicking on the icon', async () => {
const user = userEvent.setup();
render();
const button = await screen.findByLabelText(/Show help text/i);
await user.click(button);
expect(screen.getByText(/Enter the names of the advanced modules to use in your course./i)).toBeInTheDocument();
});
it('should change deprecated button text', async () => {
const user = userEvent.setup();
render();
const showDeprecatedItemsBtn = await screen.findByText(/Show Deprecated Settings/i);
expect(showDeprecatedItemsBtn).toBeInTheDocument();
await user.click(showDeprecatedItemsBtn);
expect(screen.getByText(/Hide Deprecated Settings/i)).toBeInTheDocument();
expect(screen.getByText('Certificate web/html view enabled')).toBeInTheDocument();
});
it('should reset to default value on click on Cancel button', async () => {
const user = userEvent.setup();
render();
const textarea = await screen.findByLabelText(/Advanced Module List/i);
fireEvent.change(textarea, { target: { value: '[3, 2, 1]' } });
expect(textarea).toHaveValue('[3, 2, 1]');
await user.click(screen.getByText(messages.buttonCancelText.defaultMessage));
expect(textarea).toHaveValue('[]');
});
it('should update the textarea value and display the updated value after clicking "Change manually"', async () => {
const user = userEvent.setup();
render();
const textarea = await screen.findByLabelText(/Advanced Module List/i);
fireEvent.change(textarea, { target: { value: '[3, 2, 1,' } });
fireEvent.blur(textarea);
expect(textarea).toHaveValue('[3, 2, 1,');
await user.click(screen.getByText('Save changes'));
await user.click(await screen.findByText('Change manually'));
expect(textarea).toHaveValue('[3, 2, 1,');
});
it('should show success alert after save', async () => {
const user = userEvent.setup();
render();
const textarea = await screen.findByLabelText(/Advanced Module List/i);
fireEvent.change(textarea, { target: { value: '[3, 2, 1]' } });
expect(textarea).toHaveValue('[3, 2, 1]');
axiosMock
.onPatch(`${getCourseAdvancedSettingsApiUrl(courseId)}`)
.reply(200, {
...advancedSettingsMock,
advancedModules: {
...advancedSettingsMock.advancedModules,
value: [3, 2, 1],
},
});
await user.click(screen.getByText('Save changes'));
expect(screen.getByText('Your policy changes have been saved.')).toBeInTheDocument();
});
it('should show error modal on save failure', async () => {
const user = userEvent.setup();
render();
const textarea = await screen.findByLabelText(/Advanced Module List/i);
fireEvent.change(textarea, { target: { value: '[3, 2, 1]' } });
axiosMock
.onPatch(`${getCourseAdvancedSettingsApiUrl(courseId)}`)
.reply(500);
await user.click(screen.getByText('Save changes'));
expect(await screen.findByText('Validation error while saving')).toBeInTheDocument();
});
it('should render without errors when authz.enable_course_authoring flag is enabled and the user is authorized', async () => {
mockWaffleFlags({ enableAuthzCourseAuthoring: true });
jest.mocked(useUserPermissions).mockReturnValue({
isLoading: false,
data: { canManageAdvancedSettings: true },
} as unknown as ReturnType<typeof useUserPermissions>);
render();
expect(await screen.findByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(messages.headingTitle.defaultMessage, {
selector: 'h2.sub-header-title',
})).toBeInTheDocument();
expect(screen.getByText(messages.policy.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(/Do not modify these policies unless you are familiar with their purpose./i)).toBeInTheDocument();
});
it('should show permission alert when authz.enable_course_authoring flag is enabled and the user is not authorized', async () => {
mockWaffleFlags({ enableAuthzCourseAuthoring: true });
jest.mocked(useUserPermissions).mockReturnValue({
isLoading: false,
data: { canManageAdvancedSettings: false },
} as unknown as ReturnType<typeof useUserPermissions>);
render();
expect(await screen.findByTestId('permissionDeniedAlert')).toBeInTheDocument();
});
});

View File

@@ -1,4 +1,4 @@
export default { module.exports = {
advancedModules: { advancedModules: {
deprecated: false, deprecated: false,
displayName: 'Advanced Module List', displayName: 'Advanced Module List',

View File

@@ -1,10 +1,11 @@
/* eslint-disable import/prefer-default-export */
import { import {
camelCaseObject, camelCaseObject,
getConfig, getConfig,
} from '@edx/frontend-platform'; } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { camelCase } from 'lodash'; import { camelCase } from 'lodash';
import { convertObjectToSnakeCase } from '@src/utils'; import { convertObjectToSnakeCase } from '../../utils';
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL; const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
export const getCourseAdvancedSettingsApiUrl = (courseId) => `${getApiBaseUrl()}/api/contentstore/v0/advanced_settings/${courseId}`; export const getCourseAdvancedSettingsApiUrl = (courseId) => `${getApiBaseUrl()}/api/contentstore/v0/advanced_settings/${courseId}`;
@@ -12,8 +13,10 @@ const getProctoringErrorsApiUrl = () => `${getApiBaseUrl()}/api/contentstore/v1/
/** /**
* Get's advanced setting for a course. * Get's advanced setting for a course.
* @param {string} courseId
* @returns {Promise<Object>}
*/ */
export async function getCourseAdvancedSettings(courseId: string): Promise<Record<string, any>> { export async function getCourseAdvancedSettings(courseId) {
const { data } = await getAuthenticatedHttpClient() const { data } = await getAuthenticatedHttpClient()
.get(`${getCourseAdvancedSettingsApiUrl(courseId)}?fetch_all=0`); .get(`${getCourseAdvancedSettingsApiUrl(courseId)}?fetch_all=0`);
const keepValues = {}; const keepValues = {};
@@ -33,11 +36,11 @@ export async function getCourseAdvancedSettings(courseId: string): Promise<Recor
/** /**
* Updates advanced setting for a course. * Updates advanced setting for a course.
* @param {string} courseId
* @param {object} settings
* @returns {Promise<Object>}
*/ */
export async function updateCourseAdvancedSettings( export async function updateCourseAdvancedSettings(courseId, settings) {
courseId: string,
settings: Record<string, any>,
): Promise<Record<string, any>> {
const { data } = await getAuthenticatedHttpClient() const { data } = await getAuthenticatedHttpClient()
.patch(`${getCourseAdvancedSettingsApiUrl(courseId)}`, convertObjectToSnakeCase(settings)); .patch(`${getCourseAdvancedSettingsApiUrl(courseId)}`, convertObjectToSnakeCase(settings));
const keepValues = {}; const keepValues = {};
@@ -57,8 +60,10 @@ export async function updateCourseAdvancedSettings(
/** /**
* Gets proctoring exam errors. * Gets proctoring exam errors.
* @param {string} courseId
* @returns {Promise<Object>}
*/ */
export async function getProctoringExamErrors(courseId: string): Promise<Record<string, any>> { export async function getProctoringExamErrors(courseId) {
const { data } = await getAuthenticatedHttpClient().get(`${getProctoringErrorsApiUrl()}${courseId}`); const { data } = await getAuthenticatedHttpClient().get(`${getProctoringErrorsApiUrl()}${courseId}`);
const keepValues = {}; const keepValues = {};
Object.keys(data).forEach((key) => { Object.keys(data).forEach((key) => {
@@ -72,6 +77,5 @@ export async function getProctoringExamErrors(courseId: string): Promise<Record<
value: keepValues[key]?.value, value: keepValues[key]?.value,
}; };
}); });
return formattedData; return formattedData;
} }

View File

@@ -1,56 +0,0 @@
/* eslint-disable import/no-extraneous-dependencies */
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { AxiosError } from 'axios';
import {
getCourseAdvancedSettings,
getProctoringExamErrors,
updateCourseAdvancedSettings,
} from './api';
export const advancedSettingsQueryKeys = {
all: ['advancedSettings'],
/** Base key for advanced settings specific to a courseId */
courseAdvancedSettings: (courseId: string) => [...advancedSettingsQueryKeys.all, courseId],
/** Key for proctoring exam errors specific to a courseId */
proctoringExamErrors: (courseId: string) => [...advancedSettingsQueryKeys.all, courseId, 'proctoringErrors'],
};
const sortSettingsByDisplayName = (settings: Record<string, any>): Record<string, any> => (
Object.fromEntries(Object.entries(settings).sort(
([, v1], [, v2]) => v1.displayName.localeCompare(v2.displayName),
))
);
/**
* Fetches the advanced settings for a course, sorted alphabetically by display name.
*/
export const useCourseAdvancedSettings = (courseId: string) => (
useQuery<Record<string, any>, AxiosError>({
queryKey: advancedSettingsQueryKeys.courseAdvancedSettings(courseId),
queryFn: () => getCourseAdvancedSettings(courseId),
select: sortSettingsByDisplayName,
})
);
/**
* Fetches the proctoring exam errors for a course.
*/
export const useProctoringExamErrors = (courseId: string) => (
useQuery({
queryKey: advancedSettingsQueryKeys.proctoringExamErrors(courseId),
queryFn: () => getProctoringExamErrors(courseId),
})
);
/**
* Returns a mutation to update the advanced settings for a course.
*/
export const useUpdateCourseAdvancedSettings = (courseId: string) => {
const queryClient = useQueryClient();
return useMutation<Record<string, any>, AxiosError, Record<string, any>>({
mutationFn: (settings: Record<string, any>) => updateCourseAdvancedSettings(courseId, settings),
onSettled: () => {
queryClient.invalidateQueries({ queryKey: advancedSettingsQueryKeys.courseAdvancedSettings(courseId) });
},
});
};

View File

@@ -0,0 +1,5 @@
export const getLoadingStatus = (state) => state.advancedSettings.loadingStatus;
export const getCourseAppSettings = state => state.advancedSettings.courseAppSettings;
export const getSavingStatus = (state) => state.advancedSettings.savingStatus;
export const getProctoringExamErrors = (state) => state.advancedSettings.proctoringErrors;
export const getSendRequestErrors = (state) => state.advancedSettings.sendRequestErrors.developer_message;

View File

@@ -0,0 +1,48 @@
/* eslint-disable no-param-reassign */
import { createSlice } from '@reduxjs/toolkit';
import { RequestStatus } from '../../data/constants';
const slice = createSlice({
name: 'advancedSettings',
initialState: {
loadingStatus: RequestStatus.IN_PROGRESS,
savingStatus: '',
courseAppSettings: {},
proctoringErrors: {},
sendRequestErrors: {},
},
reducers: {
updateLoadingStatus: (state, { payload }) => {
state.loadingStatus = payload.status;
},
updateSavingStatus: (state, { payload }) => {
state.savingStatus = payload.status;
},
fetchCourseAppsSettingsSuccess: (state, { payload }) => {
Object.assign(state.courseAppSettings, payload);
},
updateCourseAppsSettingsSuccess: (state, { payload }) => {
Object.assign(state.courseAppSettings, payload);
},
getDataSendErrors: (state, { payload }) => {
Object.assign(state.sendRequestErrors, payload);
},
fetchProctoringExamErrorsSuccess: (state, { payload }) => {
Object.assign(state.proctoringErrors, payload);
},
},
});
export const {
updateLoadingStatus,
updateSavingStatus,
getDataSendErrors,
fetchCourseAppsSettingsSuccess,
updateCourseAppsSettingsSuccess,
fetchProctoringExamErrorsSuccess,
} = slice.actions;
export const {
reducer,
} = slice;

View File

@@ -0,0 +1,85 @@
import { RequestStatus } from '../../data/constants';
import {
getCourseAdvancedSettings,
updateCourseAdvancedSettings,
getProctoringExamErrors,
} from './api';
import {
fetchCourseAppsSettingsSuccess,
updateCourseAppsSettingsSuccess,
updateLoadingStatus,
updateSavingStatus,
fetchProctoringExamErrorsSuccess,
getDataSendErrors,
} from './slice';
export function fetchCourseAppSettings(courseId) {
return async (dispatch) => {
dispatch(updateLoadingStatus({ status: RequestStatus.IN_PROGRESS }));
try {
const settingValues = await getCourseAdvancedSettings(courseId);
const sortedDisplayName = [];
Object.values(settingValues).forEach(value => {
const { displayName } = value;
sortedDisplayName.push(displayName);
});
const sortedSettingValues = {};
sortedDisplayName.sort().forEach((displayName => {
Object.entries(settingValues).forEach(([key, value]) => {
if (value.displayName === displayName) {
sortedSettingValues[key] = value;
}
});
}));
dispatch(fetchCourseAppsSettingsSuccess(sortedSettingValues));
dispatch(updateLoadingStatus({ status: RequestStatus.SUCCESSFUL }));
} catch (error) {
if (error.response && error.response.status === 403) {
dispatch(updateLoadingStatus({ courseId, status: RequestStatus.DENIED }));
} else {
dispatch(updateLoadingStatus({ courseId, status: RequestStatus.FAILED }));
}
}
};
}
export function updateCourseAppSetting(courseId, settings) {
return async (dispatch) => {
dispatch(updateSavingStatus({ status: RequestStatus.IN_PROGRESS }));
try {
const settingValues = await updateCourseAdvancedSettings(courseId, settings);
dispatch(updateCourseAppsSettingsSuccess(settingValues));
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
return true;
} catch (error) {
let errorData;
try {
const { customAttributes: { httpErrorResponseData } } = error;
errorData = JSON.parse(httpErrorResponseData);
} catch (err) {
errorData = {};
}
dispatch(getDataSendErrors(errorData));
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
return false;
}
};
}
export function fetchProctoringExamErrors(courseId) {
return async (dispatch) => {
dispatch(updateSavingStatus({ status: RequestStatus.IN_PROGRESS }));
try {
const settingValues = await getProctoringExamErrors(courseId);
dispatch(fetchProctoringExamErrorsSuccess(settingValues));
return true;
} catch (error) {
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
return false;
}
};
}

View File

@@ -17,7 +17,7 @@ export default function validateAdvancedSettingsData(settingObj, setErrorFields,
Object.entries(settingObj).forEach(([settingName, settingValue]) => { Object.entries(settingObj).forEach(([settingName, settingValue]) => {
try { try {
JSON.parse(settingValue); JSON.parse(settingValue);
} catch { } catch (e) {
let targetSettingValue = settingValue; let targetSettingValue = settingValue;
const firstNonWhite = settingValue.substring(0, 1); const firstNonWhite = settingValue.substring(0, 1);
const isValid = !['{', '[', "'"].includes(firstNonWhite); const isValid = !['{', '[', "'"].includes(firstNonWhite);
@@ -30,7 +30,7 @@ export default function validateAdvancedSettingsData(settingObj, setErrorFields,
...prevEditedSettings, ...prevEditedSettings,
[settingName]: targetSettingValue, [settingName]: targetSettingValue,
})); }));
} catch { /* empty */ } } catch (quotedE) { /* empty */ }
} }
pushDataToErrorArray(settingName); pushDataToErrorArray(settingName);

View File

@@ -14,7 +14,3 @@ export const CONTENT_LIBRARY_PERMISSIONS = {
MANAGE_LIBRARY_TEAM: 'content_libraries.manage_library_team', MANAGE_LIBRARY_TEAM: 'content_libraries.manage_library_team',
VIEW_LIBRARY_TEAM: 'content_libraries.view_library_team', VIEW_LIBRARY_TEAM: 'content_libraries.view_library_team',
}; };
export const COURSE_PERMISSIONS = {
MANAGE_ADVANCED_SETTINGS: 'courses.manage_advanced_settings',
};

View File

@@ -1,4 +1,4 @@
import { skipToken, useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { PermissionValidationAnswer, PermissionValidationQuery } from '@src/authz/types'; import { PermissionValidationAnswer, PermissionValidationQuery } from '@src/authz/types';
import { validateUserPermissions } from './api'; import { validateUserPermissions } from './api';
@@ -29,9 +29,8 @@ const adminConsoleQueryKeys = {
*/ */
export const useUserPermissions = ( export const useUserPermissions = (
permissions: PermissionValidationQuery, permissions: PermissionValidationQuery,
enabled: boolean = true,
) => useQuery<PermissionValidationAnswer, Error>({ ) => useQuery<PermissionValidationAnswer, Error>({
queryKey: adminConsoleQueryKeys.permissions(permissions), queryKey: adminConsoleQueryKeys.permissions(permissions),
queryFn: enabled ? () => validateUserPermissions(permissions) : skipToken, queryFn: () => validateUserPermissions(permissions),
retry: false, retry: false,
}); });

View File

@@ -1,6 +1,6 @@
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import PropTypes from 'prop-types';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import Placeholder from '../editors/Placeholder'; import Placeholder from '../editors/Placeholder';
import { RequestStatus } from '../data/constants'; import { RequestStatus } from '../data/constants';
import Loading from '../generic/Loading'; import Loading from '../generic/Loading';
@@ -21,8 +21,7 @@ const MODE_COMPONENTS = {
[MODE_STATES.editAll]: CertificateEditForm, [MODE_STATES.editAll]: CertificateEditForm,
}; };
const Certificates = () => { const Certificates = ({ courseId }) => {
const { courseId } = useCourseAuthoringContext();
const { const {
certificates, componentMode, isLoading, loadingStatus, pageHeadTitle, hasCertificateModes, certificates, componentMode, isLoading, loadingStatus, pageHeadTitle, hasCertificateModes,
} = useCertificates({ courseId }); } = useCertificates({ courseId });
@@ -51,4 +50,8 @@ const Certificates = () => {
); );
}; };
Certificates.propTypes = {
courseId: PropTypes.string.isRequired,
};
export default Certificates; export default Certificates;

View File

@@ -1,7 +1,6 @@
// @ts-check // @ts-check
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
import { initializeMocks, render, waitFor } from '../testUtils'; import { initializeMocks, render, waitFor } from '../testUtils';
import { RequestStatus } from '../data/constants'; import { RequestStatus } from '../data/constants';
import { executeThunk } from '../utils'; import { executeThunk } from '../utils';
@@ -15,11 +14,7 @@ let axiosMock;
let store; let store;
const courseId = 'course-123'; const courseId = 'course-123';
const renderComponent = (props) => render( const renderComponent = (props) => render(<Certificates courseId={courseId} {...props} />);
<CourseAuthoringProvider courseId={courseId}>
<Certificates {...props} />
</CourseAuthoringProvider>,
);
describe('Certificates', () => { describe('Certificates', () => {
beforeEach(async () => { beforeEach(async () => {

View File

@@ -1,4 +1,4 @@
export default [ module.exports = [
{ {
id: 1, id: 1,
courseTitle: 'Course Title 1', courseTitle: 'Course Title 1',

View File

@@ -1,4 +1,4 @@
export default { module.exports = {
certificateActivationHandlerUrl: '/certificates/activation/course-v1:org+101+101/', certificateActivationHandlerUrl: '/certificates/activation/course-v1:org+101+101/',
certificateWebViewUrl: '//certificates/course/course-v1:org+101+101?preview=honor', certificateWebViewUrl: '//certificates/course/course-v1:org+101+101?preview=honor',
certificates: [ certificates: [

View File

@@ -1,4 +1,4 @@
export default [ module.exports = [
{ {
id: '1', name: 'John Doe', title: 'CEO', organization: 'Company', signatureImagePath: '/path/to/signature1.png', id: '1', name: 'John Doe', title: 'CEO', organization: 'Company', signatureImagePath: '/path/to/signature1.png',
}, },

View File

@@ -7,7 +7,7 @@ const CertificateSection = ({
<section {...rest}> <section {...rest}>
<Stack className="justify-content-between mb-2.5" direction="horizontal"> <Stack className="justify-content-between mb-2.5" direction="horizontal">
<h2 className="lead section-title mb-0">{title}</h2> <h2 className="lead section-title mb-0">{title}</h2>
{actions} {actions && actions}
</Stack> </Stack>
<hr className="mt-0 mb-4" /> <hr className="mt-0 mb-4" />
<div> <div>

View File

@@ -25,7 +25,6 @@ const useCertificatesList = (courseId) => {
})); }));
const handleSubmit = async (values) => { const handleSubmit = async (values) => {
// oxlint-disable-next-line @typescript-eslint/await-thenable - this dispatch() IS returning a promise.
await dispatch(updateCourseCertificate(courseId, values)); await dispatch(updateCourseCertificate(courseId, values));
setEditModes({}); setEditModes({});
dispatch(setMode(MODE_STATES.view)); dispatch(setMode(MODE_STATES.view));

View File

@@ -5,15 +5,17 @@ import { prepareCertificatePayload } from '../utils';
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL; const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
export const getCertificatesApiUrl = (courseId: string) => `${getApiBaseUrl()}/api/contentstore/v1/certificates/${courseId}`; export const getCertificatesApiUrl = (courseId) => `${getApiBaseUrl()}/api/contentstore/v1/certificates/${courseId}`;
export const getCertificateApiUrl = (courseId: string) => `${getApiBaseUrl()}/certificates/${courseId}`; export const getCertificateApiUrl = (courseId) => `${getApiBaseUrl()}/certificates/${courseId}`;
export const getUpdateCertificateApiUrl = (courseId: string, certificateId: number) => `${getCertificateApiUrl(courseId)}/${certificateId}`; export const getUpdateCertificateApiUrl = (courseId, certificateId) => `${getCertificateApiUrl(courseId)}/${certificateId}`;
export const getUpdateCertificateActiveStatusApiUrl = (path: string) => `${getApiBaseUrl()}${path}`; export const getUpdateCertificateActiveStatusApiUrl = (path) => `${getApiBaseUrl()}${path}`;
/** /**
* Gets certificates for a course. * Gets certificates for a course.
* @param {string} courseId
* @returns {Promise<Object>}
*/ */
export async function getCertificates(courseId: string): Promise<Record<string, any>> { export async function getCertificates(courseId) {
const { data } = await getAuthenticatedHttpClient() const { data } = await getAuthenticatedHttpClient()
.get(getCertificatesApiUrl(courseId)); .get(getCertificatesApiUrl(courseId));
@@ -22,11 +24,12 @@ export async function getCertificates(courseId: string): Promise<Record<string,
/** /**
* Create course certificate. * Create course certificate.
* @param {string} courseId
* @param {object} certificatesData
* @returns {Promise<Object>}
*/ */
export async function createCertificate(
courseId: string, export async function createCertificate(courseId, certificatesData) {
certificatesData: Record<string, any>,
): Promise<Record<string, any>> {
const { data } = await getAuthenticatedHttpClient() const { data } = await getAuthenticatedHttpClient()
.post( .post(
getCertificateApiUrl(courseId), getCertificateApiUrl(courseId),
@@ -38,11 +41,11 @@ export async function createCertificate(
/** /**
* Update course certificate. * Update course certificate.
* @param {string} courseId
* @param {object} certificateData
* @returns {Promise<Object>}
*/ */
export async function updateCertificate( export async function updateCertificate(courseId, certificateData) {
courseId: string,
certificateData: Record<string, any>,
): Promise<Record<string, any>> {
const { data } = await getAuthenticatedHttpClient() const { data } = await getAuthenticatedHttpClient()
.post( .post(
getUpdateCertificateApiUrl(courseId, certificateData.id), getUpdateCertificateApiUrl(courseId, certificateData.id),
@@ -54,8 +57,11 @@ export async function updateCertificate(
/** /**
* Delete course certificate. * Delete course certificate.
* @param {string} courseId
* @param {object} certificateId
* @returns {Promise<Object>}
*/ */
export async function deleteCertificate(courseId: string, certificateId: number): Promise<Record<string, any>> { export async function deleteCertificate(courseId, certificateId) {
const { data } = await getAuthenticatedHttpClient() const { data } = await getAuthenticatedHttpClient()
.delete( .delete(
getUpdateCertificateApiUrl(courseId, certificateId), getUpdateCertificateApiUrl(courseId, certificateId),
@@ -65,8 +71,11 @@ export async function deleteCertificate(courseId: string, certificateId: number)
/** /**
* Activate/deactivate course certificate. * Activate/deactivate course certificate.
* @param {string} courseId
* @param {object} activationStatus
* @returns {Promise<Object>}
*/ */
export async function updateActiveStatus(path: string, activationStatus: unknown): Promise<Record<string, any>> { export async function updateActiveStatus(path, activationStatus) {
const body = { const body = {
is_active: activationStatus, is_active: activationStatus,
}; };

View File

@@ -4,7 +4,7 @@ export const MODE_STATES = {
view: 'view', view: 'view',
editAll: 'edit_all', editAll: 'edit_all',
create: 'create', create: 'create',
} as const; };
export const ACTIVATION_MESSAGES = { export const ACTIVATION_MESSAGES = {
activating: 'Activating', activating: 'Activating',

View File

@@ -0,0 +1,22 @@
import { createSelector } from '@reduxjs/toolkit';
export const getLoadingStatus = (state) => state.certificates.loadingStatus;
export const getSavingStatus = (state) => state.certificates.savingStatus;
export const getSavingImageStatus = (state) => state.certificates.savingImageStatus;
export const getErrorMessage = (state) => state.certificates.errorMessage;
export const getSendRequestErrors = (state) => state.certificates.sendRequestErrors.developer_message;
export const getCertificates = state => state.certificates.certificatesData.certificates;
export const getHasCertificateModes = state => state.certificates.certificatesData.hasCertificateModes;
export const getCourseModes = state => state.certificates.certificatesData.courseModes;
export const getCertificateActivationUrl = state => state.certificates.certificatesData.certificateActivationHandlerUrl;
export const getCertificateWebViewUrl = state => state.certificates.certificatesData.certificateWebViewUrl;
export const getIsCertificateActive = state => state.certificates.certificatesData.isActive;
export const getComponentMode = state => state.certificates.componentMode;
export const getCourseNumber = state => state.certificates.certificatesData.courseNumber;
export const getCourseNumberOverride = state => state.certificates.certificatesData.courseNumberOverride;
export const getCourseTitle = state => state.certificates.certificatesData.courseTitle;
export const getHasCertificates = createSelector(
[getCertificates],
(certificates) => certificates && certificates.length > 0,
);

View File

@@ -1,25 +0,0 @@
/* eslint-disable max-len */
import { createSelector } from '@reduxjs/toolkit';
import type { DeprecatedReduxState } from '@src/store';
export const getLoadingStatus = (state: DeprecatedReduxState) => state.certificates.loadingStatus;
export const getSavingStatus = (state: DeprecatedReduxState) => state.certificates.savingStatus;
export const getSavingImageStatus = (state: DeprecatedReduxState) => state.certificates.savingImageStatus;
export const getErrorMessage = (state: DeprecatedReduxState) => state.certificates.errorMessage;
// Commenting this one out as it seems to be unused:
// export const getSendRequestErrors = (state: DeprecatedReduxState) => state.certificates.sendRequestErrors.developer_message;
export const getCertificates = (state: DeprecatedReduxState) => state.certificates.certificatesData.certificates;
export const getHasCertificateModes = (state: DeprecatedReduxState) => state.certificates.certificatesData.hasCertificateModes;
export const getCourseModes = (state: DeprecatedReduxState) => state.certificates.certificatesData.courseModes;
export const getCertificateActivationUrl = (state: DeprecatedReduxState) => state.certificates.certificatesData.certificateActivationHandlerUrl;
export const getCertificateWebViewUrl = (state: DeprecatedReduxState) => state.certificates.certificatesData.certificateWebViewUrl;
export const getIsCertificateActive = (state: DeprecatedReduxState) => state.certificates.certificatesData.isActive;
export const getComponentMode = (state: DeprecatedReduxState) => state.certificates.componentMode;
export const getCourseNumber = (state: DeprecatedReduxState) => state.certificates.certificatesData.courseNumber;
export const getCourseNumberOverride = (state: DeprecatedReduxState) => state.certificates.certificatesData.courseNumberOverride;
export const getCourseTitle = (state: DeprecatedReduxState) => state.certificates.certificatesData.courseTitle;
export const getHasCertificates = createSelector(
[getCertificates],
(certificates) => certificates && certificates.length > 0,
);

View File

@@ -7,7 +7,7 @@ import { MODE_STATES } from './constants';
const slice = createSlice({ const slice = createSlice({
name: 'certificates', name: 'certificates',
initialState: { initialState: {
certificatesData: {} as Record<string, any>, certificatesData: {},
componentMode: MODE_STATES.noModes, componentMode: MODE_STATES.noModes,
loadingStatus: RequestStatus.PENDING, loadingStatus: RequestStatus.PENDING,
savingStatus: '', savingStatus: '',

View File

@@ -32,7 +32,7 @@ export function fetchCertificates(courseId) {
dispatch(fetchCertificatesSuccess(certificates)); dispatch(fetchCertificatesSuccess(certificates));
dispatch(updateLoadingStatus({ status: RequestStatus.SUCCESSFUL })); dispatch(updateLoadingStatus({ status: RequestStatus.SUCCESSFUL }));
} catch (error: any) { } catch (error) {
if (error.response && error.response.status === 403) { if (error.response && error.response.status === 403) {
dispatch(updateLoadingStatus({ courseId, status: RequestStatus.DENIED })); dispatch(updateLoadingStatus({ courseId, status: RequestStatus.DENIED }));
} else { } else {
@@ -84,8 +84,8 @@ export function deleteCourseCertificate(courseId, certificateId) {
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.deleting)); dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.deleting));
try { try {
await deleteCertificate(courseId, certificateId); const certificatesValues = await deleteCertificate(courseId, certificateId);
dispatch(deleteCertificateSuccess()); dispatch(deleteCertificateSuccess(certificatesValues));
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
return true; return true;
} catch (error) { } catch (error) {

Some files were not shown because too many files have changed in this diff Show More