Compare commits
176 Commits
release/ul
...
renovate/n
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d24bc96a00 | ||
|
|
1efd559786 | ||
|
|
df79861685 | ||
|
|
24e1c73f6b | ||
|
|
449af65d01 | ||
|
|
fce65c0215 | ||
|
|
abcef4a502 | ||
|
|
5c1cdcf01c | ||
|
|
4dccc12883 | ||
|
|
f0e735b3a1 | ||
|
|
091d9a1c3e | ||
|
|
7157d17a4e | ||
|
|
65096fde0e | ||
|
|
af204c78a5 | ||
|
|
2e6209314f | ||
|
|
060f7d4618 | ||
|
|
815b80a944 | ||
|
|
e8cd7c2dcc | ||
|
|
c4a09a2b43 | ||
|
|
3599630cd7 | ||
|
|
56726448fc | ||
|
|
b57386b9b6 | ||
|
|
e9bf4566de | ||
|
|
1324b33789 | ||
|
|
b256036527 | ||
|
|
ee6006e0e3 | ||
|
|
8f8c6d8dd2 | ||
|
|
7c1eb59f18 | ||
|
|
5ccf39d130 | ||
|
|
42f26e7404 | ||
|
|
01ddf0d2ad | ||
|
|
7a8b4ced01 | ||
|
|
e116e10845 | ||
|
|
0b357a1be1 | ||
|
|
e281681168 | ||
|
|
c39923fc81 | ||
|
|
4d401a9c22 | ||
|
|
72421969d9 | ||
|
|
0aad03420b | ||
|
|
45811ce6de | ||
|
|
d1c3fb96de | ||
|
|
2d5421e09f | ||
|
|
a16087a1d0 | ||
|
|
bb6b2ab33c | ||
|
|
eae178a9ec | ||
|
|
2ab1219f36 | ||
|
|
7173b71cc4 | ||
|
|
f900ace15c | ||
|
|
d44425c68f | ||
|
|
6effb4d39e | ||
|
|
b0066e547c | ||
|
|
6558c2b1ed | ||
|
|
747c2bc1b0 | ||
|
|
27e709912d | ||
|
|
9d9d7a7167 | ||
|
|
7e53e1143f | ||
|
|
66656753b4 | ||
|
|
05de984fa4 | ||
|
|
f6da58a950 | ||
|
|
5e5c92f487 | ||
|
|
10705bc921 | ||
|
|
2e836a55cf | ||
|
|
292a457834 | ||
|
|
5d97a98294 | ||
|
|
53923d0db4 | ||
|
|
5157cbcfb2 | ||
|
|
ef93e95dd7 | ||
|
|
1626d6808d | ||
|
|
b1c3151ca1 | ||
|
|
bea1619537 | ||
|
|
89e327b633 | ||
|
|
0db79f3527 | ||
|
|
82e24193a8 | ||
|
|
f57d33a74e | ||
|
|
0b2b8e142c | ||
|
|
d5352b8632 | ||
|
|
3076dc7598 | ||
|
|
a23a4da0a2 | ||
|
|
4cda17e046 | ||
|
|
969f7a2858 | ||
|
|
12f1c2b1f5 | ||
|
|
6464f37e2a | ||
|
|
5641daf68d | ||
|
|
3c22e4bbe1 | ||
|
|
a7cbfead75 | ||
|
|
7b0223cefc | ||
|
|
478749028b | ||
|
|
220924233e | ||
|
|
122414cb73 | ||
|
|
fd0395ba03 | ||
|
|
38dfb68286 | ||
|
|
0f20267cc4 | ||
|
|
7f10575b52 | ||
|
|
ef6bd073c0 | ||
|
|
f4d20eba45 | ||
|
|
68a4b04475 | ||
|
|
c2592a7e6e | ||
|
|
9072bb66b7 | ||
|
|
8b9f156149 | ||
|
|
9c70fd9216 | ||
|
|
4df44ab6cf | ||
|
|
0405156f91 | ||
|
|
1e2e711eaf | ||
|
|
3eeca244d7 | ||
|
|
ae67be83a0 | ||
|
|
6f37118960 | ||
|
|
41a326f7b4 | ||
|
|
b7dd6706c2 | ||
|
|
64ab7ea4fe | ||
|
|
89d2393d6e | ||
|
|
42acb53201 | ||
|
|
225c82d037 | ||
|
|
a596bf5d38 | ||
|
|
67a5694ca3 | ||
|
|
833a838348 | ||
|
|
3cf1ce2b5d | ||
|
|
7eea38e623 | ||
|
|
128de3ffc4 | ||
|
|
6724e1a296 | ||
|
|
8fc00709b4 | ||
|
|
f367a7641d | ||
|
|
49d809b809 | ||
|
|
dad736f9d1 | ||
|
|
50f4f70671 | ||
|
|
0796e897ae | ||
|
|
b76b003e01 | ||
|
|
e95758cc55 | ||
|
|
c7ab2b2837 | ||
|
|
dfe87f983b | ||
|
|
f9e09f0601 | ||
|
|
0c1554b337 | ||
|
|
294fe42942 | ||
|
|
95ec41d2e7 | ||
|
|
e620d70793 | ||
|
|
fa562a691b | ||
|
|
70c19a3ffb | ||
|
|
ef36156d55 | ||
|
|
735159311d | ||
|
|
4fd52b9a4d | ||
|
|
4a81ddbe74 | ||
|
|
2215fc53cc | ||
|
|
23ab74da14 | ||
|
|
1d385c6b4d | ||
|
|
acbd1de548 | ||
|
|
650bb62fa7 | ||
|
|
e2f1aedf9a | ||
|
|
5fadccabe2 | ||
|
|
daed664404 | ||
|
|
495a682926 | ||
|
|
6f41cd76da | ||
|
|
64489e1b4d | ||
|
|
91c813b214 | ||
|
|
2e7a29465f | ||
|
|
0f58329cb4 | ||
|
|
54cfbeb756 | ||
|
|
7cf01de84c | ||
|
|
a1abd43a11 | ||
|
|
8f06263e27 | ||
|
|
e10ab270dd | ||
|
|
a5d65abea2 | ||
|
|
5ec00236cb | ||
|
|
2530b01b82 | ||
|
|
13c51ce5a8 | ||
|
|
b2cfafc00e | ||
|
|
6d619b9c40 | ||
|
|
6afe6095a5 | ||
|
|
1b357cb2b6 | ||
|
|
2de987b254 | ||
|
|
4299bf16b4 | ||
|
|
5cda284cdb | ||
|
|
436ac3155d | ||
|
|
86a7e06a3c | ||
|
|
bd82c1d33d | ||
|
|
75ae9d549c | ||
|
|
cec074e6d4 | ||
|
|
36c9eba66d |
6
.env
6
.env
@@ -36,15 +36,17 @@ ENABLE_ASSETS_PAGE=false
|
||||
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=false
|
||||
ENABLE_TAGGING_TAXONOMY_PAGES=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=''
|
||||
HOTJAR_APP_ID=''
|
||||
HOTJAR_VERSION=6
|
||||
HOTJAR_DEBUG=false
|
||||
INVITE_STUDENTS_EMAIL_TO=''
|
||||
ENABLE_CHECKLIST_QUALITY=''
|
||||
ENABLE_GRADING_METHOD_IN_PROBLEMS=false
|
||||
# "Multi-level" blocks are unsupported in libraries
|
||||
LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder"
|
||||
LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder,library_content,itembank"
|
||||
# Fallback in local style files
|
||||
PARAGON_THEME_URLS={}
|
||||
COURSE_TEAM_SUPPORT_EMAIL=''
|
||||
|
||||
@@ -37,6 +37,9 @@ ENABLE_UNIT_PAGE=false
|
||||
ENABLE_ASSETS_PAGE=false
|
||||
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=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_TAGGING_TAXONOMY_PAGES=true
|
||||
BBB_LEARN_MORE_URL=''
|
||||
@@ -45,9 +48,8 @@ HOTJAR_VERSION=6
|
||||
HOTJAR_DEBUG=true
|
||||
INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
|
||||
ENABLE_CHECKLIST_QUALITY=true
|
||||
ENABLE_GRADING_METHOD_IN_PROBLEMS=false
|
||||
# "Multi-level" blocks are unsupported in libraries
|
||||
LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder"
|
||||
LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder,library_content,itembank"
|
||||
# Fallback in local style files
|
||||
PARAGON_THEME_URLS={}
|
||||
COURSE_TEAM_SUPPORT_EMAIL=''
|
||||
|
||||
@@ -33,12 +33,14 @@ ENABLE_UNIT_PAGE=true
|
||||
ENABLE_ASSETS_PAGE=false
|
||||
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=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
|
||||
BBB_LEARN_MORE_URL=''
|
||||
INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
|
||||
ENABLE_CHECKLIST_QUALITY=true
|
||||
ENABLE_GRADING_METHOD_IN_PROBLEMS=false
|
||||
# "Multi-level" blocks are unsupported in libraries
|
||||
LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder"
|
||||
LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder,library_content,itembank"
|
||||
PARAGON_THEME_URLS=
|
||||
COURSE_TEAM_SUPPORT_EMAIL='support@example.com'
|
||||
|
||||
@@ -14,6 +14,15 @@ module.exports = createConfig(
|
||||
'no-restricted-exports': 'off',
|
||||
// There is no reason to disallow this syntax anymore; we don't use regenerator-runtime in new browsers
|
||||
'no-restricted-syntax': 'off',
|
||||
'no-restricted-imports': ['error', {
|
||||
patterns: [
|
||||
{
|
||||
group: ['@edx/frontend-platform/i18n'],
|
||||
importNames: ['injectIntl'],
|
||||
message: "Use 'useIntl' hook instead of injectIntl.",
|
||||
},
|
||||
],
|
||||
}],
|
||||
},
|
||||
settings: {
|
||||
// Import URLs should be resolved using aliases
|
||||
|
||||
4
.github/pull_request_template.md
vendored
4
.github/pull_request_template.md
vendored
@@ -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:
|
||||
|
||||
- [ ] Any _new_ files are using TypeScript (`.ts`, `.tsx`).
|
||||
- [ ] Deprecated `propTypes`, `defaultProps`, and `injectIntl` patterns are not used in any new or modified code.
|
||||
- [ ] Avoid `propTypes` and `defaultProps` in any new or modified code.
|
||||
- [ ] 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.
|
||||
- [ ] 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.
|
||||
- [ ] Imports avoid using `../`. To import from parent folders, use `@src`, e.g. `import { initializeMocks } from '@src/testUtils';` instead of `from '../../../../testUtils'`
|
||||
- [ ] Avoid using `../` in import paths. To import from parent folders, use `@src`, e.g. `import { initializeMocks } from '@src/testUtils';` instead of `from '../../../../testUtils'`
|
||||
|
||||
8
.github/workflows/validate.yml
vendored
8
.github/workflows/validate.yml
vendored
@@ -11,13 +11,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
- run: make validate.ci
|
||||
- name: Archive code coverage results
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: code-coverage-report
|
||||
path: coverage/*.*
|
||||
@@ -25,9 +25,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
needs: tests
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
- name: Download code coverage results
|
||||
uses: actions/download-artifact@v6
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: code-coverage-report
|
||||
path: coverage
|
||||
|
||||
23
.oxlintrc.json
Normal file
23
.oxlintrc.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$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",
|
||||
]
|
||||
}
|
||||
4
Makefile
4
Makefile
@@ -51,7 +51,9 @@ validate-no-uncommitted-package-lock-changes:
|
||||
validate:
|
||||
make validate-no-uncommitted-package-lock-changes
|
||||
npm run i18n_extract
|
||||
npm run lint -- --max-warnings 0
|
||||
# We are trying out oxlint. Now that it's been working well for a while with both oxlint and eslint, we have disabled
|
||||
# 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 test:ci
|
||||
npm run build
|
||||
|
||||
13
README.rst
13
README.rst
@@ -40,7 +40,7 @@ Cloning and Setup
|
||||
|
||||
2. Use the version of Node specified in the ``.nvmrc`` file.
|
||||
|
||||
The current version of the micro-frontend build scripts supports node 20.
|
||||
The current version of the micro-frontend build scripts supports the version of Node found in ``.nvmrc``.
|
||||
Using other major versions of node *may* work, but this is unsupported. For
|
||||
convenience, this repository includes an ``.nvmrc`` file to help in setting the
|
||||
correct node version via `nvm <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
|
||||
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>`__.
|
||||
|
||||
|
||||
@@ -175,10 +175,6 @@ Feature: New Proctoring Exams View
|
||||
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
|
||||
|
||||
Configuration
|
||||
@@ -196,7 +192,6 @@ In Studio, a new item ("Proctored Exam Settings") is added to "Other Course Sett
|
||||
* Enable proctored exams for the course
|
||||
* Allow opting out of proctored exams
|
||||
* Select a proctoring provider
|
||||
* Enable automatic creation of Zendesk tickets for "suspicious" proctored exam attempts
|
||||
|
||||
Feature: Advanced Settings
|
||||
==========================
|
||||
@@ -239,7 +234,7 @@ Configuration
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -273,7 +268,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.
|
||||
|
||||
|
||||
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.
|
||||
(https://github.com/Automattic/node-canvas/issues/1733)
|
||||
|
||||
80
docs/how_tos/override-external-urls.rst
Normal file
80
docs/how_tos/override-external-urls.rst
Normal file
@@ -0,0 +1,80 @@
|
||||
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
|
||||
8929
package-lock.json
generated
8929
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
26
package.json
26
package.json
@@ -14,6 +14,7 @@
|
||||
"i18n_extract": "fedx-scripts formatjs extract --include=plugins",
|
||||
"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 .",
|
||||
"oxlint": "oxlint --type-aware --deny-warnings",
|
||||
"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:with-theme": "paragon install-theme && npm start && npm install",
|
||||
@@ -43,13 +44,14 @@
|
||||
"@dnd-kit/sortable": "^8.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.3",
|
||||
"@edx/browserslist-config": "1.5.0",
|
||||
"@edx/browserslist-config": "1.5.1",
|
||||
"@edx/frontend-component-footer": "^14.9.0",
|
||||
"@edx/frontend-component-header": "^8.1.0",
|
||||
"@edx/frontend-enterprise-hotjar": "^7.2.0",
|
||||
"@edx/frontend-platform": "^8.4.0",
|
||||
"@edx/openedx-atlas": "^0.7.0",
|
||||
"@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-learning_assistant": "file:plugins/course-apps/learning_assistant",
|
||||
"@openedx-plugins/course-app-live": "file:plugins/course-apps/live",
|
||||
@@ -60,35 +62,35 @@
|
||||
"@openedx-plugins/course-app-wiki": "file:plugins/course-apps/wiki",
|
||||
"@openedx-plugins/course-app-xpert_unit_summary": "file:plugins/course-apps/xpert_unit_summary",
|
||||
"@openedx/frontend-build": "^14.6.2",
|
||||
"@openedx/frontend-plugin-framework": "^1.7.0",
|
||||
"@openedx/frontend-plugin-framework": "^1.8.0",
|
||||
"@openedx/paragon": "^23.5.0",
|
||||
"@redux-devtools/extension": "^3.3.0",
|
||||
"@reduxjs/toolkit": "1.9.7",
|
||||
"@tanstack/react-query": "5.90.5",
|
||||
"@reduxjs/toolkit": "2.11.2",
|
||||
"@tanstack/react-query": "5.90.21",
|
||||
"@tinymce/tinymce-react": "^6.0.0",
|
||||
"classnames": "2.5.1",
|
||||
"codemirror": "^6.0.0",
|
||||
"email-validator": "2.0.4",
|
||||
"fast-xml-parser": "^5.0.0",
|
||||
"file-saver": "^2.0.5",
|
||||
"formik": "2.4.6",
|
||||
"formik": "2.4.9",
|
||||
"frontend-components-tinymce-advanced-plugins": "^1.0.3",
|
||||
"jszip": "^3.10.1",
|
||||
"lodash": "4.17.21",
|
||||
"lodash": "4.17.23",
|
||||
"meilisearch": "^0.41.0",
|
||||
"moment": "2.30.1",
|
||||
"moment-shortformat": "^2.1.0",
|
||||
"prop-types": "^15.8.1",
|
||||
"react": "^18.3.1",
|
||||
"react-datepicker": "^4.13.0",
|
||||
"react-datepicker": "^8.10.0",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-error-boundary": "^4.0.13",
|
||||
"react-helmet": "^6.1.0",
|
||||
"react-onclickoutside": "^6.13.0",
|
||||
"react-redux": "7.2.9",
|
||||
"react-responsive": "9.0.2",
|
||||
"react-router": "6.30.1",
|
||||
"react-router-dom": "6.30.1",
|
||||
"react-responsive": "10.0.1",
|
||||
"react-router": "6.30.3",
|
||||
"react-router-dom": "6.30.3",
|
||||
"react-select": "5.10.2",
|
||||
"react-textarea-autosize": "^8.5.3",
|
||||
"react-transition-group": "4.4.5",
|
||||
@@ -96,7 +98,7 @@
|
||||
"redux-logger": "^3.0.6",
|
||||
"redux-thunk": "^2.4.1",
|
||||
"reselect": "^4.1.5",
|
||||
"tinymce": "^5.10.4",
|
||||
"tinymce": "^7.0.0",
|
||||
"universal-cookie": "^8.0.0",
|
||||
"uuid": "^11.1.0",
|
||||
"xmlchecker": "^0.1.0",
|
||||
@@ -116,6 +118,8 @@
|
||||
"fetch-mock-jest": "^1.5.1",
|
||||
"jest-canvas-mock": "^2.5.2",
|
||||
"jest-expect-message": "^1.1.3",
|
||||
"oxlint": "^1.42.0",
|
||||
"oxlint-tsgolint": "^0.16.0",
|
||||
"react-test-renderer": "^18.3.1",
|
||||
"redux-mock-store": "^1.5.4"
|
||||
}
|
||||
|
||||
29
plugins/course-apps/dates/Settings.tsx
Normal file
29
plugins/course-apps/dates/Settings.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
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;
|
||||
26
plugins/course-apps/dates/messages.ts
Normal file
26
plugins/course-apps/dates/messages.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
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;
|
||||
17
plugins/course-apps/dates/package.json
Normal file
17
plugins/course-apps/dates/package.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { getConfig, getExternalLinkUrl } from '@edx/frontend-platform';
|
||||
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Form, Hyperlink } from '@openedx/paragon';
|
||||
import PropTypes from 'prop-types';
|
||||
@@ -93,7 +93,7 @@ const BbbSettings = ({
|
||||
<span data-testid="free-plan-message">
|
||||
{intl.formatMessage(messages.freePlanMessage)}
|
||||
<Hyperlink
|
||||
destination="https://bigbluebutton.org/privacy-policy/"
|
||||
destination={getExternalLinkUrl('https://bigbluebutton.org/privacy-policy/')}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
showLaunchIcon
|
||||
|
||||
@@ -4,21 +4,16 @@ import {
|
||||
getByRole,
|
||||
getAllByRole,
|
||||
waitForElementToBeRemoved,
|
||||
} from '@testing-library/react';
|
||||
initializeMocks,
|
||||
} from 'CourseAuthoring/testUtils';
|
||||
|
||||
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 initializeStore from 'CourseAuthoring/store';
|
||||
import { executeThunk } from 'CourseAuthoring/utils';
|
||||
import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
|
||||
|
||||
import { CourseAuthoringProvider } from 'CourseAuthoring/CourseAuthoringContext';
|
||||
import LiveSettings from './Settings';
|
||||
import {
|
||||
generateLiveConfigurationApiResponse,
|
||||
@@ -40,17 +35,20 @@ ReactDOM.createPortal = jest.fn(node => node);
|
||||
|
||||
const renderComponent = () => {
|
||||
const wrapper = render(
|
||||
<IntlProvider locale="en">
|
||||
<AppProvider store={store} wrapWithRouter={false}>
|
||||
<PagesAndResourcesProvider courseId={courseId}>
|
||||
<MemoryRouter initialEntries={[liveSettingsUrl]}>
|
||||
<Routes>
|
||||
<Route path={liveSettingsUrl} element={<PageWrap><LiveSettings onClose={() => {}} /></PageWrap>} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</PagesAndResourcesProvider>
|
||||
</AppProvider>
|
||||
</IntlProvider>,
|
||||
<CourseAuthoringProvider courseId={courseId}>
|
||||
<PagesAndResourcesProvider courseId={courseId}>
|
||||
<LiveSettings onClose={() => {}} />
|
||||
</PagesAndResourcesProvider>
|
||||
</CourseAuthoringProvider>,
|
||||
{
|
||||
path: liveSettingsUrl,
|
||||
routerProps: {
|
||||
initialEntries: [liveSettingsUrl],
|
||||
},
|
||||
params: {
|
||||
courseId,
|
||||
},
|
||||
},
|
||||
);
|
||||
container = wrapper.container;
|
||||
};
|
||||
@@ -74,16 +72,9 @@ const mockStore = async ({
|
||||
|
||||
describe('BBB Settings', () => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: false,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore(initialState);
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
const mocks = initializeMocks({ initialState });
|
||||
store = mocks.reduxStore;
|
||||
axiosMock = mocks.axiosMock;
|
||||
});
|
||||
|
||||
test('Plan dropdown to be visible and enabled in UI', async () => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// oxlint-disable unicorn/no-thenable
|
||||
import React, { useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { camelCase } from 'lodash';
|
||||
@@ -11,6 +12,7 @@ import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-m
|
||||
import { useModel } from 'CourseAuthoring/generic/model-store';
|
||||
import Loading from 'CourseAuthoring/generic/Loading';
|
||||
import { RequestStatus } from 'CourseAuthoring/data/constants';
|
||||
import { useCourseAuthoringContext } from 'CourseAuthoring/CourseAuthoringContext';
|
||||
|
||||
import { fetchLiveData, saveLiveConfiguration, saveLiveConfigurationAsDraft } from './data/thunks';
|
||||
import { selectApp } from './data/slice';
|
||||
@@ -25,7 +27,7 @@ const LiveSettings = ({
|
||||
const intl = useIntl();
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useDispatch();
|
||||
const courseId = useSelector(state => state.courseDetail.courseId);
|
||||
const { courseId } = useCourseAuthoringContext();
|
||||
const availableProviders = useSelector((state) => state.live.appIds);
|
||||
const {
|
||||
piiSharingAllowed, selectedAppId, enabled, status,
|
||||
@@ -71,6 +73,7 @@ const LiveSettings = ({
|
||||
};
|
||||
|
||||
const handleSettingsSave = async (values) => {
|
||||
// oxlint-disable-next-line @typescript-eslint/await-thenable - this dispatch() IS returning a promise.
|
||||
await dispatch(saveLiveConfiguration(courseId, values, navigate));
|
||||
};
|
||||
|
||||
|
||||
@@ -8,20 +8,14 @@ import {
|
||||
queryByText,
|
||||
getByRole,
|
||||
waitForElementToBeRemoved,
|
||||
} from '@testing-library/react';
|
||||
initializeMocks,
|
||||
} from 'CourseAuthoring/testUtils';
|
||||
|
||||
import ReactDOM from 'react-dom';
|
||||
import { Routes, Route, MemoryRouter } from 'react-router-dom';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { AppProvider, PageWrap } from '@edx/frontend-platform/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import initializeStore from 'CourseAuthoring/store';
|
||||
import { executeThunk } from 'CourseAuthoring/utils';
|
||||
import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
|
||||
|
||||
import { CourseAuthoringProvider } from 'CourseAuthoring/CourseAuthoringContext';
|
||||
import LiveSettings from './Settings';
|
||||
import {
|
||||
generateLiveConfigurationApiResponse,
|
||||
@@ -44,17 +38,20 @@ ReactDOM.createPortal = jest.fn(node => node);
|
||||
|
||||
const renderComponent = () => {
|
||||
const wrapper = render(
|
||||
<IntlProvider locale="en">
|
||||
<AppProvider store={store} wrapWithRouter={false}>
|
||||
<PagesAndResourcesProvider courseId={courseId}>
|
||||
<MemoryRouter initialEntries={[liveSettingsUrl]}>
|
||||
<Routes>
|
||||
<Route path={liveSettingsUrl} element={<PageWrap><LiveSettings onClose={() => {}} /></PageWrap>} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</PagesAndResourcesProvider>
|
||||
</AppProvider>
|
||||
</IntlProvider>,
|
||||
<PagesAndResourcesProvider courseId={courseId}>
|
||||
<CourseAuthoringProvider>
|
||||
<LiveSettings onClose={() => {}} />
|
||||
</CourseAuthoringProvider>
|
||||
</PagesAndResourcesProvider>,
|
||||
{
|
||||
path: liveSettingsUrl,
|
||||
routerProps: {
|
||||
initialEntries: [liveSettingsUrl],
|
||||
},
|
||||
params: {
|
||||
courseId,
|
||||
},
|
||||
},
|
||||
);
|
||||
container = wrapper.container;
|
||||
};
|
||||
@@ -77,16 +74,11 @@ const mockStore = async ({
|
||||
|
||||
describe('LiveSettings', () => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: false,
|
||||
roles: [],
|
||||
},
|
||||
const mocks = initializeMocks({
|
||||
initialState,
|
||||
});
|
||||
store = initializeStore(initialState);
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
store = mocks.reduxStore;
|
||||
axiosMock = mocks.axiosMock;
|
||||
});
|
||||
|
||||
test('Live Configuration modal is visible', async () => {
|
||||
|
||||
@@ -3,19 +3,14 @@ import {
|
||||
queryByTestId,
|
||||
getByRole,
|
||||
waitForElementToBeRemoved,
|
||||
} from '@testing-library/react';
|
||||
initializeMocks,
|
||||
} from 'CourseAuthoring/testUtils';
|
||||
|
||||
import ReactDOM from 'react-dom';
|
||||
import { Routes, Route, MemoryRouter } from 'react-router-dom';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { AppProvider, PageWrap } from '@edx/frontend-platform/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import initializeStore from 'CourseAuthoring/store';
|
||||
import { executeThunk } from 'CourseAuthoring/utils';
|
||||
import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
|
||||
import { CourseAuthoringProvider } from 'CourseAuthoring/CourseAuthoringContext';
|
||||
import LiveSettings from './Settings';
|
||||
import {
|
||||
generateLiveConfigurationApiResponse,
|
||||
@@ -38,17 +33,20 @@ ReactDOM.createPortal = jest.fn(node => node);
|
||||
|
||||
const renderComponent = () => {
|
||||
const wrapper = render(
|
||||
<IntlProvider locale="en">
|
||||
<AppProvider store={store} wrapWithRouter={false}>
|
||||
<PagesAndResourcesProvider courseId={courseId}>
|
||||
<MemoryRouter initialEntries={[liveSettingsUrl]}>
|
||||
<Routes>
|
||||
<Route path={liveSettingsUrl} element={<PageWrap><LiveSettings onClose={() => {}} /></PageWrap>} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</PagesAndResourcesProvider>
|
||||
</AppProvider>
|
||||
</IntlProvider>,
|
||||
<CourseAuthoringProvider courseId={courseId}>
|
||||
<PagesAndResourcesProvider courseId={courseId}>
|
||||
<LiveSettings onClose={() => {}} />
|
||||
</PagesAndResourcesProvider>
|
||||
</CourseAuthoringProvider>,
|
||||
{
|
||||
path: liveSettingsUrl,
|
||||
routerProps: {
|
||||
initialEntries: [liveSettingsUrl],
|
||||
},
|
||||
params: {
|
||||
courseId,
|
||||
},
|
||||
},
|
||||
);
|
||||
container = wrapper.container;
|
||||
};
|
||||
@@ -71,16 +69,9 @@ const mockStore = async ({
|
||||
|
||||
describe('Zoom Settings', () => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: false,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore(initialState);
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
const mocks = initializeMocks({ initialState });
|
||||
store = mocks.reduxStore;
|
||||
axiosMock = mocks.axiosMock;
|
||||
});
|
||||
|
||||
test('LTI fields are visible when pii sharing is enabled', async () => {
|
||||
|
||||
@@ -48,8 +48,9 @@ const ORASettings = ({ onClose }) => {
|
||||
event.preventDefault();
|
||||
|
||||
success = success && await handleSettingsSave(formValues);
|
||||
await setSaveError(!success);
|
||||
setSaveError(!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({
|
||||
modelType: 'courseApps',
|
||||
model: {
|
||||
|
||||
@@ -128,7 +128,7 @@ describe('ORASettings', () => {
|
||||
await mockStore({ apiStatus: 200, enabled: true });
|
||||
renderComponent();
|
||||
|
||||
const checkbox = await screen.getByRole('checkbox', { name: /Flex Peer Grading/ });
|
||||
const checkbox = screen.getByRole('checkbox', { name: /Flex Peer Grading/ });
|
||||
expect(checkbox).toBeChecked();
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -22,6 +22,7 @@ import { useModel } from 'CourseAuthoring/generic/model-store';
|
||||
import PermissionDeniedAlert from 'CourseAuthoring/generic/PermissionDeniedAlert';
|
||||
import { useIsMobile } from 'CourseAuthoring/utils';
|
||||
import { PagesAndResourcesContext } from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
|
||||
import { useCourseAuthoringContext } from 'CourseAuthoring/CourseAuthoringContext';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
@@ -32,7 +33,6 @@ const ProctoringSettings = ({ onClose }) => {
|
||||
proctoringProvider: false,
|
||||
escalationEmail: '',
|
||||
allowOptingOut: false,
|
||||
createZendeskTickets: false,
|
||||
};
|
||||
const [formValues, setFormValues] = useState(initialFormValues);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -41,6 +41,7 @@ const ProctoringSettings = ({ onClose }) => {
|
||||
const [loadingPermissionError, setLoadingPermissionError] = useState(false);
|
||||
const [allowLtiProviders, setAllowLtiProviders] = useState(false);
|
||||
const [availableProctoringProviders, setAvailableProctoringProviders] = useState([]);
|
||||
const [requiresEscalationEmailProviders, setRequiresEscalationEmailProviders] = useState([]);
|
||||
const [ltiProctoringProviders, setLtiProctoringProviders] = useState([]);
|
||||
const [courseStartDate, setCourseStartDate] = useState('');
|
||||
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||
@@ -65,7 +66,7 @@ const ProctoringSettings = ({ onClose }) => {
|
||||
}
|
||||
|
||||
const { courseId } = useContext(PagesAndResourcesContext);
|
||||
const courseDetails = useModel('courseDetails', courseId);
|
||||
const { courseDetails } = useCourseAuthoringContext();
|
||||
const org = courseDetails?.org;
|
||||
const appInfo = useModel('courseApps', 'proctoring');
|
||||
const alertRef = React.createRef();
|
||||
@@ -78,18 +79,14 @@ const ProctoringSettings = ({ onClose }) => {
|
||||
const value = target.type === 'checkbox' ? target.checked : target.value;
|
||||
const { name } = target;
|
||||
|
||||
if (['allowOptingOut', 'createZendeskTickets'].includes(name)) {
|
||||
if (['allowOptingOut'].includes(name)) {
|
||||
// Form.Radio expects string values, so convert back to a boolean here
|
||||
setFormValues({ ...formValues, [name]: value === 'true' });
|
||||
} else if (name === 'proctoringProvider') {
|
||||
const newFormValues = { ...formValues, proctoringProvider: value };
|
||||
|
||||
if (value === 'proctortrack') {
|
||||
setFormValues({ ...newFormValues, createZendeskTickets: false });
|
||||
if (requiresEscalationEmailProviders.includes(value)) {
|
||||
setFormValues({ ...newFormValues });
|
||||
setShowEscalationEmail(true);
|
||||
} else if (value === 'software_secure') {
|
||||
setFormValues({ ...newFormValues, createZendeskTickets: true });
|
||||
setShowEscalationEmail(false);
|
||||
} else if (isLtiProvider(value)) {
|
||||
setFormValues(newFormValues);
|
||||
setShowEscalationEmail(true);
|
||||
@@ -116,14 +113,13 @@ const ProctoringSettings = ({ onClose }) => {
|
||||
enable_proctored_exams: formValues.enableProctoredExams,
|
||||
// lti providers are managed outside edx-platform, lti_external indicates this
|
||||
proctoring_provider: isLtiProviderSelected ? 'lti_external' : selectedProvider,
|
||||
create_zendesk_tickets: formValues.createZendeskTickets,
|
||||
},
|
||||
};
|
||||
if (isEdxStaff) {
|
||||
studioDataToPostBack.proctored_exam_settings.allow_proctoring_opt_out = formValues.allowOptingOut;
|
||||
}
|
||||
|
||||
if (formValues.proctoringProvider === 'proctortrack') {
|
||||
if (requiresEscalationEmailProviders.includes(formValues.proctoringProvider)) {
|
||||
studioDataToPostBack.proctored_exam_settings.proctoring_escalation_email = formValues.escalationEmail === '' ? null : formValues.escalationEmail;
|
||||
}
|
||||
|
||||
@@ -160,7 +156,7 @@ const ProctoringSettings = ({ onClose }) => {
|
||||
event.preventDefault();
|
||||
const isLtiProviderSelected = isLtiProvider(formValues.proctoringProvider);
|
||||
if (
|
||||
(formValues.proctoringProvider === 'proctortrack' || isLtiProviderSelected)
|
||||
(requiresEscalationEmailProviders.includes(formValues.proctoringProvider) || isLtiProviderSelected)
|
||||
&& !EmailValidator.validate(formValues.escalationEmail)
|
||||
&& !(formValues.escalationEmail === '' && !formValues.enableProctoredExams)
|
||||
) {
|
||||
@@ -387,29 +383,6 @@ const ProctoringSettings = ({ onClose }) => {
|
||||
</Form.Group>
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -527,6 +500,7 @@ const ProctoringSettings = ({ onClose }) => {
|
||||
setSubmissionInProgress(false);
|
||||
setCourseStartDate(settingsResponse.data.course_start_date);
|
||||
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
|
||||
// is available as an option display the list of LTI providers returned by the exam service.
|
||||
@@ -554,10 +528,11 @@ const ProctoringSettings = ({ onClose }) => {
|
||||
selectedProvider = proctoredExamSettings.proctoring_provider;
|
||||
}
|
||||
|
||||
const isProctortrack = selectedProvider === 'proctortrack';
|
||||
const requiresEscalationEmailProvidersList = settingsResponse.data.requires_escalation_email_providers;
|
||||
const isEscalationEmailRequired = requiresEscalationEmailProvidersList.includes(selectedProvider);
|
||||
const ltiProviderSelected = proctoringProvidersLti.some(p => p.name === selectedProvider);
|
||||
|
||||
if (isProctortrack || ltiProviderSelected) {
|
||||
if (isEscalationEmailRequired || ltiProviderSelected) {
|
||||
setShowEscalationEmail(true);
|
||||
}
|
||||
|
||||
@@ -570,7 +545,6 @@ const ProctoringSettings = ({ onClose }) => {
|
||||
proctoringProvider: selectedProvider,
|
||||
enableProctoredExams: proctoredExamSettings.enable_proctored_exams,
|
||||
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.
|
||||
// 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.
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
render, screen, cleanup, waitFor, fireEvent, act,
|
||||
} from '@testing-library/react';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
initializeMocks,
|
||||
} from 'CourseAuthoring/testUtils';
|
||||
|
||||
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 { mergeConfig } from '@edx/frontend-platform';
|
||||
|
||||
import StudioApiService from 'CourseAuthoring/data/services/StudioApiService';
|
||||
import ExamsApiService from 'CourseAuthoring/data/services/ExamsApiService';
|
||||
import initializeStore from 'CourseAuthoring/store';
|
||||
import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
|
||||
import { CourseAuthoringProvider } from 'CourseAuthoring/CourseAuthoringContext';
|
||||
import { getCourseDetailsUrl } from 'CourseAuthoring/data/api';
|
||||
import ProctoredExamSettings from './Settings';
|
||||
|
||||
const courseId = 'course-v1%3AedX%2BDemoX%2BDemo_Course';
|
||||
@@ -20,57 +17,57 @@ const defaultProps = {
|
||||
courseId,
|
||||
onClose: () => {},
|
||||
};
|
||||
const IntlProctoredExamSettings = injectIntl(ProctoredExamSettings);
|
||||
let store;
|
||||
|
||||
const intlWrapper = children => (
|
||||
<AppProvider store={store}>
|
||||
const renderComponent = children => (
|
||||
<CourseAuthoringProvider courseId={defaultProps.courseId}>
|
||||
<PagesAndResourcesProvider courseId={defaultProps.courseId}>
|
||||
<IntlProvider locale="en">
|
||||
{children}
|
||||
</IntlProvider>
|
||||
{children}
|
||||
</PagesAndResourcesProvider>
|
||||
</AppProvider>
|
||||
</CourseAuthoringProvider>
|
||||
);
|
||||
let axiosMock;
|
||||
|
||||
describe('ProctoredExamSettings', () => {
|
||||
/**
|
||||
* @param {boolean} isAdmin
|
||||
* @param {string | undefined} org
|
||||
*/
|
||||
function setupApp(isAdmin = true, org = undefined) {
|
||||
mergeConfig({
|
||||
EXAMS_BASE_URL: 'http://exams.testing.co',
|
||||
}, 'CourseAuthoringConfig');
|
||||
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: isAdmin,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore({
|
||||
models: {
|
||||
courseApps: {
|
||||
proctoring: {},
|
||||
},
|
||||
courseDetails: {
|
||||
[courseId]: {
|
||||
start: Date(),
|
||||
const user = {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: isAdmin,
|
||||
roles: [],
|
||||
};
|
||||
const mocks = initializeMocks({
|
||||
user,
|
||||
initialState: {
|
||||
models: {
|
||||
courseApps: {
|
||||
proctoring: {},
|
||||
},
|
||||
},
|
||||
...(org ? { courseDetails: { [courseId]: { org } } } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
axiosMock.onGet(
|
||||
`${ExamsApiService.getExamsBaseUrl()}/api/v1/providers${org ? `?org=${org}` : ''}`,
|
||||
).reply(200, [
|
||||
{
|
||||
name: 'test_lti',
|
||||
verbose_name: 'LTI Provider',
|
||||
},
|
||||
]);
|
||||
axiosMock = mocks.axiosMock;
|
||||
axiosMock
|
||||
.onGet(getCourseDetailsUrl(courseId, user.username))
|
||||
.reply(200, {
|
||||
courseId,
|
||||
name: 'Course Test',
|
||||
start: Date(),
|
||||
...(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(
|
||||
`${ExamsApiService.getExamsBaseUrl()}/api/v1/configs/course_id/${defaultProps.courseId}`,
|
||||
).reply(200, {
|
||||
@@ -85,54 +82,20 @@ describe('ProctoredExamSettings', () => {
|
||||
allow_proctoring_opt_out: false,
|
||||
proctoring_provider: 'mockproc',
|
||||
proctoring_escalation_email: 'test@example.com',
|
||||
create_zendesk_tickets: true,
|
||||
},
|
||||
available_proctoring_providers: ['software_secure', 'proctortrack', 'mockproc', 'lti_external'],
|
||||
available_proctoring_providers: ['software_secure', 'mockproc', 'lti_external'],
|
||||
requires_escalation_email_providers: ['test_lti'],
|
||||
course_start_date: '2070-01-01T00:00:00Z',
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
axiosMock.reset();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
setupApp();
|
||||
});
|
||||
|
||||
describe('Field dependencies', () => {
|
||||
beforeEach(async () => {
|
||||
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);
|
||||
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
|
||||
});
|
||||
|
||||
it('Hides all other fields when enabledProctorExam is false when first loaded', async () => {
|
||||
@@ -146,13 +109,13 @@ describe('ProctoredExamSettings', () => {
|
||||
allow_proctoring_opt_out: false,
|
||||
proctoring_provider: 'mockproc',
|
||||
proctoring_escalation_email: 'test@example.com',
|
||||
create_zendesk_tickets: true,
|
||||
},
|
||||
available_proctoring_providers: ['software_secure', 'proctortrack', 'mockproc'],
|
||||
available_proctoring_providers: ['software_secure', 'mockproc'],
|
||||
requires_escalation_email_providers: [],
|
||||
course_start_date: '2070-01-01T00:00:00Z',
|
||||
});
|
||||
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
|
||||
await waitFor(() => {
|
||||
screen.getByText('Proctored exams');
|
||||
});
|
||||
@@ -161,8 +124,6 @@ describe('ProctoredExamSettings', () => {
|
||||
expect(screen.queryByText('Allow Opting Out of Proctored Exams')).toBeNull();
|
||||
expect(screen.queryByDisplayValue('mockproc')).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 () => {
|
||||
@@ -172,8 +133,6 @@ describe('ProctoredExamSettings', () => {
|
||||
expect(screen.queryByText('Allow opting out of proctored exams')).toBeDefined();
|
||||
expect(screen.queryByDisplayValue('mockproc')).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];
|
||||
expect(enabledProctoredExamCheck.checked).toEqual(true);
|
||||
@@ -183,8 +142,6 @@ describe('ProctoredExamSettings', () => {
|
||||
expect(screen.queryByText('Allow opting out of proctored exams')).toBeNull();
|
||||
expect(screen.queryByDisplayValue('mockproc')).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 () => {
|
||||
@@ -194,13 +151,11 @@ describe('ProctoredExamSettings', () => {
|
||||
const selectElement = screen.getByDisplayValue('mockproc');
|
||||
fireEvent.change(selectElement, { target: { value: 'test_lti' } });
|
||||
expect(screen.queryByTestId('allowOptingOutRadio')).toBeNull();
|
||||
expect(screen.queryByTestId('createZendeskTicketsYes')).toBeNull();
|
||||
expect(screen.queryByTestId('createZendeskTicketsNo')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Validation with invalid escalation email', () => {
|
||||
const proctoringProvidersRequiringEscalationEmail = ['proctortrack', 'test_lti'];
|
||||
const proctoringProvidersRequiringEscalationEmail = ['test_lti'];
|
||||
|
||||
beforeEach(async () => {
|
||||
axiosMock.onGet(
|
||||
@@ -209,14 +164,21 @@ describe('ProctoredExamSettings', () => {
|
||||
proctored_exam_settings: {
|
||||
enable_proctored_exams: true,
|
||||
allow_proctoring_opt_out: false,
|
||||
proctoring_provider: 'proctortrack',
|
||||
proctoring_provider: 'lti_external',
|
||||
proctoring_escalation_email: 'test@example.com',
|
||||
create_zendesk_tickets: true,
|
||||
},
|
||||
available_proctoring_providers: ['software_secure', 'proctortrack', 'mockproc', 'lti_external'],
|
||||
available_proctoring_providers: ['software_secure', 'mockproc', 'lti_external'],
|
||||
requires_escalation_email_providers: ['test_lti'],
|
||||
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(
|
||||
ExamsApiService.getExamConfigurationUrl(defaultProps.courseId),
|
||||
).reply(204, {});
|
||||
@@ -225,13 +187,13 @@ describe('ProctoredExamSettings', () => {
|
||||
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
|
||||
).reply(200, {});
|
||||
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
|
||||
});
|
||||
|
||||
proctoringProvidersRequiringEscalationEmail.forEach(provider => {
|
||||
it(`Creates an alert when no proctoring escalation email is provided with ${provider} selected`, async () => {
|
||||
await waitFor(() => {
|
||||
screen.getByDisplayValue('proctortrack');
|
||||
screen.getByDisplayValue('LTI Provider');
|
||||
});
|
||||
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
|
||||
fireEvent.change(selectEscalationEmailElement, { target: { value: '' } });
|
||||
@@ -252,10 +214,10 @@ describe('ProctoredExamSettings', () => {
|
||||
|
||||
it(`Creates an alert when invalid proctoring escalation email is provided with ${provider} selected`, async () => {
|
||||
await waitFor(() => {
|
||||
screen.getByDisplayValue('proctortrack');
|
||||
screen.getByDisplayValue('LTI Provider');
|
||||
});
|
||||
|
||||
const selectElement = screen.getByDisplayValue('proctortrack');
|
||||
const selectElement = screen.getByDisplayValue('LTI Provider');
|
||||
fireEvent.change(selectElement, { target: { value: provider } });
|
||||
|
||||
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
|
||||
@@ -278,7 +240,7 @@ describe('ProctoredExamSettings', () => {
|
||||
|
||||
it('Creates an alert when invalid proctoring escalation email is provided with proctoring disabled', async () => {
|
||||
await waitFor(() => {
|
||||
screen.getByDisplayValue('proctortrack');
|
||||
screen.getByDisplayValue('LTI Provider');
|
||||
});
|
||||
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
|
||||
fireEvent.change(selectEscalationEmailElement, { target: { value: 'foo.bar' } });
|
||||
@@ -296,7 +258,7 @@ describe('ProctoredExamSettings', () => {
|
||||
|
||||
it('Has no error when empty proctoring escalation email is provided with proctoring disabled', async () => {
|
||||
await waitFor(() => {
|
||||
screen.getByDisplayValue('proctortrack');
|
||||
screen.getByDisplayValue('LTI Provider');
|
||||
});
|
||||
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
|
||||
fireEvent.change(selectEscalationEmailElement, { target: { value: '' } });
|
||||
@@ -319,7 +281,7 @@ describe('ProctoredExamSettings', () => {
|
||||
|
||||
it(`Has no error when valid proctoring escalation email is provided with ${provider} selected`, async () => {
|
||||
await waitFor(() => {
|
||||
screen.getByDisplayValue('proctortrack');
|
||||
screen.getByDisplayValue('LTI Provider');
|
||||
});
|
||||
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
|
||||
fireEvent.change(selectEscalationEmailElement, { target: { value: 'foo@bar.com' } });
|
||||
@@ -340,9 +302,9 @@ describe('ProctoredExamSettings', () => {
|
||||
|
||||
it(`Escalation email field hidden when proctoring backend is not ${provider}`, async () => {
|
||||
await waitFor(() => {
|
||||
screen.getByDisplayValue('proctortrack');
|
||||
screen.getByDisplayValue('LTI Provider');
|
||||
});
|
||||
const proctoringBackendSelect = screen.getByDisplayValue('proctortrack');
|
||||
const proctoringBackendSelect = screen.getByDisplayValue('LTI Provider');
|
||||
const selectEscalationEmailElement = screen.getByTestId('escalationEmail');
|
||||
expect(selectEscalationEmailElement.value).toEqual('test@example.com');
|
||||
fireEvent.change(proctoringBackendSelect, { target: { value: 'software_secure' } });
|
||||
@@ -351,13 +313,13 @@ describe('ProctoredExamSettings', () => {
|
||||
|
||||
it(`Escalation email Field Show when proctoring backend is switched back to ${provider}`, async () => {
|
||||
await waitFor(() => {
|
||||
screen.getByDisplayValue('proctortrack');
|
||||
screen.getByDisplayValue('LTI Provider');
|
||||
});
|
||||
const proctoringBackendSelect = screen.getByDisplayValue('proctortrack');
|
||||
const proctoringBackendSelect = screen.getByDisplayValue('LTI Provider');
|
||||
let selectEscalationEmailElement = screen.getByTestId('escalationEmail');
|
||||
fireEvent.change(proctoringBackendSelect, { target: { value: 'software_secure' } });
|
||||
expect(screen.queryByTestId('escalationEmail')).toBeNull();
|
||||
fireEvent.change(proctoringBackendSelect, { target: { value: 'proctortrack' } });
|
||||
fireEvent.change(proctoringBackendSelect, { target: { value: provider } });
|
||||
expect(screen.queryByTestId('escalationEmail')).toBeDefined();
|
||||
selectEscalationEmailElement = screen.getByTestId('escalationEmail');
|
||||
expect(selectEscalationEmailElement.value).toEqual('test@example.com');
|
||||
@@ -365,7 +327,7 @@ describe('ProctoredExamSettings', () => {
|
||||
|
||||
it('Submits form when "Enter" key is hit in the escalation email field', async () => {
|
||||
await waitFor(() => {
|
||||
screen.getByDisplayValue('proctortrack');
|
||||
screen.getByDisplayValue('LTI Provider');
|
||||
});
|
||||
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
|
||||
fireEvent.change(selectEscalationEmailElement, { target: { value: '' } });
|
||||
@@ -383,9 +345,9 @@ describe('ProctoredExamSettings', () => {
|
||||
allow_proctoring_opt_out: false,
|
||||
proctoring_provider: 'mockproc',
|
||||
proctoring_escalation_email: 'test@example.com',
|
||||
create_zendesk_tickets: true,
|
||||
},
|
||||
available_proctoring_providers: ['software_secure', 'proctortrack', 'mockproc'],
|
||||
available_proctoring_providers: ['software_secure', 'mockproc'],
|
||||
requires_escalation_email_providers: [],
|
||||
course_start_date: '2099-01-01T00:00:00Z',
|
||||
};
|
||||
|
||||
@@ -395,9 +357,9 @@ describe('ProctoredExamSettings', () => {
|
||||
allow_proctoring_opt_out: false,
|
||||
proctoring_provider: 'mockproc',
|
||||
proctoring_escalation_email: 'test@example.com',
|
||||
create_zendesk_tickets: true,
|
||||
},
|
||||
available_proctoring_providers: ['software_secure', 'proctortrack', 'mockproc'],
|
||||
available_proctoring_providers: ['software_secure', 'mockproc'],
|
||||
requires_escalation_email_providers: [],
|
||||
course_start_date: '2013-01-01T00:00:00Z',
|
||||
};
|
||||
|
||||
@@ -409,8 +371,8 @@ describe('ProctoredExamSettings', () => {
|
||||
const isAdmin = false;
|
||||
setupApp(isAdmin);
|
||||
mockCourseData(mockGetPastCourseData);
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
const providerOption = screen.getByTestId('proctortrack');
|
||||
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
|
||||
const providerOption = screen.getByTestId('software_secure');
|
||||
expect(providerOption.hasAttribute('disabled')).toEqual(true);
|
||||
});
|
||||
|
||||
@@ -418,8 +380,8 @@ describe('ProctoredExamSettings', () => {
|
||||
const isAdmin = false;
|
||||
setupApp(isAdmin);
|
||||
mockCourseData(mockGetFutureCourseData);
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
const providerOption = screen.getByTestId('proctortrack');
|
||||
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
|
||||
const providerOption = screen.getByTestId('software_secure');
|
||||
expect(providerOption.hasAttribute('disabled')).toEqual(false);
|
||||
});
|
||||
|
||||
@@ -428,8 +390,8 @@ describe('ProctoredExamSettings', () => {
|
||||
const org = 'test-org';
|
||||
setupApp(isAdmin, org);
|
||||
mockCourseData(mockGetFutureCourseData);
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
const providerOption = screen.getByTestId('proctortrack');
|
||||
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
|
||||
const providerOption = screen.getByTestId('software_secure');
|
||||
expect(providerOption.hasAttribute('disabled')).toEqual(false);
|
||||
});
|
||||
|
||||
@@ -437,8 +399,8 @@ describe('ProctoredExamSettings', () => {
|
||||
const isAdmin = true;
|
||||
setupApp(isAdmin);
|
||||
mockCourseData(mockGetPastCourseData);
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
const providerOption = screen.getByTestId('proctortrack');
|
||||
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
|
||||
const providerOption = screen.getByTestId('software_secure');
|
||||
expect(providerOption.hasAttribute('disabled')).toEqual(false);
|
||||
});
|
||||
|
||||
@@ -446,18 +408,18 @@ describe('ProctoredExamSettings', () => {
|
||||
const isAdmin = true;
|
||||
setupApp(isAdmin);
|
||||
mockCourseData(mockGetFutureCourseData);
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
const providerOption = screen.getByTestId('proctortrack');
|
||||
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
|
||||
const providerOption = screen.getByTestId('software_secure');
|
||||
expect(providerOption.hasAttribute('disabled')).toEqual(false);
|
||||
});
|
||||
|
||||
it('Does not include lti_external as a selectable option', async () => {
|
||||
const courseData = {
|
||||
...mockGetFutureCourseData,
|
||||
available_proctoring_providers: ['lti_external', 'proctortrack', 'mockproc'],
|
||||
available_proctoring_providers: ['lti_external', 'mockproc'],
|
||||
};
|
||||
mockCourseData(courseData);
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
|
||||
await waitFor(() => {
|
||||
screen.getByDisplayValue('mockproc');
|
||||
});
|
||||
@@ -467,10 +429,10 @@ describe('ProctoredExamSettings', () => {
|
||||
it('Includes lti proctoring provider options when lti_external is allowed by studio', async () => {
|
||||
const courseData = {
|
||||
...mockGetFutureCourseData,
|
||||
available_proctoring_providers: ['lti_external', 'proctortrack', 'mockproc'],
|
||||
available_proctoring_providers: ['lti_external', 'mockproc'],
|
||||
};
|
||||
mockCourseData(courseData);
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
|
||||
await waitFor(() => {
|
||||
screen.getByDisplayValue('mockproc');
|
||||
});
|
||||
@@ -483,7 +445,7 @@ describe('ProctoredExamSettings', () => {
|
||||
const isAdmin = true;
|
||||
setupApp(isAdmin);
|
||||
mockCourseData(mockGetFutureCourseData);
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
|
||||
await waitFor(() => {
|
||||
screen.getByDisplayValue('mockproc');
|
||||
});
|
||||
@@ -497,18 +459,20 @@ describe('ProctoredExamSettings', () => {
|
||||
EXAMS_BASE_URL: null,
|
||||
}, 'CourseAuthoringConfig');
|
||||
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
|
||||
await waitFor(() => {
|
||||
screen.getByDisplayValue('mockproc');
|
||||
});
|
||||
// only outgoing request should be for studio settings
|
||||
expect(axiosMock.history.get.length).toBe(1);
|
||||
// (1) for studio settings
|
||||
// (2) waffle flags
|
||||
// (3) for course details
|
||||
expect(axiosMock.history.get.length).toBe(3);
|
||||
expect(axiosMock.history.get[0].url.includes('proctored_exam_settings')).toEqual(true);
|
||||
});
|
||||
|
||||
it('Selected LTI proctoring provider is shown on page load', async () => {
|
||||
const courseData = { ...mockGetFutureCourseData };
|
||||
courseData.available_proctoring_providers = ['lti_external', 'proctortrack', 'mockproc'];
|
||||
courseData.available_proctoring_providers = ['lti_external', 'mockproc'];
|
||||
courseData.proctored_exam_settings.proctoring_provider = 'lti_external';
|
||||
mockCourseData(courseData);
|
||||
axiosMock.onGet(
|
||||
@@ -516,7 +480,7 @@ describe('ProctoredExamSettings', () => {
|
||||
).reply(200, {
|
||||
provider: 'test_lti',
|
||||
});
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
|
||||
await waitFor(() => {
|
||||
screen.getByText('Proctoring provider');
|
||||
});
|
||||
@@ -527,24 +491,22 @@ describe('ProctoredExamSettings', () => {
|
||||
});
|
||||
|
||||
describe('Toggles field visibility based on user permissions', () => {
|
||||
it('Hides opting out and zendesk tickets for non edX staff', async () => {
|
||||
it('Hides opting out for non edX staff', async () => {
|
||||
setupApp(false);
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
|
||||
expect(screen.queryByTestId('allowOptingOutYes')).toBeNull();
|
||||
expect(screen.queryByTestId('createZendeskTicketsYes')).toBeNull();
|
||||
});
|
||||
|
||||
it('Shows opting out and zendesk tickets for edX staff', async () => {
|
||||
it('Shows opting out for edX staff', async () => {
|
||||
setupApp(true);
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
|
||||
expect(screen.queryByTestId('allowOptingOutYes')).not.toBeNull();
|
||||
expect(screen.queryByTestId('createZendeskTicketsYes')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Connection states', () => {
|
||||
it('Shows the spinner before the connection is complete', async () => {
|
||||
render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />));
|
||||
render(renderComponent(<ProctoredExamSettings {...defaultProps} />));
|
||||
const spinner = await screen.findByRole('status');
|
||||
expect(spinner.textContent).toEqual('Loading...');
|
||||
});
|
||||
@@ -554,7 +516,7 @@ describe('ProctoredExamSettings', () => {
|
||||
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
|
||||
).reply(500);
|
||||
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
|
||||
const connectionError = screen.getByTestId('connectionErrorAlert');
|
||||
expect(connectionError.textContent).toEqual(
|
||||
expect.stringContaining('We encountered a technical error when loading this page.'),
|
||||
@@ -566,7 +528,7 @@ describe('ProctoredExamSettings', () => {
|
||||
`${ExamsApiService.getExamsBaseUrl()}/api/v1/providers`,
|
||||
).reply(500);
|
||||
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
|
||||
const connectionError = screen.getByTestId('connectionErrorAlert');
|
||||
expect(connectionError.textContent).toEqual(
|
||||
expect.stringContaining('We encountered a technical error when loading this page.'),
|
||||
@@ -578,7 +540,7 @@ describe('ProctoredExamSettings', () => {
|
||||
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
|
||||
).reply(403);
|
||||
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
|
||||
const permissionError = screen.getByTestId('permissionDeniedAlert');
|
||||
expect(permissionError.textContent).toEqual(
|
||||
expect.stringContaining('You are not authorized to view this page'),
|
||||
@@ -597,7 +559,7 @@ describe('ProctoredExamSettings', () => {
|
||||
});
|
||||
|
||||
it('Disable button while submitting', async () => {
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
|
||||
let submitButton = screen.getByTestId('submissionButton');
|
||||
expect(screen.queryByTestId('saveInProgress')).toBeFalsy();
|
||||
fireEvent.click(submitButton);
|
||||
@@ -606,15 +568,30 @@ describe('ProctoredExamSettings', () => {
|
||||
expect(submitButton).toHaveAttribute('disabled');
|
||||
});
|
||||
|
||||
it('Makes API call successfully with proctoring_escalation_email if proctortrack', async () => {
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
// Make a change to the provider to proctortrack and set the email
|
||||
it('Makes API call successfully with proctoring_escalation_email if test_lti', async () => {
|
||||
// Setup mock to include test_lti as available provider
|
||||
axiosMock.onGet(
|
||||
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');
|
||||
fireEvent.change(selectElement, { target: { value: 'proctortrack' } });
|
||||
fireEvent.change(selectElement, { target: { value: 'test_lti' } });
|
||||
const escalationEmail = screen.getByTestId('escalationEmail');
|
||||
expect(escalationEmail.value).toEqual('test@example.com');
|
||||
fireEvent.change(escalationEmail, { target: { value: 'proctortrack@example.com' } });
|
||||
expect(escalationEmail.value).toEqual('proctortrack@example.com');
|
||||
fireEvent.change(escalationEmail, { target: { value: 'test_lti@example.com' } });
|
||||
expect(escalationEmail.value).toEqual('test_lti@example.com');
|
||||
const submitButton = screen.getByTestId('submissionButton');
|
||||
fireEvent.click(submitButton);
|
||||
expect(axiosMock.history.post.length).toBe(1);
|
||||
@@ -622,11 +599,15 @@ describe('ProctoredExamSettings', () => {
|
||||
proctored_exam_settings: {
|
||||
enable_proctored_exams: true,
|
||||
allow_proctoring_opt_out: false,
|
||||
proctoring_provider: 'proctortrack',
|
||||
proctoring_escalation_email: 'proctortrack@example.com',
|
||||
create_zendesk_tickets: false,
|
||||
proctoring_provider: 'lti_external',
|
||||
proctoring_escalation_email: 'test_lti@example.com',
|
||||
},
|
||||
});
|
||||
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(() => {
|
||||
const errorAlert = screen.getByTestId('saveSuccess');
|
||||
@@ -637,10 +618,10 @@ describe('ProctoredExamSettings', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('Makes API call successfully without proctoring_escalation_email if not proctortrack', async () => {
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
it('Makes API call successfully without proctoring_escalation_email if not requiring escalation email', async () => {
|
||||
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
|
||||
|
||||
// make sure we have not selected proctortrack as the proctoring provider
|
||||
// make sure we have not selected a provider requiring escalation email
|
||||
expect(screen.getByDisplayValue('mockproc')).toBeDefined();
|
||||
|
||||
const submitButton = screen.getByTestId('submissionButton');
|
||||
@@ -651,7 +632,6 @@ describe('ProctoredExamSettings', () => {
|
||||
enable_proctored_exams: true,
|
||||
allow_proctoring_opt_out: false,
|
||||
proctoring_provider: 'mockproc',
|
||||
create_zendesk_tickets: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -665,7 +645,7 @@ describe('ProctoredExamSettings', () => {
|
||||
});
|
||||
|
||||
it('Successfully updates exam configuration and studio provider is set to "lti_external" for lti providers', async () => {
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
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');
|
||||
fireEvent.change(selectElement, { target: { value: 'test_lti' } });
|
||||
@@ -692,7 +672,7 @@ describe('ProctoredExamSettings', () => {
|
||||
enable_proctored_exams: true,
|
||||
allow_proctoring_opt_out: false,
|
||||
proctoring_provider: 'lti_external',
|
||||
create_zendesk_tickets: true,
|
||||
proctoring_escalation_email: 'test_lti@example.com',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -706,7 +686,7 @@ describe('ProctoredExamSettings', () => {
|
||||
});
|
||||
|
||||
it('Sets exam service provider to null if a non-lti provider is selected', async () => {
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
|
||||
const submitButton = screen.getByTestId('submissionButton');
|
||||
fireEvent.click(submitButton);
|
||||
// update exam service config
|
||||
@@ -722,7 +702,6 @@ describe('ProctoredExamSettings', () => {
|
||||
enable_proctored_exams: true,
|
||||
allow_proctoring_opt_out: false,
|
||||
proctoring_provider: 'mockproc',
|
||||
create_zendesk_tickets: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -744,13 +723,13 @@ describe('ProctoredExamSettings', () => {
|
||||
allow_proctoring_opt_out: false,
|
||||
proctoring_provider: 'mockproc',
|
||||
proctoring_escalation_email: 'test@example.com',
|
||||
create_zendesk_tickets: true,
|
||||
},
|
||||
available_proctoring_providers: ['software_secure', 'proctortrack', 'mockproc'],
|
||||
available_proctoring_providers: ['software_secure', 'mockproc'],
|
||||
requires_escalation_email_providers: [],
|
||||
course_start_date: '2070-01-01T00:00:00Z',
|
||||
});
|
||||
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
|
||||
const submitButton = screen.getByTestId('submissionButton');
|
||||
fireEvent.click(submitButton);
|
||||
// does not update exam service config
|
||||
@@ -762,7 +741,6 @@ describe('ProctoredExamSettings', () => {
|
||||
enable_proctored_exams: true,
|
||||
allow_proctoring_opt_out: false,
|
||||
proctoring_provider: 'mockproc',
|
||||
create_zendesk_tickets: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -780,7 +758,7 @@ describe('ProctoredExamSettings', () => {
|
||||
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
|
||||
).reply(500);
|
||||
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
|
||||
const submitButton = screen.getByTestId('submissionButton');
|
||||
fireEvent.click(submitButton);
|
||||
expect(axiosMock.history.post.length).toBe(1);
|
||||
@@ -798,7 +776,7 @@ describe('ProctoredExamSettings', () => {
|
||||
`${ExamsApiService.getExamsBaseUrl()}/api/v1/configs/course_id/${defaultProps.courseId}`,
|
||||
).reply(500, 'error');
|
||||
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
|
||||
const submitButton = screen.getByTestId('submissionButton');
|
||||
fireEvent.click(submitButton);
|
||||
expect(axiosMock.history.post.length).toBe(1);
|
||||
@@ -816,7 +794,7 @@ describe('ProctoredExamSettings', () => {
|
||||
`${ExamsApiService.getExamsBaseUrl()}/api/v1/configs/course_id/${defaultProps.courseId}`,
|
||||
).reply(403, 'error');
|
||||
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
|
||||
const submitButton = screen.getByTestId('submissionButton');
|
||||
fireEvent.click(submitButton);
|
||||
expect(axiosMock.history.post.length).toBe(1);
|
||||
@@ -835,7 +813,7 @@ describe('ProctoredExamSettings', () => {
|
||||
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
|
||||
).reply(500);
|
||||
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
|
||||
const submitButton = screen.getByTestId('submissionButton');
|
||||
fireEvent.click(submitButton);
|
||||
expect(axiosMock.history.post.length).toBe(1);
|
||||
@@ -862,27 +840,5 @@ describe('ProctoredExamSettings', () => {
|
||||
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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -81,11 +81,6 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Allow learners to opt out of proctoring on proctored exams',
|
||||
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': {
|
||||
id: 'authoring.proctoring.error.single',
|
||||
defaultMessage: 'There is 1 error in this form.',
|
||||
@@ -107,6 +107,7 @@ const TeamSettings = ({
|
||||
)
|
||||
.when('enabled', {
|
||||
is: true,
|
||||
// oxlint-disable-next-line unicorn/no-thenable
|
||||
then: Yup.array().min(1),
|
||||
})
|
||||
.default([])
|
||||
|
||||
@@ -19,7 +19,7 @@ export function updateXpertSettings(courseId, state) {
|
||||
}
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
return false;
|
||||
} catch (error) {
|
||||
} catch {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
return false;
|
||||
}
|
||||
@@ -33,7 +33,7 @@ export function fetchXpertPluginConfigurable(courseId) {
|
||||
try {
|
||||
const { response } = await getXpertPluginConfigurable(courseId);
|
||||
enabled = response?.enabled;
|
||||
} catch (e) {
|
||||
} catch {
|
||||
enabled = undefined;
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ export function fetchXpertSettings(courseId) {
|
||||
try {
|
||||
const { response } = await getXpertSettings(courseId);
|
||||
enabled = response?.enabled;
|
||||
} catch (e) {
|
||||
} catch {
|
||||
enabled = undefined;
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ export function removeXpertSettings(courseId) {
|
||||
}
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
return false;
|
||||
} catch (error) {
|
||||
} catch {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
return false;
|
||||
}
|
||||
@@ -105,7 +105,7 @@ export function resetXpertSettings(courseId, state) {
|
||||
}
|
||||
dispatch(updateResetStatus({ status: RequestStatus.FAILED }));
|
||||
return false;
|
||||
} catch (error) {
|
||||
} catch {
|
||||
dispatch(updateResetStatus({ status: RequestStatus.FAILED }));
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { getExternalLinkUrl } from '@edx/frontend-platform';
|
||||
import {
|
||||
ActionRow,
|
||||
Alert,
|
||||
@@ -238,8 +239,10 @@ const SettingsModal = ({
|
||||
const values = { ...rest, enabled: enabled ? checked === 'true' : undefined };
|
||||
|
||||
if (enabled) {
|
||||
// oxlint-disable-next-line @typescript-eslint/await-thenable - this dispatch() IS returning a promise.
|
||||
success = await dispatch(updateXpertSettings(courseId, values));
|
||||
} else {
|
||||
// oxlint-disable-next-line @typescript-eslint/await-thenable - this dispatch() IS returning a promise.
|
||||
success = await dispatch(removeXpertSettings(courseId));
|
||||
}
|
||||
|
||||
@@ -276,7 +279,7 @@ const SettingsModal = ({
|
||||
<div className="py-1">
|
||||
<Hyperlink
|
||||
className="text-primary-500"
|
||||
destination="https://openai.com/api-data-privacy"
|
||||
destination={getExternalLinkUrl('https://openai.com/api-data-privacy')}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
|
||||
172
src/CourseAuthoringContext.tsx
Normal file
172
src/CourseAuthoringContext.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
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;
|
||||
}
|
||||
@@ -4,9 +4,9 @@ import CourseAuthoringPage from './CourseAuthoringPage';
|
||||
import PagesAndResources from './pages-and-resources/PagesAndResources';
|
||||
import { executeThunk } from './utils';
|
||||
import { fetchCourseApps } from './pages-and-resources/data/thunks';
|
||||
import { fetchCourseDetail } from './data/thunks';
|
||||
import { getApiWaffleFlagsUrl } from './data/api';
|
||||
import { initializeMocks, render } from './testUtils';
|
||||
import { CourseAuthoringProvider } from './CourseAuthoringContext';
|
||||
|
||||
const courseId = 'course-v1:edX+TestX+Test_Course';
|
||||
let mockPathname = '/evilguy/';
|
||||
@@ -19,6 +19,12 @@ jest.mock('react-router-dom', () => ({
|
||||
let axiosMock;
|
||||
let store;
|
||||
|
||||
const renderComponent = children => render(
|
||||
<CourseAuthoringProvider courseId={courseId}>
|
||||
{children}
|
||||
</CourseAuthoringProvider>,
|
||||
);
|
||||
|
||||
beforeEach(async () => {
|
||||
const mocks = initializeMocks();
|
||||
store = mocks.reduxStore;
|
||||
@@ -35,14 +41,13 @@ describe('Editor Pages Load no header', () => {
|
||||
axiosMock.onGet(`${courseAppsApiUrl}/${courseId}`).reply(200, {
|
||||
response: { status: 200 },
|
||||
});
|
||||
await executeThunk(fetchCourseApps(courseId), store.dispatch);
|
||||
};
|
||||
test('renders no loading wheel on editor pages', async () => {
|
||||
mockPathname = '/editor/';
|
||||
await mockStoreSuccess();
|
||||
const wrapper = render(
|
||||
<CourseAuthoringPage courseId={courseId}>
|
||||
<PagesAndResources courseId={courseId} />
|
||||
const wrapper = renderComponent(
|
||||
<CourseAuthoringPage>
|
||||
<PagesAndResources />
|
||||
</CourseAuthoringPage>
|
||||
,
|
||||
);
|
||||
@@ -51,9 +56,9 @@ describe('Editor Pages Load no header', () => {
|
||||
test('renders loading wheel on non editor pages', async () => {
|
||||
mockPathname = '/evilguy/';
|
||||
await mockStoreSuccess();
|
||||
const wrapper = render(
|
||||
<CourseAuthoringPage courseId={courseId}>
|
||||
<PagesAndResources courseId={courseId} />
|
||||
const wrapper = renderComponent(
|
||||
<CourseAuthoringPage>
|
||||
<PagesAndResources />
|
||||
</CourseAuthoringPage>
|
||||
,
|
||||
);
|
||||
@@ -70,7 +75,6 @@ describe('Course authoring page', () => {
|
||||
).reply(404, {
|
||||
response: { status: 404 },
|
||||
});
|
||||
await executeThunk(fetchCourseDetail(courseId), store.dispatch);
|
||||
};
|
||||
const mockStoreError = async () => {
|
||||
axiosMock.onGet(
|
||||
@@ -78,11 +82,10 @@ describe('Course authoring page', () => {
|
||||
).reply(500, {
|
||||
response: { status: 500 },
|
||||
});
|
||||
await executeThunk(fetchCourseDetail(courseId), store.dispatch);
|
||||
};
|
||||
test('renders not found page on non-existent course key', async () => {
|
||||
await mockStoreNotFound();
|
||||
const wrapper = render(<CourseAuthoringPage courseId={courseId} />);
|
||||
const wrapper = renderComponent(<CourseAuthoringPage />);
|
||||
expect(await wrapper.findByTestId('notFoundAlert')).toBeInTheDocument();
|
||||
});
|
||||
test('does not render not found page on other kinds of error', async () => {
|
||||
@@ -92,8 +95,8 @@ describe('Course authoring page', () => {
|
||||
// IN_PROGRESS but also not NOT_FOUND or DENIED- then check that the not
|
||||
// found alert is not present.
|
||||
const contentTestId = 'courseAuthoringPageContent';
|
||||
const wrapper = render(
|
||||
<CourseAuthoringPage courseId={courseId}>
|
||||
const wrapper = renderComponent(
|
||||
<CourseAuthoringPage>
|
||||
<div data-testid={contentTestId} />
|
||||
</CourseAuthoringPage>
|
||||
,
|
||||
@@ -114,7 +117,7 @@ describe('Course authoring page', () => {
|
||||
mockPathname = '/editor/';
|
||||
await mockStoreDenied();
|
||||
|
||||
const wrapper = render(<CourseAuthoringPage courseId={courseId} />);
|
||||
const wrapper = renderComponent(<CourseAuthoringPage />);
|
||||
expect(await wrapper.findByTestId('permissionDeniedAlert')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import {
|
||||
@@ -7,34 +6,31 @@ import {
|
||||
} from 'react-router-dom';
|
||||
import { StudioFooterSlot } from '@edx/frontend-component-footer';
|
||||
import Header from './header';
|
||||
import { fetchCourseDetail } from './data/thunks';
|
||||
import { useModel } from './generic/model-store';
|
||||
import NotFoundAlert from './generic/NotFoundAlert';
|
||||
import PermissionDeniedAlert from './generic/PermissionDeniedAlert';
|
||||
import { fetchOnlyStudioHomeData } from './studio-home/data/thunks';
|
||||
import { getCourseAppsApiStatus } from './pages-and-resources/data/selectors';
|
||||
import { RequestStatus } from './data/constants';
|
||||
import Loading from './generic/Loading';
|
||||
import { useCourseAuthoringContext } from './CourseAuthoringContext';
|
||||
|
||||
const CourseAuthoringPage = ({ courseId, children }) => {
|
||||
interface Props {
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
const CourseAuthoringPage = ({ children }: Props) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchCourseDetail(courseId));
|
||||
}, [courseId]);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchOnlyStudioHomeData());
|
||||
}, []);
|
||||
|
||||
const courseDetail = useModel('courseDetails', courseId);
|
||||
|
||||
const courseNumber = courseDetail ? courseDetail.number : null;
|
||||
const courseOrg = courseDetail ? courseDetail.org : null;
|
||||
const courseTitle = courseDetail ? courseDetail.name : courseId;
|
||||
const { courseId, courseDetails, courseDetailStatus } = useCourseAuthoringContext();
|
||||
const courseNumber = courseDetails?.number;
|
||||
const courseOrg = courseDetails?.org;
|
||||
const courseTitle = courseDetails?.name;
|
||||
const inProgress = courseDetailStatus === RequestStatus.IN_PROGRESS || courseDetailStatus === RequestStatus.PENDING;
|
||||
const courseAppsApiStatus = useSelector(getCourseAppsApiStatus);
|
||||
const courseDetailStatus = useSelector(state => state.courseDetail.status);
|
||||
const inProgress = courseDetailStatus === RequestStatus.IN_PROGRESS;
|
||||
const { pathname } = useLocation();
|
||||
const isEditor = pathname.includes('/editor');
|
||||
|
||||
@@ -61,6 +57,9 @@ const CourseAuthoringPage = ({ courseId, children }) => {
|
||||
org={courseOrg}
|
||||
title={courseTitle}
|
||||
contextId={courseId}
|
||||
containerProps={{
|
||||
size: 'fluid',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
@@ -70,13 +69,4 @@ const CourseAuthoringPage = ({ courseId, children }) => {
|
||||
);
|
||||
};
|
||||
|
||||
CourseAuthoringPage.propTypes = {
|
||||
children: PropTypes.node,
|
||||
courseId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
CourseAuthoringPage.defaultProps = {
|
||||
children: null,
|
||||
};
|
||||
|
||||
export default CourseAuthoringPage;
|
||||
@@ -1,153 +0,0 @@
|
||||
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;
|
||||
@@ -48,7 +48,11 @@ jest.mock('./custom-pages/CustomPages', () => (props) => {
|
||||
|
||||
describe('<CourseAuthoringRoutes>', () => {
|
||||
beforeEach(async () => {
|
||||
const { axiosMock } = initializeMocks();
|
||||
const user = {
|
||||
userId: 1,
|
||||
username: 'username',
|
||||
};
|
||||
const { axiosMock } = initializeMocks({ user });
|
||||
axiosMock
|
||||
.onGet(getApiWaffleFlagsUrl(courseId))
|
||||
.reply(200, {});
|
||||
@@ -61,11 +65,7 @@ describe('<CourseAuthoringRoutes>', () => {
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(pagesAndResourcesMockText)).toBeVisible();
|
||||
expect(mockComponentFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
courseId,
|
||||
}),
|
||||
);
|
||||
expect(mockComponentFn).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -93,11 +93,7 @@ describe('<CourseAuthoringRoutes>', () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(videoSelectorContainerMockText)).toBeInTheDocument();
|
||||
expect(screen.queryByText(pagesAndResourcesMockText)).not.toBeInTheDocument();
|
||||
expect(mockComponentFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
courseId,
|
||||
}),
|
||||
);
|
||||
expect(mockComponentFn).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
186
src/CourseAuthoringRoutes.tsx
Normal file
186
src/CourseAuthoringRoutes.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
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;
|
||||
@@ -1,46 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,3 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import { Hyperlink, MailtoLink, Stack } from '@openedx/paragon';
|
||||
|
||||
@@ -8,6 +6,9 @@ import messages from './messages';
|
||||
const AccessibilityBody = ({
|
||||
communityAccessibilityLink,
|
||||
email,
|
||||
}: {
|
||||
communityAccessibilityLink: string,
|
||||
email: string,
|
||||
}) => (
|
||||
<div className="mt-5">
|
||||
<header>
|
||||
@@ -90,9 +91,4 @@ const AccessibilityBody = ({
|
||||
</div>
|
||||
);
|
||||
|
||||
AccessibilityBody.propTypes = {
|
||||
communityAccessibilityLink: PropTypes.string.isRequired,
|
||||
email: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default AccessibilityBody;
|
||||
@@ -1,56 +1,31 @@
|
||||
import {
|
||||
initializeMocks,
|
||||
render,
|
||||
screen,
|
||||
} from '@testing-library/react';
|
||||
} from '@src/testUtils';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import initializeStore from '../../store';
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
|
||||
import AccessibilityForm from './index';
|
||||
import { getZendeskrUrl } from '../data/api';
|
||||
import messages from './messages';
|
||||
|
||||
let axiosMock;
|
||||
let store;
|
||||
|
||||
const defaultProps = {
|
||||
accessibilityEmail: 'accessibilityTest@test.com',
|
||||
};
|
||||
|
||||
const initialState = {
|
||||
accessibilityPage: {
|
||||
savingStatus: '',
|
||||
},
|
||||
};
|
||||
|
||||
const renderComponent = () => {
|
||||
render(
|
||||
<IntlProvider locale="en">
|
||||
<AppProvider store={store}>
|
||||
<AccessibilityForm {...defaultProps} />
|
||||
</AppProvider>
|
||||
</IntlProvider>,
|
||||
<AccessibilityForm {...defaultProps} />,
|
||||
);
|
||||
};
|
||||
|
||||
describe('<AccessibilityPolicyForm />', () => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: false,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore(initialState);
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
const mocks = initializeMocks();
|
||||
|
||||
axiosMock = mocks.axiosMock;
|
||||
});
|
||||
|
||||
describe('renders', () => {
|
||||
@@ -86,14 +61,23 @@ describe('<AccessibilityPolicyForm />', () => {
|
||||
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 () => {
|
||||
axiosMock.onPost(getZendeskrUrl()).reply(200);
|
||||
|
||||
await user.click(submitButton);
|
||||
|
||||
const { savingStatus } = store.getState().accessibilityPage;
|
||||
expect(savingStatus).toEqual(RequestStatus.SUCCESSFUL);
|
||||
|
||||
expect(screen.getAllByRole('alert')).toHaveLength(1);
|
||||
|
||||
expect(screen.getByText(messages.accessibilityPolicyFormSuccess.defaultMessage)).toBeVisible();
|
||||
@@ -108,9 +92,6 @@ describe('<AccessibilityPolicyForm />', () => {
|
||||
|
||||
await user.click(submitButton);
|
||||
|
||||
const { savingStatus } = store.getState().accessibilityPage;
|
||||
expect(savingStatus).toEqual(RequestStatus.FAILED);
|
||||
|
||||
expect(screen.getAllByRole('alert')).toHaveLength(1);
|
||||
|
||||
expect(screen.getByTestId('rate-limit-alert')).toBeVisible();
|
||||
@@ -1,5 +1,3 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
FormattedMessage, FormattedDate, FormattedTime, useIntl,
|
||||
} from '@edx/frontend-platform/i18n';
|
||||
@@ -7,26 +5,22 @@ import {
|
||||
ActionRow, Alert, Form, Stack, StatefulButton,
|
||||
} from '@openedx/paragon';
|
||||
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import { STATEFUL_BUTTON_STATES } from '../../constants';
|
||||
import submitAccessibilityForm from '../data/thunks';
|
||||
import { STATEFUL_BUTTON_STATES } from '@src/constants';
|
||||
import useAccessibility from './hooks';
|
||||
import messages from './messages';
|
||||
|
||||
const AccessibilityForm = ({
|
||||
accessibilityEmail,
|
||||
}) => {
|
||||
const AccessibilityForm = ({ accessibilityEmail }: { accessibilityEmail: string }) => {
|
||||
const intl = useIntl();
|
||||
const {
|
||||
errors,
|
||||
values,
|
||||
isFormFilled,
|
||||
dispatch,
|
||||
mutation,
|
||||
handleBlur,
|
||||
handleChange,
|
||||
hasErrorField,
|
||||
savingStatus,
|
||||
} = useAccessibility({ name: '', email: '', message: '' }, intl);
|
||||
} = useAccessibility({ name: '', email: '', message: '' });
|
||||
|
||||
const formFields = [
|
||||
{
|
||||
@@ -55,7 +49,7 @@ const AccessibilityForm = ({
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
dispatch(submitAccessibilityForm(values));
|
||||
mutation.mutateAsync(values).catch(() => {});
|
||||
};
|
||||
|
||||
const start = new Date('Mon Jan 29 2018 13:00:00 GMT (UTC)');
|
||||
@@ -66,7 +60,7 @@ const AccessibilityForm = ({
|
||||
<h2 className="my-4">
|
||||
<FormattedMessage {...messages.accessibilityPolicyFormHeader} />
|
||||
</h2>
|
||||
{savingStatus === RequestStatus.SUCCESSFUL && (
|
||||
{savingStatus === 'success' && (
|
||||
<Alert variant="success">
|
||||
<Stack gap={2}>
|
||||
<div className="mb-2">
|
||||
@@ -86,7 +80,7 @@ const AccessibilityForm = ({
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
{savingStatus === RequestStatus.FAILED && (
|
||||
{savingStatus === 'error' && (
|
||||
<Alert variant="danger">
|
||||
<div data-testid="rate-limit-alert">
|
||||
<FormattedMessage
|
||||
@@ -125,7 +119,7 @@ const AccessibilityForm = ({
|
||||
onClick={handleSubmit}
|
||||
disabled={!isFormFilled}
|
||||
state={
|
||||
savingStatus === RequestStatus.IN_PROGRESS
|
||||
savingStatus === 'pending'
|
||||
? STATEFUL_BUTTON_STATES.pending
|
||||
: STATEFUL_BUTTON_STATES.default
|
||||
}
|
||||
@@ -136,8 +130,4 @@ const AccessibilityForm = ({
|
||||
);
|
||||
};
|
||||
|
||||
AccessibilityForm.propTypes = {
|
||||
accessibilityEmail: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default AccessibilityForm;
|
||||
@@ -1,14 +1,14 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import messages from './messages';
|
||||
import { useSubmitAccessibilityForm } from '../data/apiHooks';
|
||||
import { AccessibilityFormData } from '../data/api';
|
||||
|
||||
const useAccessibility = (initialValues, intl) => {
|
||||
const dispatch = useDispatch();
|
||||
const savingStatus = useSelector(state => state.accessibilityPage.savingStatus);
|
||||
const useAccessibility = (initialValues: AccessibilityFormData) => {
|
||||
const intl = useIntl();
|
||||
const [isFormFilled, setFormFilled] = useState(false);
|
||||
const validationSchema = Yup.object().shape({
|
||||
name: Yup.string().required(
|
||||
@@ -29,29 +29,27 @@ const useAccessibility = (initialValues, intl) => {
|
||||
enableReinitialize: true,
|
||||
validateOnBlur: false,
|
||||
validationSchema,
|
||||
/* istanbul ignore next */
|
||||
onSubmit: () => {},
|
||||
});
|
||||
|
||||
const mutation = useSubmitAccessibilityForm(handleReset);
|
||||
|
||||
useEffect(() => {
|
||||
setFormFilled(Object.values(values).every((i) => i));
|
||||
}, [values]);
|
||||
|
||||
useEffect(() => {
|
||||
if (savingStatus === RequestStatus.SUCCESSFUL) {
|
||||
handleReset();
|
||||
}
|
||||
}, [savingStatus]);
|
||||
|
||||
const hasErrorField = (fieldName) => !!errors[fieldName] && !!touched[fieldName];
|
||||
|
||||
return {
|
||||
errors,
|
||||
values,
|
||||
isFormFilled,
|
||||
dispatch,
|
||||
mutation,
|
||||
handleBlur,
|
||||
handleChange,
|
||||
hasErrorField,
|
||||
savingStatus,
|
||||
savingStatus: mutation.status,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// @ts-check
|
||||
import { initializeMocks, render, screen } from '../testUtils';
|
||||
import AccessibilityPage from './index';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { getExternalLinkUrl } from '@edx/frontend-platform';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { Container } from '@openedx/paragon';
|
||||
import { StudioFooterSlot } from '@edx/frontend-component-footer';
|
||||
@@ -23,9 +23,12 @@ const AccessibilityPage = () => {
|
||||
</title>
|
||||
</Helmet>
|
||||
<Header isHiddenMainMenu />
|
||||
<Container size="xl" classNamae="px-4">
|
||||
<Container size="xl" className="px-4">
|
||||
<AccessibilityBody
|
||||
{...{ email: ACCESSIBILITY_EMAIL, communityAccessibilityLink: COMMUNITY_ACCESSIBILITY_LINK }}
|
||||
{...{
|
||||
email: ACCESSIBILITY_EMAIL,
|
||||
communityAccessibilityLink: getExternalLinkUrl(COMMUNITY_ACCESSIBILITY_LINK),
|
||||
}}
|
||||
/>
|
||||
<AccessibilityForm accessibilityEmail={ACCESSIBILITY_EMAIL} />
|
||||
</Container>
|
||||
@@ -34,6 +37,4 @@ const AccessibilityPage = () => {
|
||||
);
|
||||
};
|
||||
|
||||
AccessibilityPage.propTypes = {};
|
||||
|
||||
export default AccessibilityPage;
|
||||
@@ -8,12 +8,20 @@ ensureConfig([
|
||||
export const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
|
||||
export const getZendeskrUrl = () => `${getApiBaseUrl()}/zendesk_proxy/v0`;
|
||||
|
||||
export interface AccessibilityFormData {
|
||||
name: string;
|
||||
email: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Posts the form data to zendesk endpoint
|
||||
* @param {string} courseId
|
||||
* @returns {Promise<[{}]>}
|
||||
*/
|
||||
export async function postAccessibilityForm({ name, email, message }) {
|
||||
export async function postAccessibilityForm({
|
||||
name,
|
||||
email,
|
||||
message,
|
||||
}: AccessibilityFormData) {
|
||||
const data = {
|
||||
name,
|
||||
tags: ['studio_a11y'],
|
||||
12
src/accessibility-page/data/apiHooks.ts
Normal file
12
src/accessibility-page/data/apiHooks.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
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,
|
||||
})
|
||||
);
|
||||
@@ -1,23 +0,0 @@
|
||||
/* 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;
|
||||
@@ -1,24 +0,0 @@
|
||||
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;
|
||||
@@ -1,144 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
190
src/advanced-settings/AdvancedSettings.test.tsx
Normal file
190
src/advanced-settings/AdvancedSettings.test.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,59 +1,73 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import {
|
||||
Container, Button, Layout, StatefulButton, TransitionReplace,
|
||||
} from '@openedx/paragon';
|
||||
import { CheckCircle, Info, Warning } from '@openedx/paragon/icons';
|
||||
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
|
||||
import Placeholder from '../editors/Placeholder';
|
||||
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
|
||||
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 SettingsSidebar from './settings-sidebar/SettingsSidebar';
|
||||
import validateAdvancedSettingsData from './utils';
|
||||
import messages from './messages';
|
||||
import ModalError from './modal-error/ModalError';
|
||||
import getPageHeadTitle from '../generic/utils';
|
||||
import { useCourseAdvancedSettings, useProctoringExamErrors, useUpdateCourseAdvancedSettings } from './data/apiHooks';
|
||||
|
||||
const AdvancedSettings = ({ courseId }) => {
|
||||
const AdvancedSettings = () => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
const [saveSettingsPrompt, showSaveSettingsPrompt] = useState(false);
|
||||
const [showDeprecated, setShowDeprecated] = useState(false);
|
||||
const [errorModal, showErrorModal] = useState(false);
|
||||
const [editedSettings, setEditedSettings] = useState({});
|
||||
const [errorFields, setErrorFields] = useState([]);
|
||||
const [showSuccessAlert, setShowSuccessAlert] = useState(false);
|
||||
const [isQueryPending, setIsQueryPending] = useState(false);
|
||||
const [isEditableState, setIsEditableState] = useState(false);
|
||||
const [hasInternetConnectionError, setInternetConnectionError] = useState(false);
|
||||
|
||||
const courseDetails = useModel('courseDetails', courseId);
|
||||
document.title = getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.headingTitle));
|
||||
const { courseId, courseDetails } = useCourseAuthoringContext();
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchCourseAppSettings(courseId));
|
||||
dispatch(fetchProctoringExamErrors(courseId));
|
||||
}, [courseId]);
|
||||
const waffleFlags = useWaffleFlags(courseId);
|
||||
const isAuthzEnabled = waffleFlags.enableAuthzCourseAuthoring;
|
||||
const { isLoading: isLoadingUserPermissions, data: userPermissions } = useUserPermissions({
|
||||
canManageAdvancedSettings: {
|
||||
action: COURSE_PERMISSIONS.MANAGE_ADVANCED_SETTINGS,
|
||||
scope: courseId,
|
||||
},
|
||||
}, isAuthzEnabled);
|
||||
|
||||
const advancedSettingsData = useSelector(getCourseAppSettings);
|
||||
const savingStatus = useSelector(getSavingStatus);
|
||||
const proctoringExamErrors = useSelector(getProctoringExamErrors);
|
||||
const settingsWithSendErrors = useSelector(getSendRequestErrors) || {};
|
||||
const loadingSettingsStatus = useSelector(getLoadingStatus);
|
||||
const {
|
||||
data: advancedSettingsData = {},
|
||||
isPending: isPendingSettingsStatus,
|
||||
failureReason: settingsStatusError,
|
||||
} = useCourseAdvancedSettings(courseId);
|
||||
|
||||
const isLoading = loadingSettingsStatus === RequestStatus.IN_PROGRESS;
|
||||
const {
|
||||
data: proctoringExamErrors = {},
|
||||
} = useProctoringExamErrors(courseId);
|
||||
|
||||
const updateMutation = useUpdateCourseAdvancedSettings(courseId);
|
||||
|
||||
const {
|
||||
isPending: isQueryPending,
|
||||
isSuccess: isQuerySuccess,
|
||||
error: queryError,
|
||||
} = updateMutation;
|
||||
|
||||
const isLoading = isPendingSettingsStatus || (isAuthzEnabled && isLoadingUserPermissions);
|
||||
const updateSettingsButtonState = {
|
||||
labels: {
|
||||
default: intl.formatMessage(messages.buttonSaveText),
|
||||
@@ -61,30 +75,34 @@ const AdvancedSettings = ({ courseId }) => {
|
||||
},
|
||||
disabledStates: ['pending'],
|
||||
};
|
||||
|
||||
const {
|
||||
proctoringErrors,
|
||||
mfeProctoredExamSettingsUrl,
|
||||
} = proctoringExamErrors;
|
||||
|
||||
useEffect(() => {
|
||||
if (savingStatus === RequestStatus.SUCCESSFUL) {
|
||||
setIsQueryPending(false);
|
||||
if (isQuerySuccess) {
|
||||
setShowSuccessAlert(true);
|
||||
setIsEditableState(false);
|
||||
setTimeout(() => setShowSuccessAlert(false), 15000);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
showSaveSettingsPrompt(false);
|
||||
} else if (savingStatus === RequestStatus.FAILED && !hasInternetConnectionError) {
|
||||
setErrorFields(settingsWithSendErrors);
|
||||
} else if (queryError && !hasInternetConnectionError) {
|
||||
// @ts-ignore
|
||||
setErrorFields(queryError?.response?.data ?? []);
|
||||
showErrorModal(true);
|
||||
}
|
||||
}, [savingStatus]);
|
||||
}, [isQuerySuccess, queryError]);
|
||||
|
||||
if (isLoading) {
|
||||
// eslint-disable-next-line react/jsx-no-useless-fragment
|
||||
return <></>;
|
||||
return (
|
||||
<div className="row justify-content-center m-6">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (loadingSettingsStatus === RequestStatus.DENIED) {
|
||||
if (settingsStatusError?.response?.status === 403) {
|
||||
return (
|
||||
<div className="row justify-content-center m-6">
|
||||
<Placeholder />
|
||||
@@ -106,31 +124,42 @@ const AdvancedSettings = ({ courseId }) => {
|
||||
const handleUpdateAdvancedSettingsData = () => {
|
||||
const isValid = validateAdvancedSettingsData(editedSettings, setErrorFields, setEditedSettings);
|
||||
if (isValid) {
|
||||
setIsQueryPending(true);
|
||||
setShowSuccessAlert(false);
|
||||
updateMutation.mutate(parseArrayOrObjectValues(editedSettings));
|
||||
} else {
|
||||
showSaveSettingsPrompt(false);
|
||||
showErrorModal(!errorModal);
|
||||
}
|
||||
};
|
||||
|
||||
/* istanbul ignore next */
|
||||
const handleInternetConnectionFailed = () => {
|
||||
setInternetConnectionError(true);
|
||||
showSaveSettingsPrompt(false);
|
||||
setShowSuccessAlert(false);
|
||||
};
|
||||
|
||||
const handleQueryProcessing = () => {
|
||||
setShowSuccessAlert(false);
|
||||
dispatch(updateCourseAppSetting(courseId, parseArrayOrObjectValues(editedSettings)));
|
||||
};
|
||||
|
||||
const handleManuallyChangeClick = (setToState) => {
|
||||
showErrorModal(setToState);
|
||||
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 (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>
|
||||
{getPageHeadTitle(courseDetails?.name ?? '', intl.formatMessage(messages.headingTitle))}
|
||||
</title>
|
||||
</Helmet>
|
||||
<Container size="xl" className="advanced-settings px-4">
|
||||
<div className="setting-header mt-5">
|
||||
{(proctoringErrors?.length > 0) && (
|
||||
@@ -140,7 +169,11 @@ const AdvancedSettings = ({ courseId }) => {
|
||||
aria-hidden="true"
|
||||
aria-labelledby={intl.formatMessage(messages.alertProctoringAriaLabelledby)}
|
||||
aria-describedby={intl.formatMessage(messages.alertProctoringDescribedby)}
|
||||
/>
|
||||
>
|
||||
{/* Empty children to satisfy the type checker */}
|
||||
{/* eslint-disable-next-line react/jsx-no-useless-fragment */}
|
||||
<></>
|
||||
</AlertProctoringError>
|
||||
)}
|
||||
<TransitionReplace>
|
||||
{showSuccessAlert ? (
|
||||
@@ -193,8 +226,8 @@ const AdvancedSettings = ({ courseId }) => {
|
||||
defaultMessage="{visibility} deprecated settings"
|
||||
values={{
|
||||
visibility:
|
||||
showDeprecated ? intl.formatMessage(messages.deprecatedButtonHideText)
|
||||
: intl.formatMessage(messages.deprecatedButtonShowText),
|
||||
showDeprecated ? intl.formatMessage(messages.deprecatedButtonHideText)
|
||||
: intl.formatMessage(messages.deprecatedButtonShowText),
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
@@ -236,9 +269,8 @@ const AdvancedSettings = ({ courseId }) => {
|
||||
<div className="alert-toast">
|
||||
{isQueryPending && (
|
||||
<InternetConnectionAlert
|
||||
isFailed={savingStatus === RequestStatus.FAILED}
|
||||
isFailed={Boolean(queryError)}
|
||||
isQueryPending={isQueryPending}
|
||||
onQueryProcessing={handleQueryProcessing}
|
||||
onInternetConnectionFailed={handleInternetConnectionFailed}
|
||||
/>
|
||||
)}
|
||||
@@ -249,18 +281,18 @@ const AdvancedSettings = ({ courseId }) => {
|
||||
aria-describedby={intl.formatMessage(messages.alertWarningAriaDescribedby)}
|
||||
role="dialog"
|
||||
actions={[
|
||||
!isQueryPending && (
|
||||
!isQueryPending ? (
|
||||
<Button variant="tertiary" onClick={handleResetSettingsValues}>
|
||||
{intl.formatMessage(messages.buttonCancelText)}
|
||||
</Button>
|
||||
),
|
||||
) : /* istanbul ignore next */ null,
|
||||
<StatefulButton
|
||||
key="statefulBtn"
|
||||
onClick={handleUpdateAdvancedSettingsData}
|
||||
state={isQueryPending ? RequestStatus.PENDING : 'default'}
|
||||
{...updateSettingsButtonState}
|
||||
/>,
|
||||
].filter(Boolean)}
|
||||
].filter((action): action is JSX.Element => action !== null)}
|
||||
variant="warning"
|
||||
icon={Warning}
|
||||
title={intl.formatMessage(messages.alertWarning)}
|
||||
@@ -278,8 +310,4 @@ const AdvancedSettings = ({ courseId }) => {
|
||||
);
|
||||
};
|
||||
|
||||
AdvancedSettings.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default AdvancedSettings;
|
||||
@@ -1,4 +1,4 @@
|
||||
module.exports = {
|
||||
export default {
|
||||
advancedModules: {
|
||||
deprecated: false,
|
||||
displayName: 'Advanced Module List',
|
||||
@@ -1,11 +1,10 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import {
|
||||
camelCaseObject,
|
||||
getConfig,
|
||||
} from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { camelCase } from 'lodash';
|
||||
import { convertObjectToSnakeCase } from '../../utils';
|
||||
import { convertObjectToSnakeCase } from '@src/utils';
|
||||
|
||||
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
|
||||
export const getCourseAdvancedSettingsApiUrl = (courseId) => `${getApiBaseUrl()}/api/contentstore/v0/advanced_settings/${courseId}`;
|
||||
@@ -13,10 +12,8 @@ const getProctoringErrorsApiUrl = () => `${getApiBaseUrl()}/api/contentstore/v1/
|
||||
|
||||
/**
|
||||
* Get's advanced setting for a course.
|
||||
* @param {string} courseId
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function getCourseAdvancedSettings(courseId) {
|
||||
export async function getCourseAdvancedSettings(courseId: string): Promise<Record<string, any>> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(`${getCourseAdvancedSettingsApiUrl(courseId)}?fetch_all=0`);
|
||||
const keepValues = {};
|
||||
@@ -36,11 +33,11 @@ export async function getCourseAdvancedSettings(courseId) {
|
||||
|
||||
/**
|
||||
* Updates advanced setting for a course.
|
||||
* @param {string} courseId
|
||||
* @param {object} settings
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function updateCourseAdvancedSettings(courseId, settings) {
|
||||
export async function updateCourseAdvancedSettings(
|
||||
courseId: string,
|
||||
settings: Record<string, any>,
|
||||
): Promise<Record<string, any>> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.patch(`${getCourseAdvancedSettingsApiUrl(courseId)}`, convertObjectToSnakeCase(settings));
|
||||
const keepValues = {};
|
||||
@@ -60,10 +57,8 @@ export async function updateCourseAdvancedSettings(courseId, settings) {
|
||||
|
||||
/**
|
||||
* Gets proctoring exam errors.
|
||||
* @param {string} courseId
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function getProctoringExamErrors(courseId) {
|
||||
export async function getProctoringExamErrors(courseId: string): Promise<Record<string, any>> {
|
||||
const { data } = await getAuthenticatedHttpClient().get(`${getProctoringErrorsApiUrl()}${courseId}`);
|
||||
const keepValues = {};
|
||||
Object.keys(data).forEach((key) => {
|
||||
@@ -77,5 +72,6 @@ export async function getProctoringExamErrors(courseId) {
|
||||
value: keepValues[key]?.value,
|
||||
};
|
||||
});
|
||||
|
||||
return formattedData;
|
||||
}
|
||||
56
src/advanced-settings/data/apiHooks.ts
Normal file
56
src/advanced-settings/data/apiHooks.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/* 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) });
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,5 +0,0 @@
|
||||
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;
|
||||
@@ -1,48 +0,0 @@
|
||||
/* 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;
|
||||
@@ -1,85 +0,0 @@
|
||||
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;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -17,7 +17,7 @@ export default function validateAdvancedSettingsData(settingObj, setErrorFields,
|
||||
Object.entries(settingObj).forEach(([settingName, settingValue]) => {
|
||||
try {
|
||||
JSON.parse(settingValue);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
let targetSettingValue = settingValue;
|
||||
const firstNonWhite = settingValue.substring(0, 1);
|
||||
const isValid = !['{', '[', "'"].includes(firstNonWhite);
|
||||
@@ -30,7 +30,7 @@ export default function validateAdvancedSettingsData(settingObj, setErrorFields,
|
||||
...prevEditedSettings,
|
||||
[settingName]: targetSettingValue,
|
||||
}));
|
||||
} catch (quotedE) { /* empty */ }
|
||||
} catch { /* empty */ }
|
||||
}
|
||||
|
||||
pushDataToErrorArray(settingName);
|
||||
|
||||
20
src/authz/constants.ts
Normal file
20
src/authz/constants.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export const CONTENT_LIBRARY_PERMISSIONS = {
|
||||
DELETE_LIBRARY: 'content_libraries.delete_library',
|
||||
MANAGE_LIBRARY_TAGS: 'content_libraries.manage_library_tags',
|
||||
VIEW_LIBRARY: 'content_libraries.view_library',
|
||||
|
||||
EDIT_LIBRARY_CONTENT: 'content_libraries.edit_library_content',
|
||||
PUBLISH_LIBRARY_CONTENT: 'content_libraries.publish_library_content',
|
||||
REUSE_LIBRARY_CONTENT: 'content_libraries.reuse_library_content',
|
||||
|
||||
CREATE_LIBRARY_COLLECTION: 'content_libraries.create_library_collection',
|
||||
EDIT_LIBRARY_COLLECTION: 'content_libraries.edit_library_collection',
|
||||
DELETE_LIBRARY_COLLECTION: 'content_libraries.delete_library_collection',
|
||||
|
||||
MANAGE_LIBRARY_TEAM: 'content_libraries.manage_library_team',
|
||||
VIEW_LIBRARY_TEAM: 'content_libraries.view_library_team',
|
||||
};
|
||||
|
||||
export const COURSE_PERMISSIONS = {
|
||||
MANAGE_ADVANCED_SETTINGS: 'courses.manage_advanced_settings',
|
||||
};
|
||||
41
src/authz/data/api.ts
Normal file
41
src/authz/data/api.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import {
|
||||
PermissionValidationAnswer,
|
||||
PermissionValidationQuery,
|
||||
PermissionValidationRequestItem,
|
||||
PermissionValidationResponseItem,
|
||||
} from '@src/authz/types';
|
||||
import { getApiUrl } from './utils';
|
||||
|
||||
export const validateUserPermissions = async (
|
||||
query: PermissionValidationQuery,
|
||||
): Promise<PermissionValidationAnswer> => {
|
||||
// Convert the validations query object into an array for the API request
|
||||
const request: PermissionValidationRequestItem[] = Object.values(query);
|
||||
|
||||
const { data }: { data: PermissionValidationResponseItem[] } = await getAuthenticatedHttpClient().post(
|
||||
getApiUrl('/api/authz/v1/permissions/validate/me'),
|
||||
request,
|
||||
);
|
||||
|
||||
// Convert the API response back into the expected answer format
|
||||
const result: PermissionValidationAnswer = {};
|
||||
data.forEach((item: { action: string; scope?: string; allowed: boolean }) => {
|
||||
const key = Object.keys(query).find(
|
||||
(k) => query[k].action === item.action
|
||||
&& query[k].scope === item.scope,
|
||||
);
|
||||
if (key) {
|
||||
result[key] = item.allowed;
|
||||
}
|
||||
});
|
||||
|
||||
// Fill any missing keys with false
|
||||
Object.keys(query).forEach((key) => {
|
||||
if (!(key in result)) {
|
||||
result[key] = false;
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
168
src/authz/data/apiHooks.test.tsx
Normal file
168
src/authz/data/apiHooks.test.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import { act, ReactNode } from 'react';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { useUserPermissions } from './apiHooks';
|
||||
|
||||
jest.mock('@edx/frontend-platform/auth', () => ({
|
||||
getAuthenticatedHttpClient: jest.fn(),
|
||||
}));
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
|
||||
return wrapper;
|
||||
};
|
||||
|
||||
const singlePermission = {
|
||||
canRead: {
|
||||
action: 'example.read',
|
||||
scope: 'lib:example-org:test-lib',
|
||||
},
|
||||
};
|
||||
|
||||
const mockValidSinglePermission = [
|
||||
{ action: 'example.read', scope: 'lib:example-org:test-lib', allowed: true },
|
||||
];
|
||||
|
||||
const mockInvalidSinglePermission = [
|
||||
{ action: 'example.read', scope: 'lib:example-org:test-lib', allowed: false },
|
||||
];
|
||||
|
||||
const mockEmptyPermissions = [
|
||||
// No permissions returned
|
||||
];
|
||||
|
||||
const multiplePermissions = {
|
||||
canRead: {
|
||||
action: 'example.read',
|
||||
scope: 'lib:example-org:test-lib',
|
||||
},
|
||||
canWrite: {
|
||||
action: 'example.write',
|
||||
scope: 'lib:example-org:test-lib',
|
||||
},
|
||||
};
|
||||
|
||||
const mockValidMultiplePermissions = [
|
||||
{ action: 'example.read', scope: 'lib:example-org:test-lib', allowed: true },
|
||||
{ action: 'example.write', scope: 'lib:example-org:test-lib', allowed: true },
|
||||
];
|
||||
|
||||
const mockInvalidMultiplePermissions = [
|
||||
{ action: 'example.read', scope: 'lib:example-org:test-lib', allowed: false },
|
||||
{ action: 'example.write', scope: 'lib:example-org:test-lib', allowed: false },
|
||||
];
|
||||
|
||||
describe('useUserPermissions', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns allowed true when permission is valid', async () => {
|
||||
getAuthenticatedHttpClient.mockReturnValue({
|
||||
post: jest.fn().mockResolvedValueOnce({ data: mockValidSinglePermission }),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useUserPermissions(singlePermission), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current).toBeDefined());
|
||||
await waitFor(() => expect(result.current.data).toBeDefined());
|
||||
|
||||
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
|
||||
expect(result.current.data!.canRead).toBe(true);
|
||||
});
|
||||
|
||||
it('returns allowed false when permission is invalid', async () => {
|
||||
getAuthenticatedHttpClient.mockReturnValue({
|
||||
post: jest.fn().mockResolvedValue({ data: mockInvalidSinglePermission }),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useUserPermissions(singlePermission), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
await waitFor(() => expect(result.current).toBeDefined());
|
||||
await waitFor(() => expect(result.current.data).toBeDefined());
|
||||
|
||||
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
|
||||
expect(result.current.data!.canRead).toBe(false);
|
||||
});
|
||||
|
||||
it('returns allowed true when multiple permissions are valid', async () => {
|
||||
getAuthenticatedHttpClient.mockReturnValue({
|
||||
post: jest.fn().mockResolvedValueOnce({ data: mockValidMultiplePermissions }),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useUserPermissions(multiplePermissions), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current).toBeDefined());
|
||||
await waitFor(() => expect(result.current.data).toBeDefined());
|
||||
|
||||
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
|
||||
expect(result.current.data!.canRead).toBe(true);
|
||||
expect(result.current.data!.canWrite).toBe(true);
|
||||
});
|
||||
|
||||
it('returns allowed false when multiple permissions are invalid', async () => {
|
||||
getAuthenticatedHttpClient.mockReturnValue({
|
||||
post: jest.fn().mockResolvedValue({ data: mockInvalidMultiplePermissions }),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useUserPermissions(multiplePermissions), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
await waitFor(() => expect(result.current).toBeDefined());
|
||||
await waitFor(() => expect(result.current.data).toBeDefined());
|
||||
|
||||
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
|
||||
expect(result.current.data!.canRead).toBe(false);
|
||||
expect(result.current.data!.canWrite).toBe(false);
|
||||
});
|
||||
|
||||
it('returns allowed false when the permission is not included in the server response', async () => {
|
||||
getAuthenticatedHttpClient.mockReturnValue({
|
||||
post: jest.fn().mockResolvedValue({ data: mockEmptyPermissions }),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useUserPermissions(singlePermission), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
await waitFor(() => expect(result.current).toBeDefined());
|
||||
await waitFor(() => expect(result.current.data).toBeDefined());
|
||||
|
||||
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
|
||||
expect(result.current.data!.canRead).toBe(false);
|
||||
});
|
||||
|
||||
it('handles error when the API call fails', async () => {
|
||||
const mockError = new Error('API Error');
|
||||
|
||||
getAuthenticatedHttpClient.mockReturnValue({
|
||||
post: jest.fn().mockRejectedValue(new Error('API Error')),
|
||||
});
|
||||
|
||||
try {
|
||||
act(() => {
|
||||
renderHook(() => useUserPermissions(singlePermission), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
expect(error).toEqual(mockError); // Check for the expected error
|
||||
}
|
||||
});
|
||||
});
|
||||
37
src/authz/data/apiHooks.ts
Normal file
37
src/authz/data/apiHooks.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { skipToken, useQuery } from '@tanstack/react-query';
|
||||
import { PermissionValidationAnswer, PermissionValidationQuery } from '@src/authz/types';
|
||||
import { validateUserPermissions } from './api';
|
||||
|
||||
const adminConsoleQueryKeys = {
|
||||
all: ['authz'],
|
||||
permissions: (permissions: PermissionValidationQuery) => [...adminConsoleQueryKeys.all, 'validatePermissions', permissions] as const,
|
||||
};
|
||||
|
||||
/**
|
||||
* React Query hook to validate if the current user has permissions over a certain object in the instance.
|
||||
* It helps to:
|
||||
* - Determine whether the current user can access certain object.
|
||||
* - Provide role-based rendering logic for UI components.
|
||||
*
|
||||
* @param permissions - A key/value map of objects and actions to validate.
|
||||
* The key is an arbitrary string to identify the permission check,
|
||||
* and the value is an object containing the action and optional scope.
|
||||
*
|
||||
* @example
|
||||
* const { isLoading, data } = useUserPermissions({
|
||||
* canRead: {
|
||||
* action: "content_libraries.view_library",
|
||||
* scope: "lib:OpenedX:CSPROB"
|
||||
* }
|
||||
* });
|
||||
* if (data.canRead) { ... }
|
||||
*
|
||||
*/
|
||||
export const useUserPermissions = (
|
||||
permissions: PermissionValidationQuery,
|
||||
enabled: boolean = true,
|
||||
) => useQuery<PermissionValidationAnswer, Error>({
|
||||
queryKey: adminConsoleQueryKeys.permissions(permissions),
|
||||
queryFn: enabled ? () => validateUserPermissions(permissions) : skipToken,
|
||||
retry: false,
|
||||
});
|
||||
4
src/authz/data/utils.ts
Normal file
4
src/authz/data/utils.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
export const getApiUrl = (path: string) => `${getConfig().LMS_BASE_URL}${path || ''}`;
|
||||
export const getStudioApiUrl = (path: string) => `${getConfig().STUDIO_BASE_URL}${path || ''}`;
|
||||
16
src/authz/types.ts
Normal file
16
src/authz/types.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export interface PermissionValidationRequestItem {
|
||||
action: string;
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
export interface PermissionValidationResponseItem extends PermissionValidationRequestItem {
|
||||
allowed: boolean;
|
||||
}
|
||||
|
||||
export interface PermissionValidationQuery {
|
||||
[permissionKey: string]: PermissionValidationRequestItem;
|
||||
}
|
||||
|
||||
export interface PermissionValidationAnswer {
|
||||
[permissionKey: string]: boolean;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// @ts-check
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
|
||||
import { initializeMocks, render, waitFor } from '../testUtils';
|
||||
import { RequestStatus } from '../data/constants';
|
||||
import { executeThunk } from '../utils';
|
||||
@@ -14,7 +15,11 @@ let axiosMock;
|
||||
let store;
|
||||
const courseId = 'course-123';
|
||||
|
||||
const renderComponent = (props) => render(<Certificates courseId={courseId} {...props} />);
|
||||
const renderComponent = (props) => render(
|
||||
<CourseAuthoringProvider courseId={courseId}>
|
||||
<Certificates {...props} />
|
||||
</CourseAuthoringProvider>,
|
||||
);
|
||||
|
||||
describe('Certificates', () => {
|
||||
beforeEach(async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Helmet } from 'react-helmet';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
|
||||
import Placeholder from '../editors/Placeholder';
|
||||
import { RequestStatus } from '../data/constants';
|
||||
import Loading from '../generic/Loading';
|
||||
@@ -21,7 +21,8 @@ const MODE_COMPONENTS = {
|
||||
[MODE_STATES.editAll]: CertificateEditForm,
|
||||
};
|
||||
|
||||
const Certificates = ({ courseId }) => {
|
||||
const Certificates = () => {
|
||||
const { courseId } = useCourseAuthoringContext();
|
||||
const {
|
||||
certificates, componentMode, isLoading, loadingStatus, pageHeadTitle, hasCertificateModes,
|
||||
} = useCertificates({ courseId });
|
||||
@@ -50,8 +51,4 @@ const Certificates = ({ courseId }) => {
|
||||
);
|
||||
};
|
||||
|
||||
Certificates.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default Certificates;
|
||||
@@ -1,4 +1,4 @@
|
||||
module.exports = [
|
||||
export default [
|
||||
{
|
||||
id: 1,
|
||||
courseTitle: 'Course Title 1',
|
||||
@@ -1,4 +1,4 @@
|
||||
module.exports = {
|
||||
export default {
|
||||
certificateActivationHandlerUrl: '/certificates/activation/course-v1:org+101+101/',
|
||||
certificateWebViewUrl: '//certificates/course/course-v1:org+101+101?preview=honor',
|
||||
certificates: [
|
||||
@@ -1,4 +1,4 @@
|
||||
module.exports = [
|
||||
export default [
|
||||
{
|
||||
id: '1', name: 'John Doe', title: 'CEO', organization: 'Company', signatureImagePath: '/path/to/signature1.png',
|
||||
},
|
||||
@@ -7,7 +7,7 @@ const CertificateSection = ({
|
||||
<section {...rest}>
|
||||
<Stack className="justify-content-between mb-2.5" direction="horizontal">
|
||||
<h2 className="lead section-title mb-0">{title}</h2>
|
||||
{actions && actions}
|
||||
{actions}
|
||||
</Stack>
|
||||
<hr className="mt-0 mb-4" />
|
||||
<div>
|
||||
|
||||
@@ -25,6 +25,7 @@ const useCertificatesList = (courseId) => {
|
||||
}));
|
||||
|
||||
const handleSubmit = async (values) => {
|
||||
// oxlint-disable-next-line @typescript-eslint/await-thenable - this dispatch() IS returning a promise.
|
||||
await dispatch(updateCourseCertificate(courseId, values));
|
||||
setEditModes({});
|
||||
dispatch(setMode(MODE_STATES.view));
|
||||
|
||||
@@ -5,17 +5,15 @@ import { prepareCertificatePayload } from '../utils';
|
||||
|
||||
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
|
||||
|
||||
export const getCertificatesApiUrl = (courseId) => `${getApiBaseUrl()}/api/contentstore/v1/certificates/${courseId}`;
|
||||
export const getCertificateApiUrl = (courseId) => `${getApiBaseUrl()}/certificates/${courseId}`;
|
||||
export const getUpdateCertificateApiUrl = (courseId, certificateId) => `${getCertificateApiUrl(courseId)}/${certificateId}`;
|
||||
export const getUpdateCertificateActiveStatusApiUrl = (path) => `${getApiBaseUrl()}${path}`;
|
||||
export const getCertificatesApiUrl = (courseId: string) => `${getApiBaseUrl()}/api/contentstore/v1/certificates/${courseId}`;
|
||||
export const getCertificateApiUrl = (courseId: string) => `${getApiBaseUrl()}/certificates/${courseId}`;
|
||||
export const getUpdateCertificateApiUrl = (courseId: string, certificateId: number) => `${getCertificateApiUrl(courseId)}/${certificateId}`;
|
||||
export const getUpdateCertificateActiveStatusApiUrl = (path: string) => `${getApiBaseUrl()}${path}`;
|
||||
|
||||
/**
|
||||
* Gets certificates for a course.
|
||||
* @param {string} courseId
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function getCertificates(courseId) {
|
||||
export async function getCertificates(courseId: string): Promise<Record<string, any>> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(getCertificatesApiUrl(courseId));
|
||||
|
||||
@@ -24,12 +22,11 @@ export async function getCertificates(courseId) {
|
||||
|
||||
/**
|
||||
* Create course certificate.
|
||||
* @param {string} courseId
|
||||
* @param {object} certificatesData
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
|
||||
export async function createCertificate(courseId, certificatesData) {
|
||||
export async function createCertificate(
|
||||
courseId: string,
|
||||
certificatesData: Record<string, any>,
|
||||
): Promise<Record<string, any>> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(
|
||||
getCertificateApiUrl(courseId),
|
||||
@@ -41,11 +38,11 @@ export async function createCertificate(courseId, certificatesData) {
|
||||
|
||||
/**
|
||||
* Update course certificate.
|
||||
* @param {string} courseId
|
||||
* @param {object} certificateData
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function updateCertificate(courseId, certificateData) {
|
||||
export async function updateCertificate(
|
||||
courseId: string,
|
||||
certificateData: Record<string, any>,
|
||||
): Promise<Record<string, any>> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(
|
||||
getUpdateCertificateApiUrl(courseId, certificateData.id),
|
||||
@@ -57,11 +54,8 @@ export async function updateCertificate(courseId, certificateData) {
|
||||
|
||||
/**
|
||||
* Delete course certificate.
|
||||
* @param {string} courseId
|
||||
* @param {object} certificateId
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function deleteCertificate(courseId, certificateId) {
|
||||
export async function deleteCertificate(courseId: string, certificateId: number): Promise<Record<string, any>> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.delete(
|
||||
getUpdateCertificateApiUrl(courseId, certificateId),
|
||||
@@ -71,11 +65,8 @@ export async function deleteCertificate(courseId, certificateId) {
|
||||
|
||||
/**
|
||||
* Activate/deactivate course certificate.
|
||||
* @param {string} courseId
|
||||
* @param {object} activationStatus
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function updateActiveStatus(path, activationStatus) {
|
||||
export async function updateActiveStatus(path: string, activationStatus: unknown): Promise<Record<string, any>> {
|
||||
const body = {
|
||||
is_active: activationStatus,
|
||||
};
|
||||
@@ -4,7 +4,7 @@ export const MODE_STATES = {
|
||||
view: 'view',
|
||||
editAll: 'edit_all',
|
||||
create: 'create',
|
||||
};
|
||||
} as const;
|
||||
|
||||
export const ACTIVATION_MESSAGES = {
|
||||
activating: 'Activating',
|
||||
@@ -1,22 +0,0 @@
|
||||
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,
|
||||
);
|
||||
25
src/certificates/data/selectors.ts
Normal file
25
src/certificates/data/selectors.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/* 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,
|
||||
);
|
||||
@@ -7,7 +7,7 @@ import { MODE_STATES } from './constants';
|
||||
const slice = createSlice({
|
||||
name: 'certificates',
|
||||
initialState: {
|
||||
certificatesData: {},
|
||||
certificatesData: {} as Record<string, any>,
|
||||
componentMode: MODE_STATES.noModes,
|
||||
loadingStatus: RequestStatus.PENDING,
|
||||
savingStatus: '',
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user