Compare commits

..

1 Commits

Author SHA1 Message Date
Muhammad Faraz Maqsood
f16ccfe9cf fix: use hyperlink instead of Link 2025-04-21 11:10:42 +05:00
1809 changed files with 48085 additions and 77395 deletions

12
.env
View File

@@ -36,18 +36,14 @@ 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_HOME_PAGE_COURSE_API_V2=true
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_content,itembank"
# Fallback in local style files
PARAGON_THEME_URLS={}
COURSE_TEAM_SUPPORT_EMAIL=''
ADMIN_CONSOLE_URL='http://localhost:2025/admin-console'
# TODO: Missing support for ORA2
LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder,openassessment"

View File

@@ -37,9 +37,6 @@ 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=''
@@ -47,10 +44,8 @@ HOTJAR_APP_ID=''
HOTJAR_VERSION=6
HOTJAR_DEBUG=true
INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
ENABLE_HOME_PAGE_COURSE_API_V2=true
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_content,itembank"
# Fallback in local style files
PARAGON_THEME_URLS={}
COURSE_TEAM_SUPPORT_EMAIL=''
ADMIN_CONSOLE_URL='http://localhost:2025/admin-console'
LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder"

View File

@@ -33,14 +33,12 @@ 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_HOME_PAGE_COURSE_API_V2=true
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_content,itembank"
PARAGON_THEME_URLS=
COURSE_TEAM_SUPPORT_EMAIL='support@example.com'
# TODO: Missing support for ORA2
LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder,openassessment"

View File

@@ -14,15 +14,6 @@ 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

View File

@@ -2,37 +2,26 @@
Describe what this pull request changes, and why. Include implications for people using this change.
Design decisions and their rationales should be documented in the repo (docstring / ADR), per
[OEP-19](https://open-edx-proposals.readthedocs.io/en/latest/oep-0019-bp-developer-documentation.html), and can be linked here.
Useful information to include:
- Which user roles will this change impact? Common user roles are "Learner", "Course Author",
- Which edX user roles will this change impact? Common user roles are "Learner", "Course Author",
"Developer", and "Operator".
- Include screenshots for changes to the UI (ideally, both "before" and "after" screenshots, if applicable).
- Provide links to the description of corresponding configuration changes. Remember to correctly annotate these
changes.
## Supporting information
Link to other information about the change, such as GitHub issues, or Discourse discussions.
Link to other information about the change, such as Jira issues, GitHub issues, or Discourse discussions.
Be sure to check they are publicly readable, or if not, repeat the information here.
## Testing instructions
Please provide detailed step-by-step instructions for manually testing this change.
Please provide detailed step-by-step instructions for testing this change.
## Other information
Include anything else that will help reviewers and consumers understand the change.
- Does this change depend on other changes elsewhere?
- Any special concerns or limitations? For example: deprecations, migrations, security, or accessibility.
## Best Practices Checklist
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`).
- [ ] 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.
- [ ] Avoid using `../` in import paths. To import from parent folders, use `@src`, e.g. `import { initializeMocks } from '@src/testUtils';` instead of `from '../../../../testUtils'`
- Any special concerns or limitations? For example: deprecations, migrations, security, or accessibility.

View File

@@ -1,18 +0,0 @@
# Run the workflow that adds new tickets that are labelled "release testing"
# to the org-wide BTR project board
name: Add release testing issues to the BTR project board
on:
issues:
types: [labeled]
# This workflow is triggered when an issue is labeled with 'release testing'.
# It adds the issue to the BTR project and applies the 'needs triage' label
# if it doesn't already have it.
jobs:
handle-release-testing:
uses: openedx/.github/.github/workflows/add-issue-to-btr-project.yml@master
secrets:
GITHUB_APP_ID: ${{ secrets.GRAPHQL_AUTH_APP_ID }}
GITHUB_APP_PRIVATE_KEY: ${{ secrets.GRAPHQL_AUTH_APP_PEM }}

View File

@@ -1,15 +0,0 @@
name: Trigger to add Issue or PR to a Core Contributor project board
on:
issues:
types: [labeled]
pull_request:
types: [labeled]
jobs:
add-to-cc-board:
if: github.event.label.name == 'Core Contributor assignee'
uses: openedx/.github/.github/workflows/add-to-cc-board.yml@master
with:
board_name: cc-frontend-apps
secrets:
projects_access_token: ${{ secrets.PROJECTS_TOKEN }}

View File

@@ -11,13 +11,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
- run: make validate.ci
- name: Archive code coverage results
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: code-coverage-report
path: coverage/*.*
@@ -25,15 +25,13 @@ jobs:
runs-on: ubuntu-latest
needs: tests
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Download code coverage results
uses: actions/download-artifact@v8
uses: actions/download-artifact@v4
with:
pattern: code-coverage-report
path: coverage
merge-multiple: true
name: code-coverage-report
- name: Upload coverage
uses: codecov/codecov-action@v5
uses: codecov/codecov-action@v4
with:
fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }}

2
.nvmrc
View File

@@ -1 +1 @@
24
20

View File

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

2
CODEOWNERS Normal file
View File

@@ -0,0 +1,2 @@
# The following users are the maintainers of all frontend-app-authoring files
* @openedx/2u-tnl

View File

@@ -51,9 +51,7 @@ validate-no-uncommitted-package-lock-changes:
validate:
make validate-no-uncommitted-package-lock-changes
npm run i18n_extract
# We are trying out oxlint. Now that it's been working well for a while with both oxlint and eslint, we have disabled
# 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 lint -- --max-warnings 0
npm run types
npm run test:ci
npm run build

View File

@@ -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 the version of Node found in ``.nvmrc``.
The current version of the micro-frontend build scripts supports node 20.
Using other major versions of node *may* work, but this is unsupported. For
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>`__.
@@ -165,7 +165,21 @@ Feature: New React XBlock Editors
.. image:: ./docs/readme-images/feature-problem-editor.png
New React editors for the HTML, Video, and Problem XBlocks are provided here and are rendered by this MFE instead of by the XBlock's authoring view.
This allows an operator to enable the use of new React editors for the HTML, Video, and Problem XBlocks, all of which are provided here.
Requirements
------------
* ``edx-platform`` Waffle flags:
* ``new_core_editors.use_new_text_editor``: must be enabled for the new HTML Xblock editor to be used in Studio
* ``new_core_editors.use_new_video_editor``: must be enabled for the new Video Xblock editor to be used in Studio
* ``new_core_editors.use_new_problem_editor``: must be enabled for the new Problem Xblock editor to be used in Studio
Feature Description
-------------------
When a corresponding waffle flag is set, upon editing a block in Studio, the view is rendered by this MFE instead of by the XBlock's authoring view. The user remains in Studio.
Feature: New Proctoring Exams View
==================================
@@ -175,6 +189,14 @@ Feature: New Proctoring Exams View
Requirements
------------
* ``edx-platform`` Django settings:
* ``ZENDESK_*``: necessary if automatic ZenDesk ticket creation is desired
* ``edx-platform`` Feature flags:
* ``ENABLE_EXAM_SETTINGS_HTML_VIEW``: this feature flag must be enabled for the link to the settings view to be shown
* `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
@@ -192,12 +214,23 @@ 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
==========================
.. image:: ./docs/readme-images/feature-advanced-settings.png
Requirements
------------
* ``edx-platform`` Waffle flags:
* ``contentstore.new_studio_mfe.use_new_advanced_settings_page``: this feature flag must be enabled for the link to the settings view to be shown. It can be enabled on a per-course basis.
Feature Description
-------------------
In Studio, the "Advanced Settings" page for each enabled course will now be served by this frontend, instead of the UI built into edx-platform. The advanced settings page holds many different settings for the course, such as what features or XBlocks are enabled.
Feature: Files & Uploads
@@ -205,6 +238,16 @@ Feature: Files & Uploads
.. image:: ./docs/readme-images/feature-files-uploads.png
Requirements
------------
* ``edx-platform`` Waffle flags:
* ``contentstore.new_studio_mfe.use_new_files_uploads_page``: this feature flag must be enabled for the link to the Files & Uploads page to go to the MFE. It can be enabled on a per-course basis.
Feature Description
-------------------
In Studio, the "Files & Uploads" page for each enabled course will now be served by this frontend, instead of the UI built into edx-platform. This page allows managing static asset files like PDFs, images, etc. used for the course.
Feature: Course Updates
@@ -212,11 +255,26 @@ Feature: Course Updates
.. image:: ./docs/readme-images/feature-course-updates.png
Requirements
------------
* ``edx-platform`` Waffle flags:
* ``contentstore.new_studio_mfe.use_new_updates_page``: this feature flag must be enabled.
Feature: Import/Export Pages
============================
.. image:: ./docs/readme-images/feature-export.png
Requirements
------------
* ``edx-platform`` Waffle flags:
* ``contentstore.new_studio_mfe.use_new_export_page``: this feature flag will change the CMS to link to the new export page.
* ``contentstore.new_studio_mfe.use_new_import_page``: this feature flag will change the CMS to link to the new import page.
Feature: Tagging/Taxonomy Pages
================================
@@ -234,7 +292,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.
@@ -268,7 +326,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)
@@ -322,20 +380,6 @@ For more information about these options, see the `Getting Help`_ page.
.. _Getting Help: https://openedx.org/community/connect
Legacy Studio
*************
If you would like to use legacy studio for certain features, you can set the following waffle flags in ``edx-platform``:
* ``legacy_studio.text_editor``: loads the legacy HTML Xblock editor when editing a text block
* ``legacy_studio.video_editor``: loads the legacy Video editor when editing a video block
* ``legacy_studio.problem_editor``: loads the legacy Problem editor when editing a problem block
* ``legacy_studio.advanced_settings``: Advanced Settings page
* ``legacy_studio.updates``: Updates page
* ``legacy_studio.export``: Export page
* ``legacy_studio.import``: Import page
* ``legacy_studio.files_uploads``: Files page
* ``legacy_studio.exam_settings``: loads the legacy Exam Settings
License
*******

View File

@@ -14,6 +14,6 @@ metadata:
openedx.org/arch-interest-groups: ""
openedx.org/release: "master"
spec:
owner: user:bradenmacdonald
owner: group:2u-tnl
type: 'website'
lifecycle: 'production'

View File

@@ -10,6 +10,4 @@ coverage:
threshold: 0%
ignore:
- "src/grading-settings/grading-scale/react-ranger.js"
- "src/generic/DraggableList/verticalSortableList.ts"
- "src/container-comparison/data/api.mock.ts"
- "src/index.js"

View File

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

View File

@@ -11,11 +11,9 @@ module.exports = createConfig('jest', {
],
moduleNameMapper: {
'^lodash-es$': 'lodash',
// This alias is for any code in the src directory that wants to avoid '../../' style relative imports:
'^@src/(.*)$': '<rootDir>/src/$1',
// This alias is used for plugins in the plugins/ folder only.
'^CourseAuthoring/(.*)$': '<rootDir>/src/$1',
},
modulePathIgnorePatterns: [
'/src/pages-and-resources/utils.test.jsx',
],
});

9668
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,11 +11,11 @@
],
"scripts": {
"build": "fedx-scripts webpack",
"i18n_extract": "fedx-scripts formatjs extract --include=plugins",
"i18n_extract": "fedx-scripts formatjs extract",
"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 .",
"snapshot": "TZ=UTC fedx-scripts jest --updateSnapshot",
"start": "fedx-scripts webpack-dev-server --progress",
"start:with-theme": "paragon install-theme && npm start && npm install",
"dev": "PUBLIC_PATH=/authoring/ MFE_CONFIG_API_URL='http://localhost:8000/api/mfe_config/v1' fedx-scripts webpack-dev-server --progress --host apps.local.openedx.io",
@@ -34,7 +34,6 @@
},
"dependencies": {
"@codemirror/lang-html": "^6.0.0",
"@codemirror/lang-markdown": "^6.0.0",
"@codemirror/lang-xml": "^6.0.0",
"@codemirror/lint": "^6.2.1",
"@codemirror/state": "^6.0.0",
@@ -44,14 +43,13 @@
"@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.1",
"@edx/frontend-component-footer": "^14.9.0",
"@edx/frontend-component-header": "^8.1.0",
"@edx/browserslist-config": "1.2.0",
"@edx/frontend-component-footer": "^14.3.0",
"@edx/frontend-component-header": "^6.2.0",
"@edx/frontend-enterprise-hotjar": "^7.2.0",
"@edx/frontend-platform": "^8.4.0",
"@edx/openedx-atlas": "^0.7.0",
"@edx/frontend-platform": "^8.3.1",
"@edx/openedx-atlas": "^0.6.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",
@@ -61,65 +59,65 @@
"@openedx-plugins/course-app-teams": "file:plugins/course-apps/teams",
"@openedx-plugins/course-app-wiki": "file:plugins/course-apps/wiki",
"@openedx-plugins/course-app-xpert_unit_summary": "file:plugins/course-apps/xpert_unit_summary",
"@openedx/frontend-build": "^14.6.2",
"@openedx/frontend-plugin-framework": "^1.8.0",
"@openedx/paragon": "^23.5.0",
"@openedx/frontend-build": "^14.3.3",
"@openedx/frontend-plugin-framework": "^1.6.0",
"@openedx/frontend-slot-footer": "^1.2.0",
"@openedx/paragon": "^22.16.0",
"@redux-devtools/extension": "^3.3.0",
"@reduxjs/toolkit": "2.11.2",
"@tanstack/react-query": "5.90.21",
"@tinymce/tinymce-react": "^6.0.0",
"@reduxjs/toolkit": "1.9.7",
"@tanstack/react-query": "4.36.1",
"@tinymce/tinymce-react": "^3.14.0",
"classnames": "2.5.1",
"codemirror": "^6.0.0",
"email-validator": "2.0.4",
"fast-xml-parser": "^5.0.0",
"fast-xml-parser": "^4.0.10",
"file-saver": "^2.0.5",
"formik": "2.4.9",
"formik": "2.4.6",
"frontend-components-tinymce-advanced-plugins": "^1.0.3",
"jszip": "^3.10.1",
"lodash": "4.17.23",
"lodash": "4.17.21",
"meilisearch": "^0.41.0",
"moment": "2.30.1",
"moment-shortformat": "^2.1.0",
"npm": "^10.8.1",
"prop-types": "^15.8.1",
"react": "^18.3.1",
"react-datepicker": "^8.10.0",
"react-datepicker": "^4.13.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": "10.0.1",
"react-router": "6.30.3",
"react-router-dom": "6.30.3",
"react-select": "5.10.2",
"react-responsive": "9.0.2",
"react-router": "6.27.0",
"react-router-dom": "6.27.0",
"react-select": "5.8.0",
"react-textarea-autosize": "^8.5.3",
"react-transition-group": "4.4.5",
"redux": "4.2.1",
"redux": "4.0.5",
"redux-logger": "^3.0.6",
"redux-thunk": "^2.4.1",
"reselect": "^4.1.5",
"start": "^5.1.0",
"tinymce": "^5.10.4",
"universal-cookie": "^8.0.0",
"uuid": "^11.1.0",
"universal-cookie": "^4.0.4",
"uuid": "^3.4.0",
"xmlchecker": "^0.1.0",
"yup": "0.32.11"
"yup": "0.31.1"
},
"devDependencies": {
"@edx/react-unit-test-utils": "^4.0.0",
"@edx/stylelint-config-edx": "2.3.3",
"@edx/typescript-config": "^1.0.1",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@testing-library/user-event": "^14.6.1",
"@types/lodash": "^4.17.17",
"@types/react": "^18",
"@types/react-dom": "^18",
"axios-mock-adapter": "2.1.0",
"@testing-library/user-event": "^13.2.1",
"@types/lodash": "^4.17.7",
"axios-mock-adapter": "1.22.0",
"eslint-import-resolver-webpack": "^0.13.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"
}

View File

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

View File

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

View File

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

View File

@@ -2,12 +2,16 @@ import React from 'react';
import { screen, waitFor } from '@testing-library/react';
import { RequestStatus } from 'CourseAuthoring/data/constants';
import { initializeMocks, render } from 'CourseAuthoring/testUtils';
import { render } from 'CourseAuthoring/pages-and-resources/utils.test';
import LearningAssistantSettings from './Settings';
const onClose = () => { };
describe('Learning Assistant Settings', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('renders', async () => {
const initialState = {
models: {
@@ -34,8 +38,14 @@ describe('Learning Assistant Settings', () => {
},
};
initializeMocks({ initialState });
render(<LearningAssistantSettings onClose={onClose} />);
render(
<LearningAssistantSettings
onClose={onClose}
/>,
{
preloadedState: initialState,
},
);
const toggleDescription = 'Reinforce learning concepts by sharing text-based course content '
+ 'with OpenAI (via API) to power an in-course Learning Assistant. Learners can leave feedback about the quality '

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react';
import { getConfig, getExternalLinkUrl } from '@edx/frontend-platform';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import { getConfig } from '@edx/frontend-platform';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Form, Hyperlink } from '@openedx/paragon';
import PropTypes from 'prop-types';
import AppConfigFormDivider from 'CourseAuthoring/pages-and-resources/discussions/app-config-form/apps/shared/AppConfigFormDivider';
@@ -11,10 +11,10 @@ import LiveCommonFields from './LiveCommonFields';
import messages from './messages';
const BbbSettings = ({
intl,
values,
setFieldValue,
}) => {
const intl = useIntl();
const [bbbPlan, setBbbPlan] = useState(values.tierType);
useEffect(() => {
@@ -93,7 +93,7 @@ const BbbSettings = ({
<span data-testid="free-plan-message">
{intl.formatMessage(messages.freePlanMessage)}
<Hyperlink
destination={getExternalLinkUrl('https://bigbluebutton.org/privacy-policy/')}
destination="https://bigbluebutton.org/privacy-policy/"
target="_blank"
rel="noopener noreferrer"
showLaunchIcon
@@ -107,10 +107,12 @@ const BbbSettings = ({
)}
</>
</>
);
};
BbbSettings.propTypes = {
intl: intlShape.isRequired,
values: PropTypes.shape({
consumerKey: PropTypes.string,
consumerSecret: PropTypes.string,
@@ -125,4 +127,4 @@ BbbSettings.propTypes = {
setFieldValue: PropTypes.func.isRequired,
};
export default BbbSettings;
export default injectIntl(BbbSettings);

View File

@@ -4,16 +4,21 @@ import {
getByRole,
getAllByRole,
waitForElementToBeRemoved,
initializeMocks,
} from 'CourseAuthoring/testUtils';
} from '@testing-library/react';
import ReactDOM from 'react-dom';
import { Routes, Route, MemoryRouter } from 'react-router-dom';
import { initializeMockApp } from '@edx/frontend-platform';
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider, PageWrap } from '@edx/frontend-platform/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import 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,
@@ -35,20 +40,17 @@ ReactDOM.createPortal = jest.fn(node => node);
const renderComponent = () => {
const wrapper = render(
<CourseAuthoringProvider courseId={courseId}>
<PagesAndResourcesProvider courseId={courseId}>
<LiveSettings onClose={() => {}} />
</PagesAndResourcesProvider>
</CourseAuthoringProvider>,
{
path: liveSettingsUrl,
routerProps: {
initialEntries: [liveSettingsUrl],
},
params: {
courseId,
},
},
<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>,
);
container = wrapper.container;
};
@@ -72,9 +74,16 @@ const mockStore = async ({
describe('BBB Settings', () => {
beforeEach(async () => {
const mocks = initializeMocks({ initialState });
store = mocks.reduxStore;
axiosMock = mocks.axiosMock;
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: false,
roles: [],
},
});
store = initializeStore(initialState);
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
});
test('Plan dropdown to be visible and enabled in UI', async () => {
@@ -115,13 +124,12 @@ describe('BBB Settings', () => {
);
test('free plans message is visible when free plan is selected', async () => {
const user = userEvent.setup();
await mockStore({ emailSharing: true, isFreeTier: true });
renderComponent();
const spinner = getByRole(container, 'status');
await waitForElementToBeRemoved(spinner);
const dropDown = container.querySelector('select[name="tierType"]');
await user.selectOptions(
userEvent.selectOptions(
dropDown,
getByRole(dropDown, 'option', { name: 'Free' }),
);

View File

@@ -1,43 +1,42 @@
import React from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import PropTypes from 'prop-types';
import FormikControl from 'CourseAuthoring/generic/FormikControl';
import messages from './messages';
const LiveCommonFields = ({
intl,
values,
}) => {
const intl = useIntl();
return (
<>
<p className="pb-2">{intl.formatMessage(messages.formInstructions)}</p>
<FormikControl
name="consumerKey"
value={values.consumerKey}
floatingLabel={intl.formatMessage(messages.consumerKey)}
className="pb-1"
type="input"
/>
<FormikControl
name="consumerSecret"
value={values.consumerSecret}
floatingLabel={intl.formatMessage(messages.consumerSecret)}
className="pb-1"
type="password"
/>
<FormikControl
name="launchUrl"
value={values.launchUrl}
floatingLabel={intl.formatMessage(messages.launchUrl)}
className="pb-1"
type="input"
/>
</>
);
};
}) => (
<>
<p className="pb-2">{intl.formatMessage(messages.formInstructions)}</p>
<FormikControl
name="consumerKey"
value={values.consumerKey}
floatingLabel={intl.formatMessage(messages.consumerKey)}
className="pb-1"
type="input"
/>
<FormikControl
name="consumerSecret"
value={values.consumerSecret}
floatingLabel={intl.formatMessage(messages.consumerSecret)}
className="pb-1"
type="password"
/>
<FormikControl
name="launchUrl"
value={values.launchUrl}
floatingLabel={intl.formatMessage(messages.launchUrl)}
className="pb-1"
type="input"
/>
</>
);
LiveCommonFields.propTypes = {
intl: intlShape.isRequired,
values: PropTypes.shape({
consumerKey: PropTypes.string,
consumerSecret: PropTypes.string,
@@ -46,4 +45,4 @@ LiveCommonFields.propTypes = {
}).isRequired,
};
export default LiveCommonFields;
export default injectIntl(LiveCommonFields);

View File

@@ -1,9 +1,8 @@
// oxlint-disable unicorn/no-thenable
import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { camelCase } from 'lodash';
import { Icon } from '@openedx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import PropTypes from 'prop-types';
import * as Yup from 'yup';
import { useNavigate } from 'react-router-dom';
@@ -12,7 +11,6 @@ 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';
@@ -22,12 +20,12 @@ import ZoomSettings from './ZoomSettings';
import BBBSettings from './BBBSettings';
const LiveSettings = ({
intl,
onClose,
}) => {
const intl = useIntl();
const navigate = useNavigate();
const dispatch = useDispatch();
const { courseId } = useCourseAuthoringContext();
const courseId = useSelector(state => state.courseDetail.courseId);
const availableProviders = useSelector((state) => state.live.appIds);
const {
piiSharingAllowed, selectedAppId, enabled, status,
@@ -73,7 +71,6 @@ 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));
};
@@ -133,7 +130,8 @@ const LiveSettings = ({
};
LiveSettings.propTypes = {
intl: intlShape.isRequired,
onClose: PropTypes.func.isRequired,
};
export default LiveSettings;
export default injectIntl(LiveSettings);

View File

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

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import PropTypes from 'prop-types';
import FormikControl from 'CourseAuthoring/generic/FormikControl';
@@ -8,38 +8,37 @@ import { providerNames } from './constants';
import LiveCommonFields from './LiveCommonFields';
const ZoomSettings = ({
intl,
values,
}) => {
const intl = useIntl();
return (
// eslint-disable-next-line react/jsx-no-useless-fragment
<>
{!values.piiSharingEnable ? (
<p data-testid="request-pii-sharing">
{intl.formatMessage(messages.requestPiiSharingEnable, { provider: providerNames[values.provider] })}
</p>
) : (
<>
{(values.piiSharingEmail || values.piiSharingUsername)
&& (
<p data-testid="helper-text">
{intl.formatMessage(messages.providerHelperText, { providerName: providerNames[values.provider] })}
</p>
)}
<LiveCommonFields values={values} />
<FormikControl
name="launchEmail"
value={values.launchEmail}
floatingLabel={intl.formatMessage(messages.launchEmail)}
type="input"
/>
</>
)}
</>
);
};
}) => (
// eslint-disable-next-line react/jsx-no-useless-fragment
<>
{!values.piiSharingEnable ? (
<p data-testid="request-pii-sharing">
{intl.formatMessage(messages.requestPiiSharingEnable, { provider: providerNames[values.provider] })}
</p>
) : (
<>
{(values.piiSharingEmail || values.piiSharingUsername)
&& (
<p data-testid="helper-text">
{intl.formatMessage(messages.providerHelperText, { providerName: providerNames[values.provider] })}
</p>
)}
<LiveCommonFields values={values} />
<FormikControl
name="launchEmail"
value={values.launchEmail}
floatingLabel={intl.formatMessage(messages.launchEmail)}
type="input"
/>
</>
)}
</>
);
ZoomSettings.propTypes = {
intl: intlShape.isRequired,
values: PropTypes.shape({
consumerKey: PropTypes.string,
consumerSecret: PropTypes.string,
@@ -52,4 +51,4 @@ ZoomSettings.propTypes = {
}).isRequired,
};
export default ZoomSettings;
export default injectIntl(ZoomSettings);

View File

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

View File

@@ -48,9 +48,8 @@ const ORASettings = ({ onClose }) => {
event.preventDefault();
success = success && await handleSettingsSave(formValues);
setSaveError(!success);
await 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: {

View File

@@ -125,13 +125,10 @@ describe('ORASettings', () => {
});
it('Displays title, helper text and badge when flexible peer grading button is enabled', async () => {
await mockStore({ apiStatus: 200, enabled: true });
renderComponent();
await mockStore({ apiStatus: 200, enabled: true });
const checkbox = screen.getByRole('checkbox', { name: /Flex Peer Grading/ });
expect(checkbox).toBeChecked();
await waitFor(() => {
waitFor(() => {
const label = screen.getByText(messages.enableFlexPeerGradeLabel.defaultMessage);
const enableBadge = screen.getByTestId('enable-badge');

View File

@@ -8,7 +8,7 @@ import PropTypes from 'prop-types';
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
import {
ActionRow, Alert, Badge, Form, Hyperlink, ModalDialog, StatefulButton,
} from '@openedx/paragon';
@@ -22,17 +22,16 @@ 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';
const ProctoringSettings = ({ onClose }) => {
const intl = useIntl();
const ProctoringSettings = ({ intl, onClose }) => {
const initialFormValues = {
enableProctoredExams: false,
proctoringProvider: false,
escalationEmail: '',
allowOptingOut: false,
createZendeskTickets: false,
};
const [formValues, setFormValues] = useState(initialFormValues);
const [loading, setLoading] = useState(true);
@@ -41,7 +40,6 @@ 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);
@@ -66,7 +64,7 @@ const ProctoringSettings = ({ onClose }) => {
}
const { courseId } = useContext(PagesAndResourcesContext);
const { courseDetails } = useCourseAuthoringContext();
const courseDetails = useModel('courseDetails', courseId);
const org = courseDetails?.org;
const appInfo = useModel('courseApps', 'proctoring');
const alertRef = React.createRef();
@@ -79,14 +77,18 @@ const ProctoringSettings = ({ onClose }) => {
const value = target.type === 'checkbox' ? target.checked : target.value;
const { name } = target;
if (['allowOptingOut'].includes(name)) {
if (['allowOptingOut', 'createZendeskTickets'].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 (requiresEscalationEmailProviders.includes(value)) {
setFormValues({ ...newFormValues });
if (value === 'proctortrack') {
setFormValues({ ...newFormValues, createZendeskTickets: false });
setShowEscalationEmail(true);
} else if (value === 'software_secure') {
setFormValues({ ...newFormValues, createZendeskTickets: true });
setShowEscalationEmail(false);
} else if (isLtiProvider(value)) {
setFormValues(newFormValues);
setShowEscalationEmail(true);
@@ -113,13 +115,14 @@ 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 (requiresEscalationEmailProviders.includes(formValues.proctoringProvider)) {
if (formValues.proctoringProvider === 'proctortrack') {
studioDataToPostBack.proctored_exam_settings.proctoring_escalation_email = formValues.escalationEmail === '' ? null : formValues.escalationEmail;
}
@@ -156,7 +159,7 @@ const ProctoringSettings = ({ onClose }) => {
event.preventDefault();
const isLtiProviderSelected = isLtiProvider(formValues.proctoringProvider);
if (
(requiresEscalationEmailProviders.includes(formValues.proctoringProvider) || isLtiProviderSelected)
(formValues.proctoringProvider === 'proctortrack' || isLtiProviderSelected)
&& !EmailValidator.validate(formValues.escalationEmail)
&& !(formValues.escalationEmail === '' && !formValues.enableProctoredExams)
) {
@@ -383,6 +386,29 @@ 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>
)}
</>
);
}
@@ -500,7 +526,6 @@ 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.
@@ -528,11 +553,10 @@ const ProctoringSettings = ({ onClose }) => {
selectedProvider = proctoredExamSettings.proctoring_provider;
}
const requiresEscalationEmailProvidersList = settingsResponse.data.requires_escalation_email_providers;
const isEscalationEmailRequired = requiresEscalationEmailProvidersList.includes(selectedProvider);
const isProctortrack = selectedProvider === 'proctortrack';
const ltiProviderSelected = proctoringProvidersLti.some(p => p.name === selectedProvider);
if (isEscalationEmailRequired || ltiProviderSelected) {
if (isProctortrack || ltiProviderSelected) {
setShowEscalationEmail(true);
}
@@ -545,6 +569,7 @@ 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.
@@ -627,9 +652,10 @@ const ProctoringSettings = ({ onClose }) => {
};
ProctoringSettings.propTypes = {
intl: intlShape.isRequired,
onClose: PropTypes.func.isRequired,
};
ProctoringSettings.defaultProps = {};
export default ProctoringSettings;
export default injectIntl(ProctoringSettings);

View File

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

View File

@@ -1,6 +1,11 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'authoring.proctoring.alert.error': {
id: 'authoring.proctoring.alert.error',
defaultMessage: 'We encountered a technical error while trying to save proctored exam settings. This might be a temporary issue, so please try again in a few minutes. If the problem persists, please go to the {support_link} for help.',
description: 'Alert message for proctoring settings save error.',
},
'authoring.proctoring.alert.forbidden': {
id: 'authoring.proctoring.alert.forbidden',
defaultMessage: 'You do not have permission to edit proctored exam settings for this course. If you are a course team member and this problem persists, please go to the {support_link} for help.',
@@ -81,6 +86,11 @@ 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.',

View File

@@ -1,4 +1,4 @@
import { useIntl } from '@edx/frontend-platform/i18n';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import PropTypes from 'prop-types';
import React from 'react';
import * as Yup from 'yup';
@@ -8,8 +8,7 @@ import { useAppSetting } from 'CourseAuthoring/utils';
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
import messages from './messages';
const ProgressSettings = ({ onClose }) => {
const intl = useIntl();
const ProgressSettings = ({ intl, onClose }) => {
const [disableProgressGraph, saveSetting] = useAppSetting('disableProgressGraph');
const showProgressGraphSetting = getConfig().ENABLE_PROGRESS_GRAPH_SETTINGS.toString().toLowerCase() === 'true';
@@ -49,7 +48,8 @@ const ProgressSettings = ({ onClose }) => {
};
ProgressSettings.propTypes = {
intl: intlShape.isRequired,
onClose: PropTypes.func.isRequired,
};
export default ProgressSettings;
export default injectIntl(ProgressSettings);

View File

@@ -1,4 +1,4 @@
import { useIntl } from '@edx/frontend-platform/i18n';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button, Form, TransitionReplace } from '@openedx/paragon';
import PropTypes from 'prop-types';
import React, { useState } from 'react';
@@ -30,9 +30,8 @@ const TeamTypeNameMessage = {
};
const GroupEditor = ({
group, onDelete, onChange, onBlur, fieldNameCommonBase, errors,
intl, group, onDelete, onChange, onBlur, fieldNameCommonBase, errors,
}) => {
const intl = useIntl();
const [isDeleting, setDeleting] = useState(false);
const [isOpen, setOpen] = useState(group.id === null);
const initiateDeletion = () => setDeleting(true);
@@ -150,6 +149,7 @@ export const groupShape = PropTypes.shape({
});
GroupEditor.propTypes = {
intl: intlShape.isRequired,
fieldNameCommonBase: PropTypes.string.isRequired,
errors: PropTypes.shape({
name: PropTypes.string,
@@ -170,4 +170,4 @@ GroupEditor.defaultProps = {
},
};
export default GroupEditor;
export default injectIntl(GroupEditor);

View File

@@ -1,4 +1,4 @@
import { useIntl } from '@edx/frontend-platform/i18n';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button, Form } from '@openedx/paragon';
import { Add } from '@openedx/paragon/icons';
@@ -17,9 +17,9 @@ import messages from './messages';
setupYupExtensions();
const TeamSettings = ({
intl,
onClose,
}) => {
const intl = useIntl();
const [teamsConfiguration, saveSettings] = useAppSetting('teamsConfiguration');
const blankNewGroup = {
name: '',
@@ -107,7 +107,6 @@ const TeamSettings = ({
)
.when('enabled', {
is: true,
// oxlint-disable-next-line unicorn/no-thenable
then: Yup.array().min(1),
})
.default([])
@@ -167,7 +166,8 @@ const TeamSettings = ({
};
TeamSettings.propTypes = {
intl: intlShape.isRequired,
onClose: PropTypes.func.isRequired,
};
export default TeamSettings;
export default injectIntl(TeamSettings);

View File

@@ -1,4 +1,4 @@
import { useIntl } from '@edx/frontend-platform/i18n';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import PropTypes from 'prop-types';
import React from 'react';
import * as Yup from 'yup';
@@ -8,8 +8,7 @@ import { useAppSetting } from 'CourseAuthoring/utils';
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
import messages from './messages';
const WikiSettings = ({ onClose }) => {
const intl = useIntl();
const WikiSettings = ({ intl, onClose }) => {
const [enablePublicWiki, saveSetting] = useAppSetting('allowPublicWikiAccess');
const handleSettingsSave = (values) => saveSetting(values.enablePublicWiki);
@@ -33,7 +32,7 @@ const WikiSettings = ({ onClose }) => {
label={intl.formatMessage(messages.enablePublicWikiLabel)}
helpText={intl.formatMessage(messages.enablePublicWikiHelp)}
onChange={handleChange}
onBlur={handleBlur}
onBlue={handleBlur}
checked={values.enablePublicWiki}
/>
)
@@ -43,7 +42,8 @@ const WikiSettings = ({ onClose }) => {
};
WikiSettings.propTypes = {
intl: intlShape.isRequired,
onClose: PropTypes.func.isRequired,
};
export default WikiSettings;
export default injectIntl(WikiSettings);

View File

@@ -1,6 +1,7 @@
import React, { useCallback, useContext, useEffect } from 'react';
import { useDispatch } from 'react-redux';
import { useIntl } from '@edx/frontend-platform/i18n';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { PagesAndResourcesContext } from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
import { useNavigate } from 'react-router-dom';
@@ -9,8 +10,7 @@ import messages from './messages';
import { fetchXpertSettings } from './data/thunks';
const XpertUnitSummarySettings = () => {
const intl = useIntl();
const XpertUnitSummarySettings = ({ intl }) => {
const { path: pagesAndResourcesPath, courseId } = useContext(PagesAndResourcesContext);
const dispatch = useDispatch();
const navigate = useNavigate();
@@ -38,4 +38,8 @@ const XpertUnitSummarySettings = () => {
);
};
export default XpertUnitSummarySettings;
XpertUnitSummarySettings.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(XpertUnitSummarySettings);

View File

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

View File

@@ -1,5 +1,4 @@
import { useIntl } from '@edx/frontend-platform/i18n';
import { getExternalLinkUrl } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import {
ActionRow,
Alert,
@@ -71,40 +70,38 @@ AppSettingsForm.defaultProps = {
};
const SettingsModalBase = ({
title, onClose, variant, isMobile, children, footer,
}) => {
const intl = useIntl();
return (
<ModalDialog
title={title}
isOpen
onClose={onClose}
size="lg"
variant={variant}
hasCloseButton={isMobile}
isFullscreenOnMobile
>
<ModalDialog.Header>
<ModalDialog.Title data-testid="modal-title">
{title}
</ModalDialog.Title>
</ModalDialog.Header>
<ModalDialog.Body>
{children}
</ModalDialog.Body>
<ModalDialog.Footer className="p-4">
<ActionRow>
<ModalDialog.CloseButton variant="tertiary">
{intl.formatMessage(messages.cancel)}
</ModalDialog.CloseButton>
{footer}
</ActionRow>
</ModalDialog.Footer>
</ModalDialog>
);
};
intl, title, onClose, variant, isMobile, children, footer,
}) => (
<ModalDialog
title={title}
isOpen
onClose={onClose}
size="lg"
variant={variant}
hasCloseButton={isMobile}
isFullscreenOnMobile
>
<ModalDialog.Header>
<ModalDialog.Title data-testid="modal-title">
{title}
</ModalDialog.Title>
</ModalDialog.Header>
<ModalDialog.Body>
{children}
</ModalDialog.Body>
<ModalDialog.Footer className="p-4">
<ActionRow>
<ModalDialog.CloseButton variant="tertiary">
{intl.formatMessage(messages.cancel)}
</ModalDialog.CloseButton>
{footer}
</ActionRow>
</ModalDialog.Footer>
</ModalDialog>
);
SettingsModalBase.propTypes = {
intl: intlShape.isRequired,
title: PropTypes.string.isRequired,
onClose: PropTypes.func.isRequired,
variant: PropTypes.oneOf(['default', 'dark']).isRequired,
@@ -118,11 +115,11 @@ SettingsModalBase.defaultProps = {
};
const ResetUnitsButton = ({
intl,
courseId,
checked,
visible,
}) => {
const intl = useIntl();
const resetStatusRequestStatus = useSelector(getResetStatus);
const dispatch = useDispatch();
@@ -188,6 +185,7 @@ const ResetUnitsButton = ({
};
ResetUnitsButton.propTypes = {
intl: intlShape.isRequired,
courseId: PropTypes.string.isRequired,
checked: PropTypes.oneOf(['true', 'false']).isRequired,
visible: PropTypes.bool,
@@ -198,6 +196,7 @@ ResetUnitsButton.defaultProps = {
};
const SettingsModal = ({
intl,
appId,
title,
children,
@@ -214,7 +213,6 @@ const SettingsModal = ({
allUnitsEnabledText,
noUnitsEnabledText,
}) => {
const intl = useIntl();
const { courseId } = useContext(PagesAndResourcesContext);
const loadingStatus = useSelector(getLoadingStatus);
const updateSettingsRequestStatus = useSelector(getSavingStatus);
@@ -239,10 +237,8 @@ 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));
}
@@ -279,7 +275,7 @@ const SettingsModal = ({
<div className="py-1">
<Hyperlink
className="text-primary-500"
destination={getExternalLinkUrl('https://openai.com/api-data-privacy')}
destination="https://openai.com/api-data-privacy"
target="_blank"
rel="noreferrer noopener"
>
@@ -376,6 +372,7 @@ const SettingsModal = ({
>
{allUnitsEnabledText}
<ResetUnitsButton
intl={intl}
courseId={courseId}
checked={formikProps.values.checked}
visible={formikProps.values.checked === 'true'}
@@ -388,6 +385,7 @@ const SettingsModal = ({
>
{noUnitsEnabledText}
<ResetUnitsButton
intl={intl}
courseId={courseId}
checked={formikProps.values.checked}
visible={formikProps.values.checked === 'false'}
@@ -425,6 +423,7 @@ const SettingsModal = ({
};
SettingsModal.propTypes = {
intl: intlShape.isRequired,
title: PropTypes.string.isRequired,
appId: PropTypes.string.isRequired,
children: PropTypes.func,
@@ -451,4 +450,4 @@ SettingsModal.defaultProps = {
enableReinitialize: false,
};
export default SettingsModal;
export default injectIntl(SettingsModal);

View File

@@ -1,4 +1,5 @@
@import "~@openedx/paragon/styles/scss/core/utilities-only";
@import "~@edx/brand/paragon/variables";
@import "~@openedx/paragon/scss/core/utilities-only";
.summary-radio {
display: flex;

View File

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

View File

@@ -1,36 +1,41 @@
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import {
useLocation,
} from 'react-router-dom';
import { StudioFooterSlot } from '@edx/frontend-component-footer';
import { StudioFooterSlot } from '@openedx/frontend-slot-footer';
import Header from './header';
import { fetchCourseDetail, fetchWaffleFlags } 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';
interface Props {
children?: React.ReactNode;
}
const CourseAuthoringPage = ({ children }: Props) => {
const CourseAuthoringPage = ({ courseId, children }) => {
const dispatch = useDispatch();
useEffect(() => {
dispatch(fetchCourseDetail(courseId));
dispatch(fetchWaffleFlags(courseId));
}, [courseId]);
useEffect(() => {
dispatch(fetchOnlyStudioHomeData());
}, []);
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 courseDetail = useModel('courseDetails', courseId);
const courseNumber = courseDetail ? courseDetail.number : null;
const courseOrg = courseDetail ? courseDetail.org : null;
const courseTitle = courseDetail ? courseDetail.name : courseId;
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');
@@ -57,9 +62,6 @@ const CourseAuthoringPage = ({ children }: Props) => {
org={courseOrg}
title={courseTitle}
contextId={courseId}
containerProps={{
size: 'fluid',
}}
/>
)
)}
@@ -69,4 +71,13 @@ const CourseAuthoringPage = ({ children }: Props) => {
);
};
CourseAuthoringPage.propTypes = {
children: PropTypes.node,
courseId: PropTypes.string.isRequired,
};
CourseAuthoringPage.defaultProps = {
children: null,
};
export default CourseAuthoringPage;

View File

@@ -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, fetchWaffleFlags } 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,12 +19,6 @@ 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;
@@ -32,6 +26,7 @@ beforeEach(async () => {
axiosMock
.onGet(getApiWaffleFlagsUrl(courseId))
.reply(200, {});
await executeThunk(fetchWaffleFlags(courseId), store.dispatch);
});
describe('Editor Pages Load no header', () => {
@@ -41,13 +36,14 @@ 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 = renderComponent(
<CourseAuthoringPage>
<PagesAndResources />
const wrapper = render(
<CourseAuthoringPage courseId={courseId}>
<PagesAndResources courseId={courseId} />
</CourseAuthoringPage>
,
);
@@ -56,9 +52,9 @@ describe('Editor Pages Load no header', () => {
test('renders loading wheel on non editor pages', async () => {
mockPathname = '/evilguy/';
await mockStoreSuccess();
const wrapper = renderComponent(
<CourseAuthoringPage>
<PagesAndResources />
const wrapper = render(
<CourseAuthoringPage courseId={courseId}>
<PagesAndResources courseId={courseId} />
</CourseAuthoringPage>
,
);
@@ -75,6 +71,7 @@ describe('Course authoring page', () => {
).reply(404, {
response: { status: 404 },
});
await executeThunk(fetchCourseDetail(courseId), store.dispatch);
};
const mockStoreError = async () => {
axiosMock.onGet(
@@ -82,10 +79,11 @@ 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 = renderComponent(<CourseAuthoringPage />);
const wrapper = render(<CourseAuthoringPage courseId={courseId} />);
expect(await wrapper.findByTestId('notFoundAlert')).toBeInTheDocument();
});
test('does not render not found page on other kinds of error', async () => {
@@ -95,8 +93,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 = renderComponent(
<CourseAuthoringPage>
const wrapper = render(
<CourseAuthoringPage courseId={courseId}>
<div data-testid={contentTestId} />
</CourseAuthoringPage>
,
@@ -104,20 +102,4 @@ describe('Course authoring page', () => {
expect(await wrapper.findByTestId(contentTestId)).toBeInTheDocument();
expect(wrapper.queryByTestId('notFoundAlert')).not.toBeInTheDocument();
});
const mockStoreDenied = async () => {
const studioApiBaseUrl = getConfig().STUDIO_BASE_URL;
const courseAppsApiUrl = `${studioApiBaseUrl}/api/course_apps/v1/apps`;
axiosMock.onGet(
`${courseAppsApiUrl}/${courseId}`,
).reply(403);
await executeThunk(fetchCourseApps(courseId), store.dispatch);
};
test('renders PermissionDeniedAlert when courseAppsApiStatus is DENIED', async () => {
mockPathname = '/editor/';
await mockStoreDenied();
const wrapper = renderComponent(<CourseAuthoringPage />);
expect(await wrapper.findByTestId('permissionDeniedAlert')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,149 @@
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 'CourseAuthoring/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 } 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>}
/>
{DECODED_ROUTES.COURSE_UNIT.map((path) => (
<Route
key={path}
path={path}
element={<PageWrap><IframeProvider><CourseUnit courseId={courseId} /></IframeProvider></PageWrap>}
/>
))}
<Route
path="editor/course-videos/:blockId"
element={<PageWrap><VideoSelectorContainer courseId={courseId} /></PageWrap>}
/>
<Route
path="editor/:blockType/:blockId?"
element={<PageWrap><EditorContainer learningContextId={courseId} /></PageWrap>}
/>
<Route
path="settings/details"
element={<PageWrap><ScheduleAndDetails courseId={courseId} /></PageWrap>}
/>
<Route
path="settings/grading"
element={<PageWrap><GradingSettings courseId={courseId} /></PageWrap>}
/>
<Route
path="course_team"
element={<PageWrap><CourseTeam courseId={courseId} /></PageWrap>}
/>
<Route
path="group_configurations"
element={<PageWrap><GroupConfigurations courseId={courseId} /></PageWrap>}
/>
<Route
path="settings/advanced"
element={<PageWrap><AdvancedSettings courseId={courseId} /></PageWrap>}
/>
<Route
path="import"
element={<PageWrap><CourseImportPage courseId={courseId} /></PageWrap>}
/>
<Route
path="export"
element={<PageWrap><CourseExportPage courseId={courseId} /></PageWrap>}
/>
<Route
path="optimizer"
element={<PageWrap><CourseOptimizerPage courseId={courseId} /></PageWrap>}
/>
<Route
path="checklists"
element={<PageWrap><CourseChecklist courseId={courseId} /></PageWrap>}
/>
<Route
path="certificates"
element={getConfig().ENABLE_CERTIFICATE_PAGE === 'true' ? <PageWrap><Certificates courseId={courseId} /></PageWrap> : null}
/>
<Route
path="textbooks"
element={<PageWrap><Textbooks courseId={courseId} /></PageWrap>}
/>
</Routes>
</CourseAuthoringPage>
);
};
export default CourseAuthoringRoutes;

View File

@@ -1,5 +1,7 @@
import CourseAuthoringRoutes from './CourseAuthoringRoutes';
import { executeThunk } from './utils';
import { getApiWaffleFlagsUrl } from './data/api';
import { fetchWaffleFlags } from './data/thunks';
import {
screen, initializeMocks, render, waitFor,
} from './testUtils';
@@ -9,6 +11,7 @@ const pagesAndResourcesMockText = 'Pages And Resources';
const editorContainerMockText = 'Editor Container';
const videoSelectorContainerMockText = 'Video Selector Container';
const customPagesMockText = 'Custom Pages';
let store;
const mockComponentFn = jest.fn();
jest.mock('react-router-dom', () => ({
@@ -48,14 +51,12 @@ jest.mock('./custom-pages/CustomPages', () => (props) => {
describe('<CourseAuthoringRoutes>', () => {
beforeEach(async () => {
const user = {
userId: 1,
username: 'username',
};
const { axiosMock } = initializeMocks({ user });
const { axiosMock, reduxStore } = initializeMocks();
store = reduxStore;
axiosMock
.onGet(getApiWaffleFlagsUrl(courseId))
.reply(200, {});
await executeThunk(fetchWaffleFlags(courseId), store.dispatch);
});
it('renders the PagesAndResources component when the pages and resources route is active', async () => {
@@ -65,7 +66,11 @@ describe('<CourseAuthoringRoutes>', () => {
);
await waitFor(() => {
expect(screen.getByText(pagesAndResourcesMockText)).toBeVisible();
expect(mockComponentFn).toHaveBeenCalled();
expect(mockComponentFn).toHaveBeenCalledWith(
expect.objectContaining({
courseId,
}),
);
});
});
@@ -93,7 +98,11 @@ describe('<CourseAuthoringRoutes>', () => {
await waitFor(() => {
expect(screen.queryByText(videoSelectorContainerMockText)).toBeInTheDocument();
expect(screen.queryByText(pagesAndResourcesMockText)).not.toBeInTheDocument();
expect(mockComponentFn).toHaveBeenCalled();
expect(mockComponentFn).toHaveBeenCalledWith(
expect.objectContaining({
courseId,
}),
);
});
});
});

View File

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

View File

@@ -1,16 +0,0 @@
export default {
content: {
id: 67,
userId: 3,
created: '2024-01-16T13:09:11.540615Z',
purpose: 'clipboard',
status: 'ready',
blockType: 'chapter',
blockTypeDisplay: 'Section',
olxUrl: 'http://localhost:18010/api/content-staging/v1/staged-content/67/olx',
displayName: 'Chapter 1',
},
sourceUsageKey: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@chapter_0270f6de40fc',
sourceContextTitle: 'Demonstration Course',
sourceEditUrl: 'http://localhost:18010/container/block-v1:edX+DemoX+Demo_Course+type@chapter+block@chapter_0270f6de40fc',
};

View File

@@ -1,4 +1,3 @@
export { default as clipboardUnit } from './clipboardUnit';
export { default as clipboardSubsection } from './clipboardSubsection';
export { default as clipboardXBlock } from './clipboardXBlock';
export { default as clipboardSection } from './clipboardSection';

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,31 +1,57 @@
import {
initializeMocks,
render,
act,
screen,
} from '@src/testUtils';
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { initializeMockApp } from '@edx/frontend-platform';
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import initializeStore from '../../store';
import { RequestStatus } from '../../data/constants';
import AccessibilityForm from './index';
import { getZendeskrUrl } from '../data/api';
import messages from './messages';
let axiosMock;
let store;
const defaultProps = {
accessibilityEmail: 'accessibilityTest@test.com',
};
const initialState = {
accessibilityPage: {
savingStatus: '',
},
};
const renderComponent = () => {
render(
<AccessibilityForm {...defaultProps} />,
<IntlProvider locale="en">
<AppProvider store={store}>
<AccessibilityForm {...defaultProps} />
</AppProvider>
</IntlProvider>,
);
};
describe('<AccessibilityPolicyForm />', () => {
beforeEach(async () => {
const mocks = initializeMocks();
axiosMock = mocks.axiosMock;
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: false,
roles: [],
},
});
store = initializeStore(initialState);
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
});
describe('renders', () => {
@@ -48,35 +74,24 @@ describe('<AccessibilityPolicyForm />', () => {
describe('statusAlert', () => {
let formSections;
let submitButton;
let user;
beforeEach(async () => {
user = userEvent.setup();
renderComponent();
formSections = screen.getAllByRole('textbox');
await user.type(formSections[0], 'email@email.com');
await user.type(formSections[1], 'test name');
await user.type(formSections[2], 'feedback message');
await act(async () => {
userEvent.type(formSections[0], 'email@email.com');
userEvent.type(formSections[1], 'test name');
userEvent.type(formSections[2], 'feedback message');
});
submitButton = screen.getByText(messages.accessibilityPolicyFormSubmitLabel.defaultMessage);
});
it('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);
await act(async () => {
userEvent.click(submitButton);
});
const { savingStatus } = store.getState().accessibilityPage;
expect(savingStatus).toEqual(RequestStatus.SUCCESSFUL);
expect(screen.getAllByRole('alert')).toHaveLength(1);
@@ -89,8 +104,11 @@ describe('<AccessibilityPolicyForm />', () => {
it('shows correct rate limiting message', async () => {
axiosMock.onPost(getZendeskrUrl()).reply(429);
await user.click(submitButton);
await act(async () => {
userEvent.click(submitButton);
});
const { savingStatus } = store.getState().accessibilityPage;
expect(savingStatus).toEqual(RequestStatus.FAILED);
expect(screen.getAllByRole('alert')).toHaveLength(1);
@@ -105,24 +123,23 @@ describe('<AccessibilityPolicyForm />', () => {
describe('input validation', () => {
let formSections;
let submitButton;
let user;
beforeEach(async () => {
user = userEvent.setup();
renderComponent();
formSections = screen.getAllByRole('textbox');
await user.type(formSections[0], 'email@email.com');
await user.type(formSections[1], 'test name');
await user.type(formSections[2], 'feedback message');
await act(async () => {
userEvent.type(formSections[0], 'email@email.com');
userEvent.type(formSections[1], 'test name');
userEvent.type(formSections[2], 'feedback message');
});
submitButton = screen.getByText(messages.accessibilityPolicyFormSubmitLabel.defaultMessage);
});
it('adds validation checking on each input field', async () => {
await user.clear(formSections[0]);
await user.clear(formSections[1]);
await user.clear(formSections[2]);
await act(async () => {
userEvent.clear(formSections[0]);
userEvent.clear(formSections[1]);
userEvent.clear(formSections[2]);
});
const emailError = screen.getByTestId('error-feedback-email');
expect(emailError).toBeVisible();
@@ -134,10 +151,12 @@ describe('<AccessibilityPolicyForm />', () => {
});
it('sumbit button is disabled when trying to submit with all empty fields', async () => {
await user.clear(formSections[0]);
await user.clear(formSections[1]);
await user.clear(formSections[2]);
await user.click(submitButton);
await act(async () => {
userEvent.clear(formSections[0]);
userEvent.clear(formSections[1]);
userEvent.clear(formSections[2]);
userEvent.click(submitButton);
});
expect(submitButton.closest('button')).toBeDisabled();
});

View File

@@ -1,14 +1,14 @@
import { useEffect, useState } from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useDispatch, useSelector } from 'react-redux';
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: AccessibilityFormData) => {
const intl = useIntl();
const useAccessibility = (initialValues, intl) => {
const dispatch = useDispatch();
const savingStatus = useSelector(state => state.accessibilityPage.savingStatus);
const [isFormFilled, setFormFilled] = useState(false);
const validationSchema = Yup.object().shape({
name: Yup.string().required(
@@ -29,27 +29,29 @@ const useAccessibility = (initialValues: AccessibilityFormData) => {
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,
mutation,
dispatch,
handleBlur,
handleChange,
hasErrorField,
savingStatus: mutation.status,
savingStatus,
};
};

View File

@@ -0,0 +1,42 @@
import React from 'react';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Helmet } from 'react-helmet';
import { Container } from '@openedx/paragon';
import { StudioFooterSlot } from '@openedx/frontend-slot-footer';
import Header from '../header';
import messages from './messages';
import AccessibilityBody from './AccessibilityBody';
import AccessibilityForm from './AccessibilityForm';
const AccessibilityPage = ({
// injected
intl,
}) => {
const communityAccessibilityLink = 'https://www.edx.org/accessibility';
const email = 'accessibility@edx.org';
return (
<>
<Helmet>
<title>
{intl.formatMessage(messages.pageTitle, {
siteName: process.env.SITE_NAME,
})}
</title>
</Helmet>
<Header isHiddenMainMenu />
<Container size="xl" classNamae="px-4">
<AccessibilityBody {...{ email, communityAccessibilityLink }} />
<AccessibilityForm accessibilityEmail={email} />
</Container>
<StudioFooterSlot />
</>
);
};
AccessibilityPage.propTypes = {
// injected
intl: intlShape.isRequired,
};
export default injectIntl(AccessibilityPage);

View File

@@ -0,0 +1,46 @@
import {
render,
screen,
} from '@testing-library/react';
import { AppProvider } from '@edx/frontend-platform/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { initializeMockApp } from '@edx/frontend-platform';
import initializeStore from '../store';
import AccessibilityPage from './index';
const initialState = {
accessibilityPage: {
status: {},
},
};
let store;
const renderComponent = () => {
render(
<IntlProvider locale="en">
<AppProvider store={store}>
<AccessibilityPage />
</AppProvider>
</IntlProvider>,
);
};
describe('<AccessibilityPolicyPage />', () => {
describe('renders', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: false,
roles: [],
},
});
store = initializeStore(initialState);
});
it('contains the policy body', () => {
renderComponent();
expect(screen.getByText('Individualized Accessibility Process for Course Creators')).toBeVisible();
});
});
});

View File

@@ -1,16 +0,0 @@
import { initializeMocks, render, screen } from '../testUtils';
import AccessibilityPage from './index';
const renderComponent = () => render(<AccessibilityPage />);
describe('<AccessibilityPolicyPage />', () => {
describe('renders', () => {
beforeEach(async () => {
initializeMocks();
});
it('contains the policy body', () => {
renderComponent();
expect(screen.getByText('Individualized Accessibility Process for Course Creators')).toBeVisible();
});
});
});

View File

@@ -1,40 +0,0 @@
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';
import Header from '../header';
import messages from './messages';
import AccessibilityBody from './AccessibilityBody';
import AccessibilityForm from './AccessibilityForm';
import { COMMUNITY_ACCESSIBILITY_LINK, ACCESSIBILITY_EMAIL } from './constants';
const AccessibilityPage = () => {
const intl = useIntl();
return (
<>
<Helmet>
<title>
{intl.formatMessage(messages.pageTitle, {
siteName: process.env.SITE_NAME,
})}
</title>
</Helmet>
<Header isHiddenMainMenu />
<Container size="xl" className="px-4">
<AccessibilityBody
{...{
email: ACCESSIBILITY_EMAIL,
communityAccessibilityLink: getExternalLinkUrl(COMMUNITY_ACCESSIBILITY_LINK),
}}
/>
<AccessibilityForm accessibilityEmail={ACCESSIBILITY_EMAIL} />
</Container>
<StudioFooterSlot />
</>
);
};
export default AccessibilityPage;

View File

@@ -1,2 +0,0 @@
export const COMMUNITY_ACCESSIBILITY_LINK = 'https://www.edx.org/accessibility';
export const ACCESSIBILITY_EMAIL = 'accessibility@edx.org';

View File

@@ -8,20 +8,12 @@ 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,
}: AccessibilityFormData) {
export async function postAccessibilityForm({ name, email, message }) {
const data = {
name,
tags: ['studio_a11y'],

View File

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

View File

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

View File

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

View File

@@ -1,73 +1,58 @@
import { useEffect, useState } from 'react';
import { Helmet } from 'react-helmet';
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
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 { 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 { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import Placeholder from '../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 { useCourseAdvancedSettings, useProctoringExamErrors, useUpdateCourseAdvancedSettings } from './data/apiHooks';
import getPageHeadTitle from '../generic/utils';
const AdvancedSettings = () => {
const intl = useIntl();
const AdvancedSettings = ({ intl, courseId }) => {
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 { courseId, courseDetails } = useCourseAuthoringContext();
const courseDetails = useModel('courseDetails', courseId);
document.title = getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.headingTitle));
const waffleFlags = useWaffleFlags(courseId);
const isAuthzEnabled = waffleFlags.enableAuthzCourseAuthoring;
const { isLoading: isLoadingUserPermissions, data: userPermissions } = useUserPermissions({
canManageAdvancedSettings: {
action: COURSE_PERMISSIONS.MANAGE_ADVANCED_SETTINGS,
scope: courseId,
},
}, isAuthzEnabled);
useEffect(() => {
dispatch(fetchCourseAppSettings(courseId));
dispatch(fetchProctoringExamErrors(courseId));
}, [courseId]);
const {
data: advancedSettingsData = {},
isPending: isPendingSettingsStatus,
failureReason: settingsStatusError,
} = useCourseAdvancedSettings(courseId);
const advancedSettingsData = useSelector(getCourseAppSettings);
const savingStatus = useSelector(getSavingStatus);
const proctoringExamErrors = useSelector(getProctoringExamErrors);
const settingsWithSendErrors = useSelector(getSendRequestErrors) || {};
const loadingSettingsStatus = useSelector(getLoadingStatus);
const {
data: proctoringExamErrors = {},
} = useProctoringExamErrors(courseId);
const updateMutation = useUpdateCourseAdvancedSettings(courseId);
const {
isPending: isQueryPending,
isSuccess: isQuerySuccess,
error: queryError,
} = updateMutation;
const isLoading = isPendingSettingsStatus || (isAuthzEnabled && isLoadingUserPermissions);
const isLoading = loadingSettingsStatus === RequestStatus.IN_PROGRESS;
const updateSettingsButtonState = {
labels: {
default: intl.formatMessage(messages.buttonSaveText),
@@ -75,34 +60,30 @@ const AdvancedSettings = () => {
},
disabledStates: ['pending'],
};
const {
proctoringErrors,
mfeProctoredExamSettingsUrl,
} = proctoringExamErrors;
useEffect(() => {
if (isQuerySuccess) {
if (savingStatus === RequestStatus.SUCCESSFUL) {
setIsQueryPending(false);
setShowSuccessAlert(true);
setIsEditableState(false);
setTimeout(() => setShowSuccessAlert(false), 15000);
window.scrollTo({ top: 0, behavior: 'smooth' });
showSaveSettingsPrompt(false);
} else if (queryError && !hasInternetConnectionError) {
// @ts-ignore
setErrorFields(queryError?.response?.data ?? []);
} else if (savingStatus === RequestStatus.FAILED && !hasInternetConnectionError) {
setErrorFields(settingsWithSendErrors);
showErrorModal(true);
}
}, [isQuerySuccess, queryError]);
}, [savingStatus]);
if (isLoading) {
return (
<div className="row justify-content-center m-6">
<LoadingSpinner />
</div>
);
// eslint-disable-next-line react/jsx-no-useless-fragment
return <></>;
}
if (settingsStatusError?.response?.status === 403) {
if (loadingSettingsStatus === RequestStatus.DENIED) {
return (
<div className="row justify-content-center m-6">
<Placeholder />
@@ -124,42 +105,31 @@ const AdvancedSettings = () => {
const handleUpdateAdvancedSettingsData = () => {
const isValid = validateAdvancedSettingsData(editedSettings, setErrorFields, setEditedSettings);
if (isValid) {
setShowSuccessAlert(false);
updateMutation.mutate(parseArrayOrObjectValues(editedSettings));
setIsQueryPending(true);
} 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) && (
@@ -169,11 +139,7 @@ const AdvancedSettings = () => {
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 ? (
@@ -226,8 +192,8 @@ const AdvancedSettings = () => {
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>
@@ -269,8 +235,9 @@ const AdvancedSettings = () => {
<div className="alert-toast">
{isQueryPending && (
<InternetConnectionAlert
isFailed={Boolean(queryError)}
isFailed={savingStatus === RequestStatus.FAILED}
isQueryPending={isQueryPending}
onQueryProcessing={handleQueryProcessing}
onInternetConnectionFailed={handleInternetConnectionFailed}
/>
)}
@@ -281,18 +248,18 @@ const AdvancedSettings = () => {
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((action): action is JSX.Element => action !== null)}
].filter(Boolean)}
variant="warning"
icon={Warning}
title={intl.formatMessage(messages.alertWarning)}
@@ -310,4 +277,9 @@ const AdvancedSettings = () => {
);
};
export default AdvancedSettings;
AdvancedSettings.propTypes = {
intl: intlShape.isRequired,
courseId: PropTypes.string.isRequired,
};
export default injectIntl(AdvancedSettings);

View File

@@ -0,0 +1,164 @@
import React from 'react';
import { initializeMockApp } 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 { render, fireEvent, waitFor } from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import initializeStore from '../store';
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={() => {}}
/>
)));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: () => ({
pathname: mockPathname,
}),
}));
const RootWrapper = () => (
<AppProvider store={store}>
<IntlProvider locale="en" messages={{}}>
<AdvancedSettings intl={injectIntl} courseId={courseId} />
</IntlProvider>
</AppProvider>
);
describe('<AdvancedSettings />', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock
.onGet(`${getCourseAdvancedSettingsApiUrl(courseId)}?fetch_all=0`)
.reply(200, advancedSettingsMock);
});
it('should render without errors', async () => {
const { getByText } = render(<RootWrapper />);
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(<RootWrapper />);
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(<RootWrapper />);
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(<RootWrapper />);
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(<RootWrapper />);
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(<RootWrapper />);
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(<RootWrapper />);
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(<RootWrapper />);
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(<RootWrapper />);
let textarea;
await waitFor(() => {
textarea = getByLabelText(/Advanced Module List/i);
});
fireEvent.change(textarea, { target: { value: '[3, 2, 1]' } });
expect(textarea.value).toBe('[3, 2, 1]');
axiosMock
.onPatch(`${getCourseAdvancedSettingsApiUrl(courseId)}`)
.reply(200, {
...advancedSettingsMock,
advancedModules: {
...advancedSettingsMock.advancedModules,
value: [3, 2, 1],
},
});
fireEvent.click(getByText('Save changes'));
await executeThunk(updateCourseAppSetting(courseId, [3, 2, 1]), store.dispatch);
expect(getByText('Your policy changes have been saved.')).toBeInTheDocument();
});
});

View File

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

View File

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

View File

@@ -0,0 +1,40 @@
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { convertObjectToSnakeCase } from '../../utils';
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
export const getCourseAdvancedSettingsApiUrl = (courseId) => `${getApiBaseUrl()}/api/contentstore/v0/advanced_settings/${courseId}`;
const getProctoringErrorsApiUrl = () => `${getApiBaseUrl()}/api/contentstore/v1/proctoring_errors/`;
/**
* Get's advanced setting for a course.
* @param {string} courseId
* @returns {Promise<Object>}
*/
export async function getCourseAdvancedSettings(courseId) {
const { data } = await getAuthenticatedHttpClient()
.get(`${getCourseAdvancedSettingsApiUrl(courseId)}?fetch_all=0`);
return camelCaseObject(data);
}
/**
* Updates advanced setting for a course.
* @param {string} courseId
* @param {object} settings
* @returns {Promise<Object>}
*/
export async function updateCourseAdvancedSettings(courseId, settings) {
const { data } = await getAuthenticatedHttpClient()
.patch(`${getCourseAdvancedSettingsApiUrl(courseId)}`, convertObjectToSnakeCase(settings));
return camelCaseObject(data);
}
/**
* Gets proctoring exam errors.
* @param {string} courseId
* @returns {Promise<Object>}
*/
export async function getProctoringExamErrors(courseId) {
const { data } = await getAuthenticatedHttpClient().get(`${getProctoringErrorsApiUrl()}${courseId}`);
return camelCaseObject(data);
}

View File

@@ -1,236 +0,0 @@
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import {
getCourseAdvancedSettings,
updateCourseAdvancedSettings,
getProctoringExamErrors,
} from './api';
jest.mock('@edx/frontend-platform/auth', () => ({
getAuthenticatedHttpClient: jest.fn(),
}));
describe('courseSettings API', () => {
const mockHttpClient = {
get: jest.fn(),
patch: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
getAuthenticatedHttpClient.mockReturnValue(mockHttpClient);
});
describe('getCourseAdvancedSettings', () => {
it('should fetch and unformat course advanced settings', async () => {
const fakeData = {
key_snake_case: {
display_name: 'To come camelCase',
testCamelCase: 'This key must not be formatted',
PascalCase: 'To come camelCase',
'kebab-case': 'To come camelCase',
UPPER_CASE: 'To come camelCase',
lowercase: 'This key must not be formatted',
UPPERCASE: 'To come lowercase',
'Title Case': 'To come camelCase',
'dot.case': 'To come camelCase',
SCREAMING_SNAKE_CASE: 'To come camelCase',
MixedCase: 'To come camelCase',
'Train-Case': 'To come camelCase',
nestedOption: {
anotherOption: 'To come camelCase',
},
// value is an object with various cases
// this contain must not be formatted to camelCase
value: {
snake_case: 'snake_case',
camelCase: 'camelCase',
PascalCase: 'PascalCase',
'kebab-case': 'kebab-case',
UPPER_CASE: 'UPPER_CASE',
lowercase: 'lowercase',
UPPERCASE: 'UPPERCASE',
'Title Case': 'Title Case',
'dot.case': 'dot.case',
SCREAMING_SNAKE_CASE: 'SCREAMING_SNAKE_CASE',
MixedCase: 'MixedCase',
'Train-Case': 'Train-Case',
nestedOption: {
anotherOption: 'nestedContent',
},
},
},
};
const expected = {
keySnakeCase: {
displayName: 'To come camelCase',
testCamelCase: 'This key must not be formatted',
pascalCase: 'To come camelCase',
kebabCase: 'To come camelCase',
upperCase: 'To come camelCase',
lowercase: 'This key must not be formatted',
uppercase: 'To come lowercase',
titleCase: 'To come camelCase',
dotCase: 'To come camelCase',
screamingSnakeCase: 'To come camelCase',
mixedCase: 'To come camelCase',
trainCase: 'To come camelCase',
nestedOption: {
anotherOption: 'To come camelCase',
},
value: fakeData.key_snake_case.value,
},
};
mockHttpClient.get.mockResolvedValue({ data: fakeData });
const result = await getCourseAdvancedSettings('course-v1:Test+T101+2024');
expect(mockHttpClient.get).toHaveBeenCalledWith(
`${process.env.STUDIO_BASE_URL}/api/contentstore/v0/advanced_settings/course-v1:Test+T101+2024?fetch_all=0`,
);
expect(result).toEqual(expected);
});
});
describe('updateCourseAdvancedSettings', () => {
it('should update and unformat course advanced settings', async () => {
const fakeData = {
key_snake_case: {
display_name: 'To come camelCase',
testCamelCase: 'This key must not be formatted', // because already be camelCase
PascalCase: 'To come camelCase',
'kebab-case': 'To come camelCase',
UPPER_CASE: 'To come camelCase',
lowercase: 'This key must not be formatted', // because camelCase in lowercase not formatted
UPPERCASE: 'To come lowercase', // because camelCase in UPPERCASE format to lowercase
'Title Case': 'To come camelCase',
'dot.case': 'To come camelCase',
SCREAMING_SNAKE_CASE: 'To come camelCase',
MixedCase: 'To come camelCase',
'Train-Case': 'To come camelCase',
nestedOption: {
anotherOption: 'To come camelCase',
},
// value is an object with various cases
// this contain must not be formatted to camelCase
value: {
snake_case: 'snake_case',
camelCase: 'camelCase',
PascalCase: 'PascalCase',
'kebab-case': 'kebab-case',
UPPER_CASE: 'UPPER_CASE',
lowercase: 'lowercase',
UPPERCASE: 'UPPERCASE',
'Title Case': 'Title Case',
'dot.case': 'dot.case',
SCREAMING_SNAKE_CASE: 'SCREAMING_SNAKE_CASE',
MixedCase: 'MixedCase',
'Train-Case': 'Train-Case',
nestedOption: {
anotherOption: 'nestedContent',
},
},
},
};
const expected = {
keySnakeCase: {
displayName: 'To come camelCase',
testCamelCase: 'This key must not be formatted',
pascalCase: 'To come camelCase',
kebabCase: 'To come camelCase',
upperCase: 'To come camelCase',
lowercase: 'This key must not be formatted',
uppercase: 'To come lowercase',
titleCase: 'To come camelCase',
dotCase: 'To come camelCase',
screamingSnakeCase: 'To come camelCase',
mixedCase: 'To come camelCase',
trainCase: 'To come camelCase',
nestedOption: {
anotherOption: 'To come camelCase',
},
value: fakeData.key_snake_case.value,
},
};
mockHttpClient.patch.mockResolvedValue({ data: fakeData });
const result = await updateCourseAdvancedSettings('course-v1:Test+T101+2024', {});
expect(mockHttpClient.patch).toHaveBeenCalledWith(
`${process.env.STUDIO_BASE_URL}/api/contentstore/v0/advanced_settings/course-v1:Test+T101+2024`,
{},
);
expect(result).toEqual(expected);
});
});
describe('getProctoringExamErrors', () => {
it('should fetch proctoring errors and return unformat object', async () => {
const fakeData = {
key_snake_case: {
display_name: 'To come camelCase',
testCamelCase: 'This key must not be formatted',
PascalCase: 'To come camelCase',
'kebab-case': 'To come camelCase',
UPPER_CASE: 'To come camelCase',
lowercase: 'This key must not be formatted',
UPPERCASE: 'To come lowercase',
'Title Case': 'To come camelCase',
'dot.case': 'To come camelCase',
SCREAMING_SNAKE_CASE: 'To come camelCase',
MixedCase: 'To come camelCase',
'Train-Case': 'To come camelCase',
nestedOption: {
anotherOption: 'To come camelCase',
},
// value is an object with various cases
// this contain must not be formatted to camelCase
value: {
snake_case: 'snake_case',
camelCase: 'camelCase',
PascalCase: 'PascalCase',
'kebab-case': 'kebab-case',
UPPER_CASE: 'UPPER_CASE',
lowercase: 'lowercase',
UPPERCASE: 'UPPERCASE',
'Title Case': 'Title Case',
'dot.case': 'dot.case',
SCREAMING_SNAKE_CASE: 'SCREAMING_SNAKE_CASE',
MixedCase: 'MixedCase',
'Train-Case': 'Train-Case',
nestedOption: {
anotherOption: 'nestedContent',
},
},
},
};
const expected = {
keySnakeCase: {
displayName: 'To come camelCase',
testCamelCase: 'This key must not be formatted',
pascalCase: 'To come camelCase',
kebabCase: 'To come camelCase',
upperCase: 'To come camelCase',
lowercase: 'This key must not be formatted',
uppercase: 'To come lowercase',
titleCase: 'To come camelCase',
dotCase: 'To come camelCase',
screamingSnakeCase: 'To come camelCase',
mixedCase: 'To come camelCase',
trainCase: 'To come camelCase',
nestedOption: {
anotherOption: 'To come camelCase',
},
value: fakeData.key_snake_case.value,
},
};
mockHttpClient.get.mockResolvedValue({ data: fakeData });
const result = await getProctoringExamErrors('course-v1:Test+T101+2024');
expect(mockHttpClient.get).toHaveBeenCalledWith(
`${process.env.STUDIO_BASE_URL}/api/contentstore/v1/proctoring_errors/course-v1:Test+T101+2024`,
);
expect(result).toEqual(expected);
});
});
});

View File

@@ -1,77 +0,0 @@
import {
camelCaseObject,
getConfig,
} from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { camelCase } from 'lodash';
import { convertObjectToSnakeCase } from '@src/utils';
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
export const getCourseAdvancedSettingsApiUrl = (courseId) => `${getApiBaseUrl()}/api/contentstore/v0/advanced_settings/${courseId}`;
const getProctoringErrorsApiUrl = () => `${getApiBaseUrl()}/api/contentstore/v1/proctoring_errors/`;
/**
* Get's advanced setting for a course.
*/
export async function getCourseAdvancedSettings(courseId: string): Promise<Record<string, any>> {
const { data } = await getAuthenticatedHttpClient()
.get(`${getCourseAdvancedSettingsApiUrl(courseId)}?fetch_all=0`);
const keepValues = {};
Object.keys(data).forEach((key) => {
keepValues[camelCase(key)] = { value: data[key].value };
});
const formattedData = {};
const formattedCamelCaseData = camelCaseObject(data);
Object.keys(formattedCamelCaseData).forEach((key) => {
formattedData[key] = {
...formattedCamelCaseData[key],
value: keepValues[key]?.value,
};
});
return formattedData;
}
/**
* Updates advanced setting for a course.
*/
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 = {};
Object.keys(data).forEach((key) => {
keepValues[camelCase(key)] = { value: data[key].value };
});
const formattedData = {};
const formattedCamelCaseData = camelCaseObject(data);
Object.keys(formattedCamelCaseData).forEach((key) => {
formattedData[key] = {
...formattedCamelCaseData[key],
value: keepValues[key]?.value,
};
});
return formattedData;
}
/**
* Gets proctoring exam errors.
*/
export async function getProctoringExamErrors(courseId: string): Promise<Record<string, any>> {
const { data } = await getAuthenticatedHttpClient().get(`${getProctoringErrorsApiUrl()}${courseId}`);
const keepValues = {};
Object.keys(data).forEach((key) => {
keepValues[camelCase(key)] = { value: data[key].value };
});
const formattedData = {};
const formattedCamelCaseData = camelCaseObject(data);
Object.keys(formattedCamelCaseData).forEach((key) => {
formattedData[key] = {
...formattedCamelCaseData[key],
value: keepValues[key]?.value,
};
});
return formattedData;
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,57 +1,55 @@
import React from 'react';
import PropTypes from 'prop-types';
import { ActionRow, AlertModal, Button } from '@openedx/paragon';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import ModalErrorListItem from './ModalErrorListItem';
import messages from './messages';
const ModalError = ({
isError, handleUndoChanges, showErrorModal, errorList, settingsData,
}) => {
const intl = useIntl();
return (
<AlertModal
title={intl.formatMessage(messages.modalErrorTitle)}
isOpen={isError}
variant="danger"
footerNode={(
<ActionRow>
<Button
variant="tertiary"
onClick={() => showErrorModal(!isError)}
>
{intl.formatMessage(messages.modalErrorButtonChangeManually)}
</Button>
<Button onClick={handleUndoChanges}>
{intl.formatMessage(messages.modalErrorButtonUndoChanges)}
</Button>
</ActionRow>
intl, isError, handleUndoChanges, showErrorModal, errorList, settingsData,
}) => (
<AlertModal
title={intl.formatMessage(messages.modalErrorTitle)}
isOpen={isError}
variant="danger"
footerNode={(
<ActionRow>
<Button
variant="tertiary"
onClick={() => showErrorModal(!isError)}
>
{intl.formatMessage(messages.modalErrorButtonChangeManually)}
</Button>
<Button onClick={handleUndoChanges}>
{intl.formatMessage(messages.modalErrorButtonUndoChanges)}
</Button>
</ActionRow>
)}
>
<p>
<FormattedMessage
id="course-authoring.advanced-settings.modal.error.description"
defaultMessage="There was {errorCounter} while trying to save the course settings in the database.
>
<p>
<FormattedMessage
id="course-authoring.advanced-settings.modal.error.description"
defaultMessage="There was {errorCounter} while trying to save the course settings in the database.
Please check the following validation feedbacks and reflect them in your course settings:"
values={{ errorCounter: <strong>{errorList.length} validation error </strong> }}
values={{ errorCounter: <strong>{errorList.length} validation error </strong> }}
/>
</p>
<hr />
<ul className="p-0">
{errorList.map((settingName) => (
<ModalErrorListItem
key={settingName.key}
settingName={settingName}
settingsData={settingsData}
/>
</p>
<hr />
<ul className="p-0">
{errorList.map((settingName) => (
<ModalErrorListItem
key={settingName.key}
settingName={settingName}
settingsData={settingsData}
/>
))}
</ul>
</AlertModal>
);
};
))}
</ul>
</AlertModal>
);
ModalError.propTypes = {
intl: intlShape.isRequired,
isError: PropTypes.bool.isRequired,
handleUndoChanges: PropTypes.func.isRequired,
showErrorModal: PropTypes.func.isRequired,
@@ -62,4 +60,4 @@ ModalError.propTypes = {
settingsData: PropTypes.shape({}).isRequired,
};
export default ModalError;
export default injectIntl(ModalError);

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