Compare commits
2 Commits
master
...
abdullahwa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
beb5f51e47 | ||
|
|
6a115797e6 |
15
.env
15
.env
@@ -1,4 +1,3 @@
|
|||||||
APP_ID='authoring'
|
|
||||||
NODE_ENV='production'
|
NODE_ENV='production'
|
||||||
ACCESS_TOKEN_COOKIE_NAME=''
|
ACCESS_TOKEN_COOKIE_NAME=''
|
||||||
BASE_URL=''
|
BASE_URL=''
|
||||||
@@ -31,23 +30,15 @@ USER_INFO_COOKIE_NAME=''
|
|||||||
ENABLE_ACCESSIBILITY_PAGE=false
|
ENABLE_ACCESSIBILITY_PAGE=false
|
||||||
ENABLE_PROGRESS_GRAPH_SETTINGS=false
|
ENABLE_PROGRESS_GRAPH_SETTINGS=false
|
||||||
ENABLE_TEAM_TYPE_SETTING=false
|
ENABLE_TEAM_TYPE_SETTING=false
|
||||||
|
ENABLE_NEW_EDITOR_PAGES=true
|
||||||
ENABLE_UNIT_PAGE=false
|
ENABLE_UNIT_PAGE=false
|
||||||
ENABLE_ASSETS_PAGE=false
|
ENABLE_ASSETS_PAGE=false
|
||||||
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=false
|
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=false
|
||||||
ENABLE_TAGGING_TAXONOMY_PAGES=true
|
ENABLE_TAGGING_TAXONOMY_PAGES=false
|
||||||
ENABLE_CERTIFICATE_PAGE=true
|
|
||||||
ENABLE_COURSE_IMPORT_IN_LIBRARY=false
|
|
||||||
ENABLE_UNIT_PAGE_NEW_DESIGN=false
|
|
||||||
ENABLE_COURSE_OUTLINE_NEW_DESIGN=false
|
|
||||||
BBB_LEARN_MORE_URL=''
|
BBB_LEARN_MORE_URL=''
|
||||||
HOTJAR_APP_ID=''
|
HOTJAR_APP_ID=''
|
||||||
HOTJAR_VERSION=6
|
HOTJAR_VERSION=6
|
||||||
HOTJAR_DEBUG=false
|
HOTJAR_DEBUG=false
|
||||||
INVITE_STUDENTS_EMAIL_TO=''
|
INVITE_STUDENTS_EMAIL_TO=''
|
||||||
|
AI_TRANSLATIONS_BASE_URL=''
|
||||||
ENABLE_CHECKLIST_QUALITY=''
|
ENABLE_CHECKLIST_QUALITY=''
|
||||||
# "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'
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
APP_ID='authoring'
|
|
||||||
NODE_ENV='development'
|
NODE_ENV='development'
|
||||||
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
|
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
|
||||||
BASE_URL='http://localhost:2001'
|
BASE_URL='http://localhost:2001'
|
||||||
@@ -33,24 +32,15 @@ USER_INFO_COOKIE_NAME='edx-user-info'
|
|||||||
ENABLE_ACCESSIBILITY_PAGE=false
|
ENABLE_ACCESSIBILITY_PAGE=false
|
||||||
ENABLE_PROGRESS_GRAPH_SETTINGS=false
|
ENABLE_PROGRESS_GRAPH_SETTINGS=false
|
||||||
ENABLE_TEAM_TYPE_SETTING=false
|
ENABLE_TEAM_TYPE_SETTING=false
|
||||||
|
ENABLE_NEW_EDITOR_PAGES=true
|
||||||
ENABLE_UNIT_PAGE=false
|
ENABLE_UNIT_PAGE=false
|
||||||
ENABLE_ASSETS_PAGE=false
|
ENABLE_ASSETS_PAGE=false
|
||||||
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=true
|
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=false
|
||||||
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
|
ENABLE_TAGGING_TAXONOMY_PAGES=true
|
||||||
BBB_LEARN_MORE_URL=''
|
BBB_LEARN_MORE_URL=''
|
||||||
HOTJAR_APP_ID=''
|
HOTJAR_APP_ID=''
|
||||||
HOTJAR_VERSION=6
|
HOTJAR_VERSION=6
|
||||||
HOTJAR_DEBUG=true
|
HOTJAR_DEBUG=true
|
||||||
INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
|
INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
|
||||||
|
AI_TRANSLATIONS_BASE_URL='http://localhost:18760'
|
||||||
ENABLE_CHECKLIST_QUALITY=true
|
ENABLE_CHECKLIST_QUALITY=true
|
||||||
# "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'
|
|
||||||
|
|||||||
10
.env.test
10
.env.test
@@ -1,4 +1,3 @@
|
|||||||
APP_ID='authoring'
|
|
||||||
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
|
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
|
||||||
BASE_URL='http://localhost:2001'
|
BASE_URL='http://localhost:2001'
|
||||||
CREDENTIALS_BASE_URL='http://localhost:18150'
|
CREDENTIALS_BASE_URL='http://localhost:18150'
|
||||||
@@ -29,18 +28,11 @@ SUPPORT_URL='https://support.edx.org'
|
|||||||
USER_INFO_COOKIE_NAME='edx-user-info'
|
USER_INFO_COOKIE_NAME='edx-user-info'
|
||||||
ENABLE_PROGRESS_GRAPH_SETTINGS=false
|
ENABLE_PROGRESS_GRAPH_SETTINGS=false
|
||||||
ENABLE_TEAM_TYPE_SETTING=false
|
ENABLE_TEAM_TYPE_SETTING=false
|
||||||
|
ENABLE_NEW_EDITOR_PAGES=true
|
||||||
ENABLE_UNIT_PAGE=true
|
ENABLE_UNIT_PAGE=true
|
||||||
ENABLE_ASSETS_PAGE=false
|
ENABLE_ASSETS_PAGE=false
|
||||||
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=true
|
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=true
|
||||||
ENABLE_CERTIFICATE_PAGE=true
|
|
||||||
ENABLE_COURSE_IMPORT_IN_LIBRARY=true
|
|
||||||
ENABLE_COURSE_OUTLINE_NEW_DESIGN=false
|
|
||||||
ENABLE_UNIT_PAGE_NEW_DESIGN=false
|
|
||||||
ENABLE_TAGGING_TAXONOMY_PAGES=true
|
ENABLE_TAGGING_TAXONOMY_PAGES=true
|
||||||
BBB_LEARN_MORE_URL=''
|
BBB_LEARN_MORE_URL=''
|
||||||
INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
|
INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
|
||||||
ENABLE_CHECKLIST_QUALITY=true
|
ENABLE_CHECKLIST_QUALITY=true
|
||||||
# "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'
|
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
coverage/*
|
coverage/*
|
||||||
dist/
|
dist/
|
||||||
node_modules/
|
node_modules/
|
||||||
jest.config.js
|
jest.config.js
|
||||||
env.config.jsx
|
|
||||||
example.env.config.jsx
|
|
||||||
12
.eslintrc.js
12
.eslintrc.js
@@ -11,18 +11,8 @@ module.exports = createConfig(
|
|||||||
}],
|
}],
|
||||||
'template-curly-spacing': 'off',
|
'template-curly-spacing': 'off',
|
||||||
'react-hooks/exhaustive-deps': 'off',
|
'react-hooks/exhaustive-deps': 'off',
|
||||||
|
indent: ['error', 2],
|
||||||
'no-restricted-exports': 'off',
|
'no-restricted-exports': 'off',
|
||||||
// There is no reason to disallow this syntax anymore; we don't use regenerator-runtime in new browsers
|
|
||||||
'no-restricted-syntax': 'off',
|
|
||||||
'no-restricted-imports': ['error', {
|
|
||||||
patterns: [
|
|
||||||
{
|
|
||||||
group: ['@edx/frontend-platform/i18n'],
|
|
||||||
importNames: ['injectIntl'],
|
|
||||||
message: "Use 'useIntl' hook instead of injectIntl.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}],
|
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
// Import URLs should be resolved using aliases
|
// Import URLs should be resolved using aliases
|
||||||
|
|||||||
7
.github/dependabot.yml
vendored
7
.github/dependabot.yml
vendored
@@ -1,7 +0,0 @@
|
|||||||
version: 2
|
|
||||||
updates:
|
|
||||||
# Adding new check for github-actions
|
|
||||||
- package-ecosystem: "github-actions"
|
|
||||||
directory: "/"
|
|
||||||
schedule:
|
|
||||||
interval: "weekly"
|
|
||||||
25
.github/pull_request_template.md
vendored
25
.github/pull_request_template.md
vendored
@@ -2,37 +2,26 @@
|
|||||||
|
|
||||||
Describe what this pull request changes, and why. Include implications for people using this change.
|
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
|
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:
|
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".
|
"Developer", and "Operator".
|
||||||
- Include screenshots for changes to the UI (ideally, both "before" and "after" screenshots, if applicable).
|
- 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
|
## 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.
|
Be sure to check they are publicly readable, or if not, repeat the information here.
|
||||||
|
|
||||||
## Testing instructions
|
## 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
|
## Other information
|
||||||
|
|
||||||
Include anything else that will help reviewers and consumers understand the change.
|
Include anything else that will help reviewers and consumers understand the change.
|
||||||
- Does this change depend on other changes elsewhere?
|
- Does this change depend on other changes elsewhere?
|
||||||
- Any special concerns or limitations? For example: deprecations, migrations, security, or accessibility.
|
- 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'`
|
|
||||||
18
.github/workflows/add-issue-to-btr-project.yml
vendored
18
.github/workflows/add-issue-to-btr-project.yml
vendored
@@ -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 }}
|
|
||||||
15
.github/workflows/add-to-cc-board.yml
vendored
15
.github/workflows/add-to-cc-board.yml
vendored
@@ -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 }}
|
|
||||||
28
.github/workflows/validate.yml
vendored
28
.github/workflows/validate.yml
vendored
@@ -9,31 +9,15 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
tests:
|
tests:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-node@v6
|
- name: Setup Nodejs Env
|
||||||
|
run: echo "NODE_VER=`cat .nvmrc`" >> $GITHUB_ENV
|
||||||
|
- uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version-file: '.nvmrc'
|
node-version: ${{ env.NODE_VER }}
|
||||||
- run: make validate.ci
|
- run: make validate.ci
|
||||||
- name: Archive code coverage results
|
|
||||||
uses: actions/upload-artifact@v7
|
|
||||||
with:
|
|
||||||
name: code-coverage-report
|
|
||||||
path: coverage/*.*
|
|
||||||
coverage:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: tests
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v6
|
|
||||||
- name: Download code coverage results
|
|
||||||
uses: actions/download-artifact@v8
|
|
||||||
with:
|
|
||||||
pattern: code-coverage-report
|
|
||||||
path: coverage
|
|
||||||
merge-multiple: true
|
|
||||||
- name: Upload coverage
|
- name: Upload coverage
|
||||||
uses: codecov/codecov-action@v5
|
uses: codecov/codecov-action@v3
|
||||||
with:
|
with:
|
||||||
fail_ci_if_error: true
|
fail_ci_if_error: true
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,7 +1,6 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
.eslintcache
|
.eslintcache
|
||||||
.idea
|
.idea
|
||||||
.run
|
|
||||||
node_modules
|
node_modules
|
||||||
npm-debug.log
|
npm-debug.log
|
||||||
coverage
|
coverage
|
||||||
@@ -27,6 +26,3 @@ temp/babel-plugin-react-intl
|
|||||||
|
|
||||||
# Messages .json files fetched by atlas
|
# Messages .json files fetched by atlas
|
||||||
src/i18n/messages/
|
src/i18n/messages/
|
||||||
|
|
||||||
# environment js config
|
|
||||||
env.config.jsx
|
|
||||||
|
|||||||
@@ -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",
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -26,7 +26,6 @@
|
|||||||
"scss/at-rule-no-unknown": true,
|
"scss/at-rule-no-unknown": true,
|
||||||
"scss/at-import-partial-extension": null,
|
"scss/at-import-partial-extension": null,
|
||||||
"scss/comment-no-empty": null,
|
"scss/comment-no-empty": null,
|
||||||
"import-notation": "string",
|
|
||||||
"property-no-unknown": [true, {
|
"property-no-unknown": [true, {
|
||||||
"ignoreProperties": ["xs", "sm", "md", "lg", "xl", "xxl"]
|
"ignoreProperties": ["xs", "sm", "md", "lg", "xl", "xxl"]
|
||||||
}],
|
}],
|
||||||
|
|||||||
2
CODEOWNERS
Normal file
2
CODEOWNERS
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# The following users are the maintainers of all frontend-app-course-authoring files
|
||||||
|
* @openedx/2u-tnl
|
||||||
9
Makefile
9
Makefile
@@ -35,12 +35,13 @@ pull_translations:
|
|||||||
cd src/i18n/messages \
|
cd src/i18n/messages \
|
||||||
&& atlas pull $(ATLAS_OPTIONS) \
|
&& atlas pull $(ATLAS_OPTIONS) \
|
||||||
translations/frontend-component-ai-translations/src/i18n/messages:frontend-component-ai-translations \
|
translations/frontend-component-ai-translations/src/i18n/messages:frontend-component-ai-translations \
|
||||||
|
translations/frontend-lib-content-components/src/i18n/messages:frontend-lib-content-components \
|
||||||
translations/frontend-platform/src/i18n/messages:frontend-platform \
|
translations/frontend-platform/src/i18n/messages:frontend-platform \
|
||||||
translations/paragon/src/i18n/messages:paragon \
|
translations/paragon/src/i18n/messages:paragon \
|
||||||
translations/frontend-component-footer/src/i18n/messages:frontend-component-footer \
|
translations/frontend-component-footer/src/i18n/messages:frontend-component-footer \
|
||||||
translations/frontend-app-course-authoring/src/i18n/messages:frontend-app-course-authoring
|
translations/frontend-app-course-authoring/src/i18n/messages:frontend-app-course-authoring
|
||||||
|
|
||||||
$(intl_imports) frontend-component-ai-translations frontend-platform paragon frontend-component-footer frontend-app-course-authoring
|
$(intl_imports) frontend-component-ai-translations frontend-lib-content-components frontend-platform paragon frontend-component-footer frontend-app-course-authoring
|
||||||
|
|
||||||
# This target is used by Travis.
|
# This target is used by Travis.
|
||||||
validate-no-uncommitted-package-lock-changes:
|
validate-no-uncommitted-package-lock-changes:
|
||||||
@@ -51,11 +52,9 @@ validate-no-uncommitted-package-lock-changes:
|
|||||||
validate:
|
validate:
|
||||||
make validate-no-uncommitted-package-lock-changes
|
make validate-no-uncommitted-package-lock-changes
|
||||||
npm run i18n_extract
|
npm run i18n_extract
|
||||||
# We are trying out oxlint. Now that it's been working well for a while with both oxlint and eslint, we have disabled
|
npm run lint -- --max-warnings 0
|
||||||
# eslint, and after a few weeks we'll evaluate whether any problems are slipping through if only oxlint is used.
|
|
||||||
npm run oxlint
|
|
||||||
npm run types
|
npm run types
|
||||||
npm run test:ci
|
npm run test
|
||||||
npm run build
|
npm run build
|
||||||
|
|
||||||
.PHONY: validate.ci
|
.PHONY: validate.ci
|
||||||
|
|||||||
208
README.rst
208
README.rst
@@ -1,5 +1,5 @@
|
|||||||
frontend-app-authoring
|
frontend-app-course-authoring
|
||||||
######################
|
#############################
|
||||||
|
|
||||||
|license-badge| |status-badge| |codecov-badge|
|
|license-badge| |status-badge| |codecov-badge|
|
||||||
|
|
||||||
@@ -7,9 +7,9 @@ frontend-app-authoring
|
|||||||
Purpose
|
Purpose
|
||||||
*******
|
*******
|
||||||
|
|
||||||
This implements most of the frontend for **Open edX Studio**, allowing authors to create and edit courses, libraries, and their learning components.
|
This is the Course Authoring micro-frontend, currently under development by `2U <https://2u.com>`_.
|
||||||
|
|
||||||
A few parts of Studio still default to the `"legacy" pages defined in edx-platform <https://github.com/openedx/edx-platform/tree/master/cms>`_, but those are rapidly being deprecated and replaced with the React- and Paragon-based pages defined here.
|
Its purpose is to provide both a framework and UI for new or replacement React-based authoring features outside ``edx-platform``. You can find the current set described below.
|
||||||
|
|
||||||
|
|
||||||
Getting Started
|
Getting Started
|
||||||
@@ -18,87 +18,51 @@ Getting Started
|
|||||||
Prerequisites
|
Prerequisites
|
||||||
=============
|
=============
|
||||||
|
|
||||||
`Tutor`_ is currently recommended as a development environment for the Authoring
|
The `devstack`_ is currently recommended as a development environment for your
|
||||||
MFE. Most likely, it already has this MFE configured; however, you'll need to
|
new MFE. If you start it with ``make dev.up.lms`` that should give you
|
||||||
make some changes in order to run it in development mode. You can refer
|
everything you need as a companion to this frontend.
|
||||||
to the `relevant tutor-mfe documentation`_ for details, or follow the quick
|
|
||||||
guide below.
|
Note that it is also possible to use `Tutor`_ to develop an MFE. You can refer
|
||||||
|
to the `relevant tutor-mfe documentation`_ to get started using it.
|
||||||
|
|
||||||
|
.. _Devstack: https://github.com/openedx/devstack
|
||||||
|
|
||||||
.. _Tutor: https://github.com/overhangio/tutor
|
.. _Tutor: https://github.com/overhangio/tutor
|
||||||
|
|
||||||
.. _relevant tutor-mfe documentation: https://github.com/overhangio/tutor-mfe#mfe-development
|
.. _relevant tutor-mfe documentation: https://github.com/overhangio/tutor-mfe#mfe-development
|
||||||
|
|
||||||
|
Configuration
|
||||||
|
=============
|
||||||
|
|
||||||
Cloning and Setup
|
All features that integrate into the edx-platform CMS require that the ``COURSE_AUTHORING_MICROFRONTEND_URL`` Django setting is set in the CMS environment and points to this MFE's deployment URL. This should be done automatically if you are using devstack or tutor-mfe.
|
||||||
=================
|
|
||||||
|
|
||||||
1. Clone your new repo:
|
Cloning and Startup
|
||||||
|
===================
|
||||||
|
|
||||||
.. code-block:: bash
|
|
||||||
|
|
||||||
git clone https://github.com/openedx/frontend-app-authoring.git
|
1. Clone the repo:
|
||||||
|
|
||||||
2. Use the version of Node specified in the ``.nvmrc`` file.
|
``git clone https://github.com/openedx/frontend-app-course-authoring.git``
|
||||||
|
|
||||||
The current version of the micro-frontend build scripts supports the version of Node found in ``.nvmrc``.
|
2. Use node v18.x.
|
||||||
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>`_.
|
|
||||||
|
|
||||||
3. Stop the Tutor devstack, if it's running: ``tutor dev stop``
|
The current version of the micro-frontend build scripts support node 18.
|
||||||
|
Using other major versions of node *may* work, but this is unsupported. For
|
||||||
|
convenience, this repository includes an .nvmrc file to help in setting the
|
||||||
|
correct node version via `nvm use`_.
|
||||||
|
|
||||||
4. Next, we need to tell Tutor that we're going to be running this repo in
|
3. Install npm dependencies:
|
||||||
development mode, and it should be excluded from the ``mfe`` container that
|
|
||||||
otherwise runs every MFE. Run this:
|
|
||||||
|
|
||||||
.. code-block:: bash
|
``cd frontend-app-course-authoring && npm install``
|
||||||
|
|
||||||
tutor mounts add /path/to/frontend-app-authoring
|
|
||||||
|
|
||||||
5. Start Tutor in development mode. This command will start the LMS and Studio,
|
4. Start the dev server:
|
||||||
and other required MFEs like ``authn`` and ``account``, but will not start
|
|
||||||
the Authoring MFE, which we're going to run on the host instead of in a
|
|
||||||
container managed by Tutor. Run:
|
|
||||||
|
|
||||||
.. code-block:: bash
|
``npm start``
|
||||||
|
|
||||||
tutor dev start lms cms mfe
|
|
||||||
|
|
||||||
Startup
|
The dev server is running at `http://localhost:2001 <http://localhost:2001>`_.
|
||||||
=======
|
or whatever port you setup.
|
||||||
|
|
||||||
1. Install npm dependencies:
|
|
||||||
|
|
||||||
.. code-block:: bash
|
|
||||||
|
|
||||||
cd frontend-app-authoring && npm ci
|
|
||||||
|
|
||||||
2. Start the dev server:
|
|
||||||
|
|
||||||
.. code-block:: bash
|
|
||||||
|
|
||||||
npm run dev
|
|
||||||
|
|
||||||
Then you can access the app at http://apps.local.openedx.io:2001/course-authoring/home
|
|
||||||
|
|
||||||
Troubleshooting
|
|
||||||
---------------
|
|
||||||
|
|
||||||
* If you see an "Invalid Host header" error, then you're probably using a different domain name for your devstack such as
|
|
||||||
``local.edly.io`` or ``local.overhang.io`` (not the new recommended default, ``local.openedx.io``). In that case, run
|
|
||||||
these commands to update your devstack's domain names:
|
|
||||||
|
|
||||||
.. code-block:: bash
|
|
||||||
|
|
||||||
tutor dev stop
|
|
||||||
tutor config save --set LMS_HOST=local.openedx.io --set CMS_HOST=studio.local.openedx.io
|
|
||||||
tutor dev launch -I --skip-build
|
|
||||||
tutor dev stop authoring # We will run this MFE on the host
|
|
||||||
|
|
||||||
* If tutor-mfe is not starting the authoring MFE in development mode (eg. `tutor dev start authoring` fails), it may be due to
|
|
||||||
using a tutor version that expects the MFE name to be frontend-app-course-authoring (the previous name of this repo). To fix
|
|
||||||
this, you can rename the cloned repo directory to frontend-app-course-authoring. More information can be found in
|
|
||||||
`this forum post <https://discuss.openedx.org/t/repo-rename-frontend-app-course-authoring-frontend-app-authoring/13930/2>`__.
|
|
||||||
|
|
||||||
|
|
||||||
Features
|
Features
|
||||||
@@ -165,7 +129,32 @@ Feature: New React XBlock Editors
|
|||||||
|
|
||||||
.. image:: ./docs/readme-images/feature-problem-editor.png
|
.. 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
|
||||||
|
|
||||||
|
Configuration
|
||||||
|
-------------
|
||||||
|
|
||||||
|
In additional to the standard settings, the following local configuration item is required:
|
||||||
|
|
||||||
|
* ``ENABLE_NEW_EDITOR_PAGES``: must be enabled in order to actually present the new XBlock editors (on by default)
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
|
||||||
|
The new editors themselves are currently implemented in a repository outside ``openedx``: `frontend-lib-content-components <https://github.com/edx/frontend-lib-content-components/>`_, a dependency of this MFE. This repository is slated to be moved to the ``openedx`` org, however.
|
||||||
|
|
||||||
Feature: New Proctoring Exams View
|
Feature: New Proctoring Exams View
|
||||||
==================================
|
==================================
|
||||||
@@ -175,6 +164,14 @@ Feature: New Proctoring Exams View
|
|||||||
Requirements
|
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
|
* `edx-exams <https://github.com/edx/edx-exams>`_: for this feature to work, the ``edx-exams`` IDA must be deployed and its API accessible by the browser
|
||||||
|
|
||||||
Configuration
|
Configuration
|
||||||
@@ -192,12 +189,23 @@ In Studio, a new item ("Proctored Exam Settings") is added to "Other Course Sett
|
|||||||
* Enable proctored exams for the course
|
* Enable proctored exams for the course
|
||||||
* Allow opting out of proctored exams
|
* Allow opting out of proctored exams
|
||||||
* Select a proctoring provider
|
* Select a proctoring provider
|
||||||
|
* Enable automatic creation of Zendesk tickets for "suspicious" proctored exam attempts
|
||||||
|
|
||||||
Feature: Advanced Settings
|
Feature: Advanced Settings
|
||||||
==========================
|
==========================
|
||||||
|
|
||||||
.. image:: ./docs/readme-images/feature-advanced-settings.png
|
.. 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.
|
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
|
Feature: Files & Uploads
|
||||||
@@ -205,6 +213,16 @@ Feature: Files & Uploads
|
|||||||
|
|
||||||
.. image:: ./docs/readme-images/feature-files-uploads.png
|
.. 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.
|
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
|
Feature: Course Updates
|
||||||
@@ -212,11 +230,26 @@ Feature: Course Updates
|
|||||||
|
|
||||||
.. image:: ./docs/readme-images/feature-course-updates.png
|
.. 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
|
Feature: Import/Export Pages
|
||||||
============================
|
============================
|
||||||
|
|
||||||
.. image:: ./docs/readme-images/feature-export.png
|
.. 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
|
Feature: Tagging/Taxonomy Pages
|
||||||
================================
|
================================
|
||||||
|
|
||||||
@@ -234,30 +267,13 @@ Configuration
|
|||||||
|
|
||||||
In additional to the standard settings, the following local configuration items are required:
|
In additional to the standard settings, the following local configuration items are required:
|
||||||
|
|
||||||
* ``ENABLE_TAGGING_TAXONOMY_PAGES``: must be enabled (which it is by default) in order to actually enable/show the new
|
* ``ENABLE_TAGGING_TAXONOMY_PAGES``: must be enabled in order to actually present the new Tagging/Taxonomy pages.
|
||||||
Tagging/Taxonomy functionality.
|
|
||||||
|
|
||||||
|
|
||||||
Feature: Libraries V2/Legacy Tabs
|
|
||||||
=================================
|
|
||||||
|
|
||||||
Configuration
|
|
||||||
-------------
|
|
||||||
|
|
||||||
In additional to the standard settings, the following local configurations can be set to switch between different library modes:
|
|
||||||
|
|
||||||
* ``MEILISEARCH_ENABLED``: Studio setting which is enabled when the `meilisearch plugin`_ is installed.
|
|
||||||
* ``edx-platform`` Waffle flags:
|
|
||||||
|
|
||||||
* ``contentstore.new_studio_mfe.disable_legacy_libraries``: this feature flag must be OFF to show legacy Libraries V1
|
|
||||||
* ``contentstore.new_studio_mfe.disable_new_libraries``: this feature flag must be OFF to show Content Libraries V2
|
|
||||||
|
|
||||||
.. _meilisearch plugin: https://github.com/open-craft/tutor-contrib-meilisearch
|
|
||||||
|
|
||||||
Developing
|
Developing
|
||||||
**********
|
**********
|
||||||
|
|
||||||
`Tutor <https://docs.tutor.edly.io/>`_ is the community-supported Open edX development environment. See the `tutor-mfe plugin README <https://github.com/overhangio/tutor-mfe?tab=readme-ov-file#mfe-development>`_ for more information.
|
`Devstack <https://edx.readthedocs.io/projects/edx-installing-configuring-and-running/en/latest/installation/index.html>`_. If you start Devstack with ``make dev.up.studio`` that should give you everything you need as a companion to this frontend.
|
||||||
|
|
||||||
|
|
||||||
If your devstack includes the default Demo course, you can visit the following URLs to see content:
|
If your devstack includes the default Demo course, you can visit the following URLs to see content:
|
||||||
@@ -268,7 +284,7 @@ Troubleshooting
|
|||||||
========================
|
========================
|
||||||
|
|
||||||
* ``npm ERR! gyp ERR! build error`` while running npm install on Macs with M1 processors: Probably due to a compatibility issue of node-canvas with M1.
|
* ``npm ERR! gyp ERR! build error`` while running npm install on Macs with M1 processors: Probably due to a compatibility issue of node-canvas with M1.
|
||||||
|
|
||||||
Run ``brew install pkg-config pixman cairo pango libpng jpeg giflib librsvg`` before ``npm install`` to get the correct versions of the dependencies.
|
Run ``brew install pkg-config pixman cairo pango libpng jpeg giflib librsvg`` before ``npm install`` to get the correct versions of the dependencies.
|
||||||
If there is still an error, look for "no package [...] found" in the error message and install missing package via brew.
|
If there is still an error, look for "no package [...] found" in the error message and install missing package via brew.
|
||||||
(https://github.com/Automattic/node-canvas/issues/1733)
|
(https://github.com/Automattic/node-canvas/issues/1733)
|
||||||
@@ -286,8 +302,8 @@ The production build is created with ``npm run build``.
|
|||||||
:target: https://travis-ci.com/edx/frontend-app-course-authoring
|
:target: https://travis-ci.com/edx/frontend-app-course-authoring
|
||||||
.. |Codecov| image:: https://codecov.io/gh/edx/frontend-app-course-authoring/branch/master/graph/badge.svg
|
.. |Codecov| image:: https://codecov.io/gh/edx/frontend-app-course-authoring/branch/master/graph/badge.svg
|
||||||
:target: https://codecov.io/gh/edx/frontend-app-course-authoring
|
:target: https://codecov.io/gh/edx/frontend-app-course-authoring
|
||||||
.. |license| image:: https://img.shields.io/npm/l/@edx/frontend-app-authoring.svg
|
.. |license| image:: https://img.shields.io/npm/l/@edx/frontend-app-course-authoring.svg
|
||||||
:target: @edx/frontend-app-authoring
|
:target: @edx/frontend-app-course-authoring
|
||||||
|
|
||||||
Internationalization
|
Internationalization
|
||||||
====================
|
====================
|
||||||
@@ -322,20 +338,6 @@ For more information about these options, see the `Getting Help`_ page.
|
|||||||
.. _Getting Help: https://openedx.org/community/connect
|
.. _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
|
License
|
||||||
*******
|
*******
|
||||||
|
|
||||||
|
|||||||
@@ -4,16 +4,15 @@
|
|||||||
apiVersion: backstage.io/v1alpha1
|
apiVersion: backstage.io/v1alpha1
|
||||||
kind: Component
|
kind: Component
|
||||||
metadata:
|
metadata:
|
||||||
name: 'frontend-app-authoring'
|
name: 'frontend-app-course-authoring'
|
||||||
description: "The frontend (MFE) for Open edX Authoring (aka Studio)"
|
description: "The frontend (MFE) for Open edX Course Authoring (aka Studio)"
|
||||||
links:
|
links:
|
||||||
- url: "https://github.com/openedx/frontend-app-authoring"
|
- url: "https://github.com/openedx/frontend-app-course-authoring"
|
||||||
title: "Frontend app authoring"
|
title: "Frontend app course authoring"
|
||||||
icon: "Web"
|
icon: "Web"
|
||||||
annotations:
|
annotations:
|
||||||
openedx.org/arch-interest-groups: ""
|
openedx.org/arch-interest-groups: ""
|
||||||
openedx.org/release: "master"
|
|
||||||
spec:
|
spec:
|
||||||
owner: user:bradenmacdonald
|
owner: group:2u-tnl
|
||||||
type: 'website'
|
type: 'website'
|
||||||
lifecycle: 'production'
|
lifecycle: 'production'
|
||||||
|
|||||||
@@ -10,6 +10,4 @@ coverage:
|
|||||||
threshold: 0%
|
threshold: 0%
|
||||||
ignore:
|
ignore:
|
||||||
- "src/grading-settings/grading-scale/react-ranger.js"
|
- "src/grading-settings/grading-scale/react-ranger.js"
|
||||||
- "src/generic/DraggableList/verticalSortableList.ts"
|
|
||||||
- "src/container-comparison/data/api.mock.ts"
|
|
||||||
- "src/index.js"
|
- "src/index.js"
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import WholeCourseTranslation from '@edx/course-app-translation-plugin';
|
|
||||||
import { PLUGIN_OPERATIONS, DIRECT_PLUGIN } from '@openedx/frontend-plugin-framework';
|
|
||||||
|
|
||||||
// Load environment variables from .env file
|
|
||||||
const config = {
|
|
||||||
...process.env,
|
|
||||||
pluginSlots: {
|
|
||||||
additional_course_plugin: {
|
|
||||||
plugins: [
|
|
||||||
{
|
|
||||||
op: PLUGIN_OPERATIONS.Insert,
|
|
||||||
widget: {
|
|
||||||
id: 'whole-course-translation-plugin',
|
|
||||||
type: DIRECT_PLUGIN,
|
|
||||||
priority: 1,
|
|
||||||
RenderWidget: WholeCourseTranslation,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default config;
|
|
||||||
@@ -11,11 +11,9 @@ module.exports = createConfig('jest', {
|
|||||||
],
|
],
|
||||||
moduleNameMapper: {
|
moduleNameMapper: {
|
||||||
'^lodash-es$': 'lodash',
|
'^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',
|
'^CourseAuthoring/(.*)$': '<rootDir>/src/$1',
|
||||||
},
|
},
|
||||||
modulePathIgnorePatterns: [
|
modulePathIgnorePatterns: [
|
||||||
|
'/src/pages-and-resources/utils.test.jsx',
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
11
openedx.yaml
Normal file
11
openedx.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# This file describes this Open edX repo, as described in OEP-2:
|
||||||
|
# http://open-edx-proposals.readthedocs.io/en/latest/oeps/oep-0002.html#specification
|
||||||
|
|
||||||
|
nick: cath
|
||||||
|
oeps: {}
|
||||||
|
owner: edx/platform-core-tnl
|
||||||
|
openedx-release:
|
||||||
|
# The openedx-release key is described in OEP-10:
|
||||||
|
# https://open-edx-proposals.readthedocs.io/en/latest/oep-0010-proc-openedx-releases.html
|
||||||
|
# The FAQ might also be helpful: https://openedx.atlassian.net/wiki/spaces/COMM/pages/1331268879/Open+edX+Release+FAQ
|
||||||
|
ref: master
|
||||||
20586
package-lock.json
generated
20586
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
144
package.json
144
package.json
@@ -1,57 +1,60 @@
|
|||||||
{
|
{
|
||||||
"name": "@edx/frontend-app-authoring",
|
"name": "@edx/frontend-app-course-authoring",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "Frontend application template",
|
"description": "Frontend application template",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "git+https://github.com/openedx/frontend-app-authoring.git"
|
"url": "git+https://github.com/openedx/frontend-app-course-authoring.git"
|
||||||
},
|
},
|
||||||
"browserslist": [
|
"browserslist": [
|
||||||
"extends @edx/browserslist-config"
|
"extends @edx/browserslist-config"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "fedx-scripts webpack",
|
"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",
|
"stylelint": "stylelint \"src/**/*.scss\" \"scss/**/*.scss\" --config .stylelintrc.json",
|
||||||
"lint": "npm run stylelint && fedx-scripts eslint --ext .js --ext .jsx --ext .ts --ext .tsx .",
|
"lint": "npm run stylelint && fedx-scripts eslint --ext .js --ext .jsx .",
|
||||||
"oxlint": "oxlint --type-aware --deny-warnings",
|
"lint:fix": "npm run stylelint && fedx-scripts eslint --ext .js --ext .jsx . --fix",
|
||||||
"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": "fedx-scripts webpack-dev-server --progress",
|
||||||
"start:with-theme": "paragon install-theme && npm start && npm install",
|
"start:with-theme": "paragon install-theme && npm start && npm install",
|
||||||
"dev": "PUBLIC_PATH=/authoring/ MFE_CONFIG_API_URL='http://localhost:8000/api/mfe_config/v1' fedx-scripts webpack-dev-server --progress --host apps.local.openedx.io",
|
|
||||||
"test": "TZ=UTC fedx-scripts jest --coverage --passWithNoTests",
|
"test": "TZ=UTC fedx-scripts jest --coverage --passWithNoTests",
|
||||||
"test:ci": "TZ=UTC fedx-scripts jest --silent --coverage --passWithNoTests",
|
|
||||||
"types": "tsc --noEmit"
|
"types": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
|
"husky": {
|
||||||
|
"hooks": {
|
||||||
|
"pre-commit": "npm run lint"
|
||||||
|
}
|
||||||
|
},
|
||||||
"author": "edX",
|
"author": "edX",
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"homepage": "https://github.com/openedx/frontend-app-authoring#readme",
|
"homepage": "https://github.com/openedx/frontend-app-course-authoring#readme",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public"
|
"access": "public"
|
||||||
},
|
},
|
||||||
"bugs": {
|
"bugs": {
|
||||||
"url": "https://github.com/openedx/frontend-app-authoring/issues"
|
"url": "https://github.com/openedx/frontend-app-course-authoring/issues"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/lang-html": "^6.0.0",
|
"@datadog/browser-rum": "^5.13.0",
|
||||||
"@codemirror/lang-markdown": "^6.0.0",
|
|
||||||
"@codemirror/lang-xml": "^6.0.0",
|
|
||||||
"@codemirror/lint": "^6.2.1",
|
|
||||||
"@codemirror/state": "^6.0.0",
|
|
||||||
"@codemirror/view": "^6.0.0",
|
|
||||||
"@dnd-kit/core": "^6.1.0",
|
"@dnd-kit/core": "^6.1.0",
|
||||||
"@dnd-kit/modifiers": "^7.0.0",
|
"@dnd-kit/modifiers": "^7.0.0",
|
||||||
"@dnd-kit/sortable": "^8.0.0",
|
"@dnd-kit/sortable": "^8.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.3",
|
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
|
||||||
"@edx/browserslist-config": "1.5.1",
|
"@edx/frontend-component-ai-translations": "^2.0.0",
|
||||||
"@edx/frontend-component-footer": "^14.9.0",
|
"@edx/frontend-component-footer": "^13.0.2",
|
||||||
"@edx/frontend-component-header": "^8.1.0",
|
"@edx/frontend-component-header": "^5.0.2",
|
||||||
"@edx/frontend-enterprise-hotjar": "^7.2.0",
|
"@edx/frontend-enterprise-hotjar": "^2.0.0",
|
||||||
"@edx/frontend-platform": "^8.4.0",
|
"@edx/frontend-lib-content-components": "^2.1.4",
|
||||||
"@edx/openedx-atlas": "^0.7.0",
|
"@edx/frontend-platform": "7.0.1",
|
||||||
|
"@edx/openedx-atlas": "^0.6.0",
|
||||||
|
"@fortawesome/fontawesome-svg-core": "1.2.36",
|
||||||
|
"@fortawesome/free-brands-svg-icons": "5.15.4",
|
||||||
|
"@fortawesome/free-regular-svg-icons": "5.15.4",
|
||||||
|
"@fortawesome/free-solid-svg-icons": "5.15.4",
|
||||||
|
"@fortawesome/react-fontawesome": "0.2.0",
|
||||||
"@openedx-plugins/course-app-calculator": "file:plugins/course-apps/calculator",
|
"@openedx-plugins/course-app-calculator": "file:plugins/course-apps/calculator",
|
||||||
"@openedx-plugins/course-app-dates": "file:plugins/course-apps/dates",
|
|
||||||
"@openedx-plugins/course-app-edxnotes": "file:plugins/course-apps/edxnotes",
|
"@openedx-plugins/course-app-edxnotes": "file:plugins/course-apps/edxnotes",
|
||||||
"@openedx-plugins/course-app-learning_assistant": "file:plugins/course-apps/learning_assistant",
|
"@openedx-plugins/course-app-learning_assistant": "file:plugins/course-apps/learning_assistant",
|
||||||
"@openedx-plugins/course-app-live": "file:plugins/course-apps/live",
|
"@openedx-plugins/course-app-live": "file:plugins/course-apps/live",
|
||||||
@@ -61,66 +64,59 @@
|
|||||||
"@openedx-plugins/course-app-teams": "file:plugins/course-apps/teams",
|
"@openedx-plugins/course-app-teams": "file:plugins/course-apps/teams",
|
||||||
"@openedx-plugins/course-app-wiki": "file:plugins/course-apps/wiki",
|
"@openedx-plugins/course-app-wiki": "file:plugins/course-apps/wiki",
|
||||||
"@openedx-plugins/course-app-xpert_unit_summary": "file:plugins/course-apps/xpert_unit_summary",
|
"@openedx-plugins/course-app-xpert_unit_summary": "file:plugins/course-apps/xpert_unit_summary",
|
||||||
"@openedx/frontend-build": "^14.6.2",
|
"@openedx/paragon": "^21.5.7",
|
||||||
"@openedx/frontend-plugin-framework": "^1.8.0",
|
"@reduxjs/toolkit": "1.9.7",
|
||||||
"@openedx/paragon": "^23.5.0",
|
"@tanstack/react-query": "4.36.1",
|
||||||
"@redux-devtools/extension": "^3.3.0",
|
"broadcast-channel": "^7.0.0",
|
||||||
"@reduxjs/toolkit": "2.11.2",
|
"classnames": "2.2.6",
|
||||||
"@tanstack/react-query": "5.90.21",
|
"core-js": "3.8.1",
|
||||||
"@tinymce/tinymce-react": "^6.0.0",
|
|
||||||
"classnames": "2.5.1",
|
|
||||||
"codemirror": "^6.0.0",
|
|
||||||
"email-validator": "2.0.4",
|
"email-validator": "2.0.4",
|
||||||
"fast-xml-parser": "^5.0.0",
|
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"formik": "2.4.9",
|
"formik": "2.2.6",
|
||||||
"frontend-components-tinymce-advanced-plugins": "^1.0.3",
|
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"lodash": "4.17.23",
|
"lodash": "4.17.21",
|
||||||
"meilisearch": "^0.41.0",
|
"moment": "2.29.4",
|
||||||
"moment": "2.30.1",
|
"prop-types": "15.7.2",
|
||||||
"moment-shortformat": "^2.1.0",
|
"react": "17.0.2",
|
||||||
"prop-types": "^15.8.1",
|
"react-datepicker": "^4.13.0",
|
||||||
"react": "^18.3.1",
|
"react-dom": "17.0.2",
|
||||||
"react-datepicker": "^8.10.0",
|
|
||||||
"react-dom": "^18.3.1",
|
|
||||||
"react-error-boundary": "^4.0.13",
|
|
||||||
"react-helmet": "^6.1.0",
|
"react-helmet": "^6.1.0",
|
||||||
"react-onclickoutside": "^6.13.0",
|
|
||||||
"react-redux": "7.2.9",
|
"react-redux": "7.2.9",
|
||||||
"react-responsive": "10.0.1",
|
"react-responsive": "9.0.2",
|
||||||
"react-router": "6.30.3",
|
"react-router": "6.16.0",
|
||||||
"react-router-dom": "6.30.3",
|
"react-router-dom": "6.16.0",
|
||||||
"react-select": "5.10.2",
|
"react-select": "5.8.0",
|
||||||
"react-textarea-autosize": "^8.5.3",
|
"react-textarea-autosize": "^8.4.1",
|
||||||
"react-transition-group": "4.4.5",
|
"react-transition-group": "4.4.5",
|
||||||
"redux": "4.2.1",
|
"redux": "4.0.5",
|
||||||
"redux-logger": "^3.0.6",
|
"regenerator-runtime": "0.13.7",
|
||||||
"redux-thunk": "^2.4.1",
|
"universal-cookie": "^4.0.4",
|
||||||
"reselect": "^4.1.5",
|
"uuid": "^3.4.0",
|
||||||
"tinymce": "^5.10.4",
|
"yup": "0.31.1"
|
||||||
"universal-cookie": "^8.0.0",
|
|
||||||
"uuid": "^11.1.0",
|
|
||||||
"xmlchecker": "^0.1.0",
|
|
||||||
"yup": "0.32.11"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@edx/stylelint-config-edx": "2.3.3",
|
"@edx/browserslist-config": "1.2.0",
|
||||||
|
"@edx/react-unit-test-utils": "^2.0.0",
|
||||||
|
"@edx/reactifex": "^1.0.3",
|
||||||
|
"@edx/stylelint-config-edx": "2.3.0",
|
||||||
"@edx/typescript-config": "^1.0.1",
|
"@edx/typescript-config": "^1.0.1",
|
||||||
"@testing-library/jest-dom": "^6.6.3",
|
"@openedx/frontend-build": "13.0.27",
|
||||||
"@testing-library/react": "^16.2.0",
|
"@testing-library/jest-dom": "5.17.0",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/react": "12.1.5",
|
||||||
"@types/lodash": "^4.17.17",
|
"@testing-library/react-hooks": "^8.0.1",
|
||||||
"@types/react": "^18",
|
"@testing-library/user-event": "^13.2.1",
|
||||||
"@types/react-dom": "^18",
|
"axios": "^0.27.2",
|
||||||
"axios-mock-adapter": "2.1.0",
|
"axios-mock-adapter": "1.22.0",
|
||||||
"eslint-import-resolver-webpack": "^0.13.8",
|
"eslint-import-resolver-webpack": "^0.13.8",
|
||||||
"fetch-mock-jest": "^1.5.1",
|
"glob": "7.2.3",
|
||||||
|
"husky": "7.0.4",
|
||||||
"jest-canvas-mock": "^2.5.2",
|
"jest-canvas-mock": "^2.5.2",
|
||||||
"jest-expect-message": "^1.1.3",
|
"jest-expect-message": "^1.1.3",
|
||||||
"oxlint": "^1.42.0",
|
"react-test-renderer": "17.0.2",
|
||||||
"oxlint-tsgolint": "^0.16.0",
|
"reactifex": "1.1.1",
|
||||||
"react-test-renderer": "^18.3.1",
|
"ts-loader": "^9.5.0"
|
||||||
"redux-mock-store": "^1.5.4"
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"decode-uri-component": ">=0.2.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,14 +3,14 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "Calculator configuration for courses using it",
|
"description": "Calculator configuration for courses using it",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@edx/frontend-app-authoring": "*",
|
"@edx/frontend-app-course-authoring": "*",
|
||||||
"@edx/frontend-platform": "*",
|
"@edx/frontend-platform": "*",
|
||||||
"@openedx/paragon": "*",
|
"@openedx/paragon": "*",
|
||||||
"prop-types": "*",
|
"prop-types": "*",
|
||||||
"react": "*"
|
"react": "*"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@edx/frontend-app-authoring": {
|
"@edx/frontend-app-course-authoring": {
|
||||||
"optional": true
|
"optional": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,14 +3,14 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "edxnotes configuration for courses using it",
|
"description": "edxnotes configuration for courses using it",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@edx/frontend-app-authoring": "*",
|
"@edx/frontend-app-course-authoring": "*",
|
||||||
"@edx/frontend-platform": "*",
|
"@edx/frontend-platform": "*",
|
||||||
"@openedx/paragon": "*",
|
"@openedx/paragon": "*",
|
||||||
"prop-types": "*",
|
"prop-types": "*",
|
||||||
"react": "*"
|
"react": "*"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@edx/frontend-app-authoring": {
|
"@edx/frontend-app-course-authoring": {
|
||||||
"optional": true
|
"optional": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,16 @@ import React from 'react';
|
|||||||
import { screen, waitFor } from '@testing-library/react';
|
import { screen, waitFor } from '@testing-library/react';
|
||||||
|
|
||||||
import { RequestStatus } from 'CourseAuthoring/data/constants';
|
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';
|
import LearningAssistantSettings from './Settings';
|
||||||
|
|
||||||
const onClose = () => { };
|
const onClose = () => { };
|
||||||
|
|
||||||
describe('Learning Assistant Settings', () => {
|
describe('Learning Assistant Settings', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
it('renders', async () => {
|
it('renders', async () => {
|
||||||
const initialState = {
|
const initialState = {
|
||||||
models: {
|
models: {
|
||||||
@@ -34,8 +38,14 @@ describe('Learning Assistant Settings', () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
initializeMocks({ initialState });
|
render(
|
||||||
render(<LearningAssistantSettings onClose={onClose} />);
|
<LearningAssistantSettings
|
||||||
|
onClose={onClose}
|
||||||
|
/>,
|
||||||
|
{
|
||||||
|
preloadedState: initialState,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const toggleDescription = 'Reinforce learning concepts by sharing text-based course content '
|
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 '
|
+ 'with OpenAI (via API) to power an in-course Learning Assistant. Learners can leave feedback about the quality '
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "Learning Assistant configuration for courses using it",
|
"description": "Learning Assistant configuration for courses using it",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@edx/frontend-app-authoring": "*",
|
"@edx/frontend-app-course-authoring": "*",
|
||||||
"@edx/frontend-platform": "*",
|
"@edx/frontend-platform": "*",
|
||||||
"@openedx/paragon": "*",
|
"@openedx/paragon": "*",
|
||||||
"prop-types": "*",
|
"prop-types": "*",
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
"yup": "*"
|
"yup": "*"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@edx/frontend-app-authoring": {
|
"@edx/frontend-app-course-authoring": {
|
||||||
"optional": true
|
"optional": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { getConfig, getExternalLinkUrl } from '@edx/frontend-platform';
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
|
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||||
import { Form, Hyperlink } from '@openedx/paragon';
|
import { Form, Hyperlink } from '@openedx/paragon';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import AppConfigFormDivider from 'CourseAuthoring/pages-and-resources/discussions/app-config-form/apps/shared/AppConfigFormDivider';
|
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';
|
import messages from './messages';
|
||||||
|
|
||||||
const BbbSettings = ({
|
const BbbSettings = ({
|
||||||
|
intl,
|
||||||
values,
|
values,
|
||||||
setFieldValue,
|
setFieldValue,
|
||||||
}) => {
|
}) => {
|
||||||
const intl = useIntl();
|
|
||||||
const [bbbPlan, setBbbPlan] = useState(values.tierType);
|
const [bbbPlan, setBbbPlan] = useState(values.tierType);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -93,7 +93,7 @@ const BbbSettings = ({
|
|||||||
<span data-testid="free-plan-message">
|
<span data-testid="free-plan-message">
|
||||||
{intl.formatMessage(messages.freePlanMessage)}
|
{intl.formatMessage(messages.freePlanMessage)}
|
||||||
<Hyperlink
|
<Hyperlink
|
||||||
destination={getExternalLinkUrl('https://bigbluebutton.org/privacy-policy/')}
|
destination="https://bigbluebutton.org/privacy-policy/"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
showLaunchIcon
|
showLaunchIcon
|
||||||
@@ -107,10 +107,12 @@ const BbbSettings = ({
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
</>
|
</>
|
||||||
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
BbbSettings.propTypes = {
|
BbbSettings.propTypes = {
|
||||||
|
intl: intlShape.isRequired,
|
||||||
values: PropTypes.shape({
|
values: PropTypes.shape({
|
||||||
consumerKey: PropTypes.string,
|
consumerKey: PropTypes.string,
|
||||||
consumerSecret: PropTypes.string,
|
consumerSecret: PropTypes.string,
|
||||||
@@ -125,4 +127,4 @@ BbbSettings.propTypes = {
|
|||||||
setFieldValue: PropTypes.func.isRequired,
|
setFieldValue: PropTypes.func.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default BbbSettings;
|
export default injectIntl(BbbSettings);
|
||||||
|
|||||||
@@ -4,16 +4,21 @@ import {
|
|||||||
getByRole,
|
getByRole,
|
||||||
getAllByRole,
|
getAllByRole,
|
||||||
waitForElementToBeRemoved,
|
waitForElementToBeRemoved,
|
||||||
initializeMocks,
|
} from '@testing-library/react';
|
||||||
} from 'CourseAuthoring/testUtils';
|
|
||||||
|
|
||||||
import ReactDOM from 'react-dom';
|
import ReactDOM from 'react-dom';
|
||||||
|
import { Routes, Route, MemoryRouter } from 'react-router-dom';
|
||||||
|
import { initializeMockApp } from '@edx/frontend-platform';
|
||||||
|
import MockAdapter from 'axios-mock-adapter';
|
||||||
|
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||||
|
import { AppProvider, PageWrap } from '@edx/frontend-platform/react';
|
||||||
|
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import initializeStore from 'CourseAuthoring/store';
|
||||||
import { executeThunk } from 'CourseAuthoring/utils';
|
import { executeThunk } from 'CourseAuthoring/utils';
|
||||||
import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
|
import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
|
||||||
|
|
||||||
import { CourseAuthoringProvider } from 'CourseAuthoring/CourseAuthoringContext';
|
|
||||||
import LiveSettings from './Settings';
|
import LiveSettings from './Settings';
|
||||||
import {
|
import {
|
||||||
generateLiveConfigurationApiResponse,
|
generateLiveConfigurationApiResponse,
|
||||||
@@ -35,20 +40,17 @@ ReactDOM.createPortal = jest.fn(node => node);
|
|||||||
|
|
||||||
const renderComponent = () => {
|
const renderComponent = () => {
|
||||||
const wrapper = render(
|
const wrapper = render(
|
||||||
<CourseAuthoringProvider courseId={courseId}>
|
<IntlProvider locale="en">
|
||||||
<PagesAndResourcesProvider courseId={courseId}>
|
<AppProvider store={store} wrapWithRouter={false}>
|
||||||
<LiveSettings onClose={() => {}} />
|
<PagesAndResourcesProvider courseId={courseId}>
|
||||||
</PagesAndResourcesProvider>
|
<MemoryRouter initialEntries={[liveSettingsUrl]}>
|
||||||
</CourseAuthoringProvider>,
|
<Routes>
|
||||||
{
|
<Route path={liveSettingsUrl} element={<PageWrap><LiveSettings onClose={() => {}} /></PageWrap>} />
|
||||||
path: liveSettingsUrl,
|
</Routes>
|
||||||
routerProps: {
|
</MemoryRouter>
|
||||||
initialEntries: [liveSettingsUrl],
|
</PagesAndResourcesProvider>
|
||||||
},
|
</AppProvider>
|
||||||
params: {
|
</IntlProvider>,
|
||||||
courseId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
container = wrapper.container;
|
container = wrapper.container;
|
||||||
};
|
};
|
||||||
@@ -72,9 +74,16 @@ const mockStore = async ({
|
|||||||
|
|
||||||
describe('BBB Settings', () => {
|
describe('BBB Settings', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const mocks = initializeMocks({ initialState });
|
initializeMockApp({
|
||||||
store = mocks.reduxStore;
|
authenticatedUser: {
|
||||||
axiosMock = mocks.axiosMock;
|
userId: 3,
|
||||||
|
username: 'abc123',
|
||||||
|
administrator: false,
|
||||||
|
roles: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
store = initializeStore(initialState);
|
||||||
|
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Plan dropdown to be visible and enabled in UI', async () => {
|
test('Plan dropdown to be visible and enabled in UI', async () => {
|
||||||
@@ -115,13 +124,12 @@ describe('BBB Settings', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
test('free plans message is visible when free plan is selected', async () => {
|
test('free plans message is visible when free plan is selected', async () => {
|
||||||
const user = userEvent.setup();
|
|
||||||
await mockStore({ emailSharing: true, isFreeTier: true });
|
await mockStore({ emailSharing: true, isFreeTier: true });
|
||||||
renderComponent();
|
renderComponent();
|
||||||
const spinner = getByRole(container, 'status');
|
const spinner = getByRole(container, 'status');
|
||||||
await waitForElementToBeRemoved(spinner);
|
await waitForElementToBeRemoved(spinner);
|
||||||
const dropDown = container.querySelector('select[name="tierType"]');
|
const dropDown = container.querySelector('select[name="tierType"]');
|
||||||
await user.selectOptions(
|
userEvent.selectOptions(
|
||||||
dropDown,
|
dropDown,
|
||||||
getByRole(dropDown, 'option', { name: 'Free' }),
|
getByRole(dropDown, 'option', { name: 'Free' }),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,43 +1,42 @@
|
|||||||
import React from 'react';
|
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 PropTypes from 'prop-types';
|
||||||
import FormikControl from 'CourseAuthoring/generic/FormikControl';
|
import FormikControl from 'CourseAuthoring/generic/FormikControl';
|
||||||
|
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
|
|
||||||
const LiveCommonFields = ({
|
const LiveCommonFields = ({
|
||||||
|
intl,
|
||||||
values,
|
values,
|
||||||
}) => {
|
}) => (
|
||||||
const intl = useIntl();
|
<>
|
||||||
return (
|
<p className="pb-2">{intl.formatMessage(messages.formInstructions)}</p>
|
||||||
<>
|
<FormikControl
|
||||||
<p className="pb-2">{intl.formatMessage(messages.formInstructions)}</p>
|
name="consumerKey"
|
||||||
<FormikControl
|
value={values.consumerKey}
|
||||||
name="consumerKey"
|
floatingLabel={intl.formatMessage(messages.consumerKey)}
|
||||||
value={values.consumerKey}
|
className="pb-1"
|
||||||
floatingLabel={intl.formatMessage(messages.consumerKey)}
|
type="input"
|
||||||
className="pb-1"
|
/>
|
||||||
type="input"
|
<FormikControl
|
||||||
/>
|
name="consumerSecret"
|
||||||
<FormikControl
|
value={values.consumerSecret}
|
||||||
name="consumerSecret"
|
floatingLabel={intl.formatMessage(messages.consumerSecret)}
|
||||||
value={values.consumerSecret}
|
className="pb-1"
|
||||||
floatingLabel={intl.formatMessage(messages.consumerSecret)}
|
type="password"
|
||||||
className="pb-1"
|
/>
|
||||||
type="password"
|
<FormikControl
|
||||||
/>
|
name="launchUrl"
|
||||||
<FormikControl
|
value={values.launchUrl}
|
||||||
name="launchUrl"
|
floatingLabel={intl.formatMessage(messages.launchUrl)}
|
||||||
value={values.launchUrl}
|
className="pb-1"
|
||||||
floatingLabel={intl.formatMessage(messages.launchUrl)}
|
type="input"
|
||||||
className="pb-1"
|
/>
|
||||||
type="input"
|
</>
|
||||||
/>
|
);
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
LiveCommonFields.propTypes = {
|
LiveCommonFields.propTypes = {
|
||||||
|
intl: intlShape.isRequired,
|
||||||
values: PropTypes.shape({
|
values: PropTypes.shape({
|
||||||
consumerKey: PropTypes.string,
|
consumerKey: PropTypes.string,
|
||||||
consumerSecret: PropTypes.string,
|
consumerSecret: PropTypes.string,
|
||||||
@@ -46,4 +45,4 @@ LiveCommonFields.propTypes = {
|
|||||||
}).isRequired,
|
}).isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default LiveCommonFields;
|
export default injectIntl(LiveCommonFields);
|
||||||
|
|||||||
@@ -1,18 +1,16 @@
|
|||||||
// oxlint-disable unicorn/no-thenable
|
|
||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
import { camelCase } from 'lodash';
|
import { camelCase } from 'lodash';
|
||||||
import { Icon } from '@openedx/paragon';
|
import { Icon } from '@openedx/paragon';
|
||||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||||
|
import { SelectableBox } from '@edx/frontend-lib-content-components';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import * as Yup from 'yup';
|
import * as Yup from 'yup';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import SelectableBox from 'CourseAuthoring/editors/sharedComponents/SelectableBox';
|
|
||||||
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
|
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
|
||||||
import { useModel } from 'CourseAuthoring/generic/model-store';
|
import { useModel } from 'CourseAuthoring/generic/model-store';
|
||||||
import Loading from 'CourseAuthoring/generic/Loading';
|
import Loading from 'CourseAuthoring/generic/Loading';
|
||||||
import { RequestStatus } from 'CourseAuthoring/data/constants';
|
import { RequestStatus } from 'CourseAuthoring/data/constants';
|
||||||
import { useCourseAuthoringContext } from 'CourseAuthoring/CourseAuthoringContext';
|
|
||||||
|
|
||||||
import { fetchLiveData, saveLiveConfiguration, saveLiveConfigurationAsDraft } from './data/thunks';
|
import { fetchLiveData, saveLiveConfiguration, saveLiveConfigurationAsDraft } from './data/thunks';
|
||||||
import { selectApp } from './data/slice';
|
import { selectApp } from './data/slice';
|
||||||
@@ -22,12 +20,12 @@ import ZoomSettings from './ZoomSettings';
|
|||||||
import BBBSettings from './BBBSettings';
|
import BBBSettings from './BBBSettings';
|
||||||
|
|
||||||
const LiveSettings = ({
|
const LiveSettings = ({
|
||||||
|
intl,
|
||||||
onClose,
|
onClose,
|
||||||
}) => {
|
}) => {
|
||||||
const intl = useIntl();
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const { courseId } = useCourseAuthoringContext();
|
const courseId = useSelector(state => state.courseDetail.courseId);
|
||||||
const availableProviders = useSelector((state) => state.live.appIds);
|
const availableProviders = useSelector((state) => state.live.appIds);
|
||||||
const {
|
const {
|
||||||
piiSharingAllowed, selectedAppId, enabled, status,
|
piiSharingAllowed, selectedAppId, enabled, status,
|
||||||
@@ -73,7 +71,6 @@ const LiveSettings = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSettingsSave = async (values) => {
|
const handleSettingsSave = async (values) => {
|
||||||
// oxlint-disable-next-line @typescript-eslint/await-thenable - this dispatch() IS returning a promise.
|
|
||||||
await dispatch(saveLiveConfiguration(courseId, values, navigate));
|
await dispatch(saveLiveConfiguration(courseId, values, navigate));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -133,7 +130,8 @@ const LiveSettings = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
LiveSettings.propTypes = {
|
LiveSettings.propTypes = {
|
||||||
|
intl: intlShape.isRequired,
|
||||||
onClose: PropTypes.func.isRequired,
|
onClose: PropTypes.func.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default LiveSettings;
|
export default injectIntl(LiveSettings);
|
||||||
|
|||||||
@@ -8,14 +8,20 @@ import {
|
|||||||
queryByText,
|
queryByText,
|
||||||
getByRole,
|
getByRole,
|
||||||
waitForElementToBeRemoved,
|
waitForElementToBeRemoved,
|
||||||
initializeMocks,
|
} from '@testing-library/react';
|
||||||
} from 'CourseAuthoring/testUtils';
|
|
||||||
|
|
||||||
import ReactDOM from 'react-dom';
|
import ReactDOM from 'react-dom';
|
||||||
|
import { Routes, Route, MemoryRouter } from 'react-router-dom';
|
||||||
|
import { initializeMockApp } from '@edx/frontend-platform';
|
||||||
|
import MockAdapter from 'axios-mock-adapter';
|
||||||
|
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||||
|
import { AppProvider, PageWrap } from '@edx/frontend-platform/react';
|
||||||
|
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
|
import initializeStore from 'CourseAuthoring/store';
|
||||||
import { executeThunk } from 'CourseAuthoring/utils';
|
import { executeThunk } from 'CourseAuthoring/utils';
|
||||||
import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
|
import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
|
||||||
|
|
||||||
import { CourseAuthoringProvider } from 'CourseAuthoring/CourseAuthoringContext';
|
|
||||||
import LiveSettings from './Settings';
|
import LiveSettings from './Settings';
|
||||||
import {
|
import {
|
||||||
generateLiveConfigurationApiResponse,
|
generateLiveConfigurationApiResponse,
|
||||||
@@ -38,20 +44,17 @@ ReactDOM.createPortal = jest.fn(node => node);
|
|||||||
|
|
||||||
const renderComponent = () => {
|
const renderComponent = () => {
|
||||||
const wrapper = render(
|
const wrapper = render(
|
||||||
<PagesAndResourcesProvider courseId={courseId}>
|
<IntlProvider locale="en">
|
||||||
<CourseAuthoringProvider>
|
<AppProvider store={store} wrapWithRouter={false}>
|
||||||
<LiveSettings onClose={() => {}} />
|
<PagesAndResourcesProvider courseId={courseId}>
|
||||||
</CourseAuthoringProvider>
|
<MemoryRouter initialEntries={[liveSettingsUrl]}>
|
||||||
</PagesAndResourcesProvider>,
|
<Routes>
|
||||||
{
|
<Route path={liveSettingsUrl} element={<PageWrap><LiveSettings onClose={() => {}} /></PageWrap>} />
|
||||||
path: liveSettingsUrl,
|
</Routes>
|
||||||
routerProps: {
|
</MemoryRouter>
|
||||||
initialEntries: [liveSettingsUrl],
|
</PagesAndResourcesProvider>
|
||||||
},
|
</AppProvider>
|
||||||
params: {
|
</IntlProvider>,
|
||||||
courseId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
container = wrapper.container;
|
container = wrapper.container;
|
||||||
};
|
};
|
||||||
@@ -74,11 +77,16 @@ const mockStore = async ({
|
|||||||
|
|
||||||
describe('LiveSettings', () => {
|
describe('LiveSettings', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const mocks = initializeMocks({
|
initializeMockApp({
|
||||||
initialState,
|
authenticatedUser: {
|
||||||
|
userId: 3,
|
||||||
|
username: 'abc123',
|
||||||
|
administrator: false,
|
||||||
|
roles: [],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
store = mocks.reduxStore;
|
store = initializeStore(initialState);
|
||||||
axiosMock = mocks.axiosMock;
|
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Live Configuration modal is visible', async () => {
|
test('Live Configuration modal is visible', async () => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
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 PropTypes from 'prop-types';
|
||||||
import FormikControl from 'CourseAuthoring/generic/FormikControl';
|
import FormikControl from 'CourseAuthoring/generic/FormikControl';
|
||||||
|
|
||||||
@@ -8,38 +8,37 @@ import { providerNames } from './constants';
|
|||||||
import LiveCommonFields from './LiveCommonFields';
|
import LiveCommonFields from './LiveCommonFields';
|
||||||
|
|
||||||
const ZoomSettings = ({
|
const ZoomSettings = ({
|
||||||
|
intl,
|
||||||
values,
|
values,
|
||||||
}) => {
|
}) => (
|
||||||
const intl = useIntl();
|
// eslint-disable-next-line react/jsx-no-useless-fragment
|
||||||
return (
|
<>
|
||||||
// eslint-disable-next-line react/jsx-no-useless-fragment
|
{!values.piiSharingEnable ? (
|
||||||
<>
|
<p data-testid="request-pii-sharing">
|
||||||
{!values.piiSharingEnable ? (
|
{intl.formatMessage(messages.requestPiiSharingEnable, { provider: providerNames[values.provider] })}
|
||||||
<p data-testid="request-pii-sharing">
|
</p>
|
||||||
{intl.formatMessage(messages.requestPiiSharingEnable, { provider: providerNames[values.provider] })}
|
) : (
|
||||||
</p>
|
<>
|
||||||
) : (
|
{(values.piiSharingEmail || values.piiSharingUsername)
|
||||||
<>
|
&& (
|
||||||
{(values.piiSharingEmail || values.piiSharingUsername)
|
<p data-testid="helper-text">
|
||||||
&& (
|
{intl.formatMessage(messages.providerHelperText, { providerName: providerNames[values.provider] })}
|
||||||
<p data-testid="helper-text">
|
</p>
|
||||||
{intl.formatMessage(messages.providerHelperText, { providerName: providerNames[values.provider] })}
|
)}
|
||||||
</p>
|
<LiveCommonFields values={values} />
|
||||||
)}
|
<FormikControl
|
||||||
<LiveCommonFields values={values} />
|
name="launchEmail"
|
||||||
<FormikControl
|
value={values.launchEmail}
|
||||||
name="launchEmail"
|
floatingLabel={intl.formatMessage(messages.launchEmail)}
|
||||||
value={values.launchEmail}
|
type="input"
|
||||||
floatingLabel={intl.formatMessage(messages.launchEmail)}
|
/>
|
||||||
type="input"
|
</>
|
||||||
/>
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
);
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
ZoomSettings.propTypes = {
|
ZoomSettings.propTypes = {
|
||||||
|
intl: intlShape.isRequired,
|
||||||
values: PropTypes.shape({
|
values: PropTypes.shape({
|
||||||
consumerKey: PropTypes.string,
|
consumerKey: PropTypes.string,
|
||||||
consumerSecret: PropTypes.string,
|
consumerSecret: PropTypes.string,
|
||||||
@@ -52,4 +51,4 @@ ZoomSettings.propTypes = {
|
|||||||
}).isRequired,
|
}).isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ZoomSettings;
|
export default injectIntl(ZoomSettings);
|
||||||
|
|||||||
@@ -3,14 +3,19 @@ import {
|
|||||||
queryByTestId,
|
queryByTestId,
|
||||||
getByRole,
|
getByRole,
|
||||||
waitForElementToBeRemoved,
|
waitForElementToBeRemoved,
|
||||||
initializeMocks,
|
} from '@testing-library/react';
|
||||||
} from 'CourseAuthoring/testUtils';
|
|
||||||
|
|
||||||
import ReactDOM from 'react-dom';
|
import ReactDOM from 'react-dom';
|
||||||
|
import { Routes, Route, MemoryRouter } from 'react-router-dom';
|
||||||
|
import { initializeMockApp } from '@edx/frontend-platform';
|
||||||
|
import MockAdapter from 'axios-mock-adapter';
|
||||||
|
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||||
|
import { AppProvider, PageWrap } from '@edx/frontend-platform/react';
|
||||||
|
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
|
import initializeStore from 'CourseAuthoring/store';
|
||||||
import { executeThunk } from 'CourseAuthoring/utils';
|
import { executeThunk } from 'CourseAuthoring/utils';
|
||||||
import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
|
import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
|
||||||
import { CourseAuthoringProvider } from 'CourseAuthoring/CourseAuthoringContext';
|
|
||||||
import LiveSettings from './Settings';
|
import LiveSettings from './Settings';
|
||||||
import {
|
import {
|
||||||
generateLiveConfigurationApiResponse,
|
generateLiveConfigurationApiResponse,
|
||||||
@@ -33,20 +38,17 @@ ReactDOM.createPortal = jest.fn(node => node);
|
|||||||
|
|
||||||
const renderComponent = () => {
|
const renderComponent = () => {
|
||||||
const wrapper = render(
|
const wrapper = render(
|
||||||
<CourseAuthoringProvider courseId={courseId}>
|
<IntlProvider locale="en">
|
||||||
<PagesAndResourcesProvider courseId={courseId}>
|
<AppProvider store={store} wrapWithRouter={false}>
|
||||||
<LiveSettings onClose={() => {}} />
|
<PagesAndResourcesProvider courseId={courseId}>
|
||||||
</PagesAndResourcesProvider>
|
<MemoryRouter initialEntries={[liveSettingsUrl]}>
|
||||||
</CourseAuthoringProvider>,
|
<Routes>
|
||||||
{
|
<Route path={liveSettingsUrl} element={<PageWrap><LiveSettings onClose={() => {}} /></PageWrap>} />
|
||||||
path: liveSettingsUrl,
|
</Routes>
|
||||||
routerProps: {
|
</MemoryRouter>
|
||||||
initialEntries: [liveSettingsUrl],
|
</PagesAndResourcesProvider>
|
||||||
},
|
</AppProvider>
|
||||||
params: {
|
</IntlProvider>,
|
||||||
courseId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
container = wrapper.container;
|
container = wrapper.container;
|
||||||
};
|
};
|
||||||
@@ -69,9 +71,16 @@ const mockStore = async ({
|
|||||||
|
|
||||||
describe('Zoom Settings', () => {
|
describe('Zoom Settings', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const mocks = initializeMocks({ initialState });
|
initializeMockApp({
|
||||||
store = mocks.reduxStore;
|
authenticatedUser: {
|
||||||
axiosMock = mocks.axiosMock;
|
userId: 3,
|
||||||
|
username: 'abc123',
|
||||||
|
administrator: false,
|
||||||
|
roles: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
store = initializeStore(initialState);
|
||||||
|
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||||
});
|
});
|
||||||
|
|
||||||
test('LTI fields are visible when pii sharing is enabled', async () => {
|
test('LTI fields are visible when pii sharing is enabled', async () => {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable import/prefer-default-export */
|
||||||
import { ensureConfig, getConfig } from '@edx/frontend-platform';
|
import { ensureConfig, getConfig } from '@edx/frontend-platform';
|
||||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||||
import { bbbPlanTypes } from '../constants';
|
import { bbbPlanTypes } from '../constants';
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "Live course configuration for courses using it",
|
"description": "Live course configuration for courses using it",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@edx/frontend-app-authoring": "*",
|
"@edx/frontend-app-course-authoring": "*",
|
||||||
|
"@edx/frontend-lib-content-components": "*",
|
||||||
"@edx/frontend-platform": "*",
|
"@edx/frontend-platform": "*",
|
||||||
"@openedx/paragon": "*",
|
"@openedx/paragon": "*",
|
||||||
"@reduxjs/toolkit": "*",
|
"@reduxjs/toolkit": "*",
|
||||||
@@ -15,7 +16,7 @@
|
|||||||
"yup": "*"
|
"yup": "*"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@edx/frontend-app-authoring": {
|
"@edx/frontend-app-course-authoring": {
|
||||||
"optional": true
|
"optional": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,177 +1,69 @@
|
|||||||
import { useEffect, useState, useRef } from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
import * as Yup from 'yup';
|
||||||
|
|
||||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
|
||||||
|
|
||||||
import {
|
import { Hyperlink } from '@openedx/paragon';
|
||||||
ActionRow, Alert, Badge, Form, Hyperlink, ModalDialog, StatefulButton,
|
import { useModel } from 'CourseAuthoring/generic/model-store';
|
||||||
} from '@openedx/paragon';
|
|
||||||
import { Info } from '@openedx/paragon/icons';
|
|
||||||
import { updateModel, useModel } from 'CourseAuthoring/generic/model-store';
|
|
||||||
|
|
||||||
import { RequestStatus } from 'CourseAuthoring/data/constants';
|
|
||||||
import FormSwitchGroup from 'CourseAuthoring/generic/FormSwitchGroup';
|
import FormSwitchGroup from 'CourseAuthoring/generic/FormSwitchGroup';
|
||||||
import Loading from 'CourseAuthoring/generic/Loading';
|
import { useAppSetting } from 'CourseAuthoring/utils';
|
||||||
import PermissionDeniedAlert from 'CourseAuthoring/generic/PermissionDeniedAlert';
|
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
|
||||||
import ConnectionErrorAlert from 'CourseAuthoring/generic/ConnectionErrorAlert';
|
|
||||||
import { useAppSetting, useIsMobile } from 'CourseAuthoring/utils';
|
|
||||||
import { getLoadingStatus, getSavingStatus } from 'CourseAuthoring/pages-and-resources/data/selectors';
|
|
||||||
import { updateSavingStatus } from 'CourseAuthoring/pages-and-resources/data/slice';
|
|
||||||
|
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
|
|
||||||
const ORASettings = ({ onClose }) => {
|
const ORASettings = ({ intl, onClose }) => {
|
||||||
const dispatch = useDispatch();
|
|
||||||
const { formatMessage } = useIntl();
|
|
||||||
const alertRef = useRef(null);
|
|
||||||
const updateSettingsRequestStatus = useSelector(getSavingStatus);
|
|
||||||
const loadingStatus = useSelector(getLoadingStatus);
|
|
||||||
const isMobile = useIsMobile();
|
|
||||||
const modalVariant = isMobile ? 'dark' : 'default';
|
|
||||||
const appId = 'ora_settings';
|
const appId = 'ora_settings';
|
||||||
const appInfo = useModel('courseApps', appId);
|
const appInfo = useModel('courseApps', appId);
|
||||||
|
|
||||||
const [enableFlexiblePeerGrade, saveSetting] = useAppSetting(
|
const [enableFlexiblePeerGrade, saveSetting] = useAppSetting(
|
||||||
'forceOnFlexiblePeerOpenassessments',
|
'forceOnFlexiblePeerOpenassessments',
|
||||||
);
|
);
|
||||||
const initialFormValues = { enableFlexiblePeerGrade };
|
|
||||||
|
|
||||||
const [formValues, setFormValues] = useState(initialFormValues);
|
|
||||||
const [saveError, setSaveError] = useState(false);
|
|
||||||
|
|
||||||
const submitButtonState = updateSettingsRequestStatus === RequestStatus.IN_PROGRESS ? 'pending' : 'default';
|
|
||||||
const handleSettingsSave = (values) => saveSetting(values.enableFlexiblePeerGrade);
|
const handleSettingsSave = (values) => saveSetting(values.enableFlexiblePeerGrade);
|
||||||
|
|
||||||
const handleSubmit = async (event) => {
|
const title = (
|
||||||
let success = true;
|
<div>
|
||||||
event.preventDefault();
|
<p>{intl.formatMessage(messages.heading)}</p>
|
||||||
|
<div className="pt-3">
|
||||||
success = success && await handleSettingsSave(formValues);
|
<Hyperlink
|
||||||
setSaveError(!success);
|
className="text-primary-500 small"
|
||||||
if ((initialFormValues.enableFlexiblePeerGrade !== formValues.enableFlexiblePeerGrade) && success) {
|
destination={appInfo.documentationLinks?.learnMoreConfiguration}
|
||||||
// oxlint-disable-next-line @typescript-eslint/await-thenable - this dispatch() IS returning a promise.
|
target="_blank"
|
||||||
success = await dispatch(updateModel({
|
rel="noreferrer noopener"
|
||||||
modelType: 'courseApps',
|
>
|
||||||
model: {
|
{intl.formatMessage(messages.ORASettingsHelpLink)}
|
||||||
id: appId, enabled: formValues.enableFlexiblePeerGrade,
|
</Hyperlink>
|
||||||
},
|
</div>
|
||||||
}));
|
</div>
|
||||||
}
|
);
|
||||||
!success && alertRef?.current.scrollIntoView(); // eslint-disable-line @typescript-eslint/no-unused-expressions
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleChange = (e) => {
|
|
||||||
setFormValues({ enableFlexiblePeerGrade: e.target.checked });
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (updateSettingsRequestStatus === RequestStatus.SUCCESSFUL) {
|
|
||||||
dispatch(updateSavingStatus({ status: '' }));
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
}, [updateSettingsRequestStatus]);
|
|
||||||
|
|
||||||
const renderBody = () => {
|
|
||||||
switch (loadingStatus) {
|
|
||||||
case RequestStatus.SUCCESSFUL:
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{saveError && (
|
|
||||||
<Alert variant="danger" icon={Info} ref={alertRef}>
|
|
||||||
<Alert.Heading>
|
|
||||||
{formatMessage(messages.errorSavingTitle)}
|
|
||||||
</Alert.Heading>
|
|
||||||
{formatMessage(messages.errorSavingMessage)}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
<FormSwitchGroup
|
|
||||||
id="enable-flexible-peer-grade"
|
|
||||||
name="enableFlexiblePeerGrade"
|
|
||||||
label={(
|
|
||||||
<div className="d-flex align-items-center">
|
|
||||||
{formatMessage(messages.enableFlexPeerGradeLabel)}
|
|
||||||
{formValues.enableFlexiblePeerGrade && (
|
|
||||||
<Badge className="ml-2" variant="success" data-testid="enable-badge">
|
|
||||||
{formatMessage(messages.enabledBadgeLabel)}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
helpText={(
|
|
||||||
<div>
|
|
||||||
<p>{formatMessage(messages.enableFlexPeerGradeHelp)}</p>
|
|
||||||
<span className="py-3">
|
|
||||||
<Hyperlink
|
|
||||||
className="text-primary-500 small"
|
|
||||||
destination={appInfo.documentationLinks?.learnMoreConfiguration}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer noopener"
|
|
||||||
>
|
|
||||||
{formatMessage(messages.ORASettingsHelpLink)}
|
|
||||||
</Hyperlink>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
onChange={handleChange}
|
|
||||||
checked={formValues.enableFlexiblePeerGrade}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
case RequestStatus.DENIED:
|
|
||||||
return <PermissionDeniedAlert />;
|
|
||||||
case RequestStatus.FAILED:
|
|
||||||
return <ConnectionErrorAlert />;
|
|
||||||
default:
|
|
||||||
return <Loading />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ModalDialog
|
<AppSettingsModal
|
||||||
title={formatMessage(messages.heading)}
|
appId={appId}
|
||||||
isOpen
|
title={title}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
size="lg"
|
initialValues={{ enableFlexiblePeerGrade }}
|
||||||
variant={modalVariant}
|
validationSchema={{ enableFlexiblePeerGrade: Yup.boolean() }}
|
||||||
hasCloseButton={isMobile}
|
onSettingsSave={handleSettingsSave}
|
||||||
isFullscreenScroll
|
hideAppToggle
|
||||||
isFullscreenOnMobile
|
|
||||||
>
|
>
|
||||||
<Form onSubmit={handleSubmit} data-testid="proctoringForm">
|
{({ values, handleChange, handleBlur }) => (
|
||||||
<ModalDialog.Header>
|
<FormSwitchGroup
|
||||||
<ModalDialog.Title>
|
id="enable-flexible-peer-grade"
|
||||||
{formatMessage(messages.heading)}
|
name="enableFlexiblePeerGrade"
|
||||||
</ModalDialog.Title>
|
label={intl.formatMessage(messages.enableFlexPeerGradeLabel)}
|
||||||
</ModalDialog.Header>
|
helpText={intl.formatMessage(messages.enableFlexPeerGradeHelp)}
|
||||||
<ModalDialog.Body>
|
onChange={handleChange}
|
||||||
{renderBody()}
|
onBlur={handleBlur}
|
||||||
</ModalDialog.Body>
|
checked={values.enableFlexiblePeerGrade}
|
||||||
<ModalDialog.Footer className="p-4">
|
/>
|
||||||
<ActionRow>
|
)}
|
||||||
<ModalDialog.CloseButton variant="tertiary">
|
</AppSettingsModal>
|
||||||
{formatMessage(messages.cancelLabel)}
|
|
||||||
</ModalDialog.CloseButton>
|
|
||||||
<StatefulButton
|
|
||||||
labels={{
|
|
||||||
default: formatMessage(messages.saveLabel),
|
|
||||||
pending: formatMessage(messages.pendingSaveLabel),
|
|
||||||
}}
|
|
||||||
description="Form save button"
|
|
||||||
data-testid="submissionButton"
|
|
||||||
disabled={submitButtonState === RequestStatus.IN_PROGRESS}
|
|
||||||
state={submitButtonState}
|
|
||||||
type="submit"
|
|
||||||
/>
|
|
||||||
</ActionRow>
|
|
||||||
</ModalDialog.Footer>
|
|
||||||
</Form>
|
|
||||||
</ModalDialog>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
ORASettings.propTypes = {
|
ORASettings.propTypes = {
|
||||||
|
intl: intlShape.isRequired,
|
||||||
onClose: PropTypes.func.isRequired,
|
onClose: PropTypes.func.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ORASettings;
|
export default injectIntl(ORASettings);
|
||||||
|
|||||||
@@ -1,155 +1,33 @@
|
|||||||
import {
|
import { shallow } from '@edx/react-unit-test-utils';
|
||||||
render,
|
|
||||||
screen,
|
|
||||||
waitFor,
|
|
||||||
within,
|
|
||||||
} from '@testing-library/react';
|
|
||||||
import ReactDOM from 'react-dom';
|
|
||||||
import { Routes, Route, MemoryRouter } from 'react-router-dom';
|
|
||||||
import { initializeMockApp } from '@edx/frontend-platform';
|
|
||||||
import MockAdapter from 'axios-mock-adapter';
|
|
||||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
|
||||||
import { AppProvider, PageWrap } from '@edx/frontend-platform/react';
|
|
||||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
|
||||||
|
|
||||||
import initializeStore from 'CourseAuthoring/store';
|
|
||||||
import { executeThunk } from 'CourseAuthoring/utils';
|
|
||||||
import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
|
|
||||||
import { getCourseAppsApiUrl, getCourseAdvancedSettingsApiUrl } from 'CourseAuthoring/pages-and-resources/data/api';
|
|
||||||
import { fetchCourseApps, fetchCourseAppSettings } from 'CourseAuthoring/pages-and-resources/data/thunks';
|
|
||||||
import ORASettings from './Settings';
|
import ORASettings from './Settings';
|
||||||
import messages from './messages';
|
|
||||||
import {
|
|
||||||
courseId,
|
|
||||||
inititalState,
|
|
||||||
} from './factories/mockData';
|
|
||||||
|
|
||||||
let axiosMock;
|
jest.mock('@edx/frontend-platform/i18n', () => ({
|
||||||
let store;
|
...jest.requireActual('@edx/frontend-platform/i18n'), // use actual for all non-hook parts
|
||||||
const oraSettingsUrl = `/course/${courseId}/pages-and-resources/live/settings`;
|
injectIntl: (component) => component,
|
||||||
|
intlShape: {},
|
||||||
|
}));
|
||||||
|
jest.mock('yup', () => ({
|
||||||
|
boolean: jest.fn().mockReturnValue('Yub.boolean'),
|
||||||
|
}));
|
||||||
|
jest.mock('CourseAuthoring/generic/model-store', () => ({
|
||||||
|
useModel: jest.fn().mockReturnValue({ documentationLinks: { learnMoreConfiguration: 'https://learnmore.test' } }),
|
||||||
|
}));
|
||||||
|
jest.mock('CourseAuthoring/generic/FormSwitchGroup', () => 'FormSwitchGroup');
|
||||||
|
jest.mock('CourseAuthoring/utils', () => ({
|
||||||
|
useAppSetting: jest.fn().mockReturnValue(['abitrary value', jest.fn().mockName('saveSetting')]),
|
||||||
|
}));
|
||||||
|
jest.mock('CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal', () => 'AppSettingsModal');
|
||||||
|
|
||||||
// Modal creates a portal. Overriding ReactDOM.createPortal allows portals to be tested in jest.
|
const props = {
|
||||||
ReactDOM.createPortal = jest.fn(node => node);
|
onClose: jest.fn().mockName('onClose'),
|
||||||
|
intl: {
|
||||||
const renderComponent = () => (
|
formatMessage: (message) => message.defaultMessage,
|
||||||
render(
|
},
|
||||||
<IntlProvider locale="en">
|
|
||||||
<AppProvider store={store} wrapWithRouter={false}>
|
|
||||||
<PagesAndResourcesProvider courseId={courseId}>
|
|
||||||
<MemoryRouter initialEntries={[oraSettingsUrl]}>
|
|
||||||
<Routes>
|
|
||||||
<Route path={oraSettingsUrl} element={<PageWrap><ORASettings onClose={jest.fn()} /></PageWrap>} />
|
|
||||||
</Routes>
|
|
||||||
</MemoryRouter>
|
|
||||||
</PagesAndResourcesProvider>
|
|
||||||
</AppProvider>
|
|
||||||
</IntlProvider>,
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const mockStore = async ({
|
|
||||||
apiStatus,
|
|
||||||
enabled,
|
|
||||||
}) => {
|
|
||||||
const settings = ['forceOnFlexiblePeerOpenassessments'];
|
|
||||||
const fetchCourseAppsUrl = `${getCourseAppsApiUrl()}/${courseId}`;
|
|
||||||
const fetchAdvancedSettingsUrl = `${getCourseAdvancedSettingsApiUrl()}/${courseId}`;
|
|
||||||
|
|
||||||
axiosMock.onGet(fetchCourseAppsUrl).reply(
|
|
||||||
200,
|
|
||||||
[{
|
|
||||||
allowed_operations: { enable: false, configure: true },
|
|
||||||
description: 'setting',
|
|
||||||
documentation_links: { learnMoreConfiguration: '' },
|
|
||||||
enabled,
|
|
||||||
id: 'ora_settings',
|
|
||||||
name: 'Flexible Peer Grading for ORAs',
|
|
||||||
}],
|
|
||||||
);
|
|
||||||
axiosMock.onGet(fetchAdvancedSettingsUrl).reply(
|
|
||||||
apiStatus,
|
|
||||||
{ force_on_flexible_peer_openassessments: { value: enabled } },
|
|
||||||
);
|
|
||||||
|
|
||||||
await executeThunk(fetchCourseApps(courseId), store.dispatch);
|
|
||||||
await executeThunk(fetchCourseAppSettings(courseId, settings), store.dispatch);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('ORASettings', () => {
|
describe('ORASettings', () => {
|
||||||
beforeEach(async () => {
|
it('should render', () => {
|
||||||
initializeMockApp({
|
const wrapper = shallow(<ORASettings {...props} />);
|
||||||
authenticatedUser: {
|
expect(wrapper.snapshot).toMatchSnapshot();
|
||||||
userId: 3,
|
|
||||||
username: 'abc123',
|
|
||||||
administrator: false,
|
|
||||||
roles: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
store = initializeStore(inititalState);
|
|
||||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Flexible peer grading configuration modal is visible', async () => {
|
|
||||||
renderComponent();
|
|
||||||
expect(screen.getByRole('dialog')).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Displays "Configure Flexible Peer Grading" heading', async () => {
|
|
||||||
renderComponent();
|
|
||||||
const headingElement = screen.getByText(messages.heading.defaultMessage);
|
|
||||||
|
|
||||||
expect(headingElement).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Displays loading component', () => {
|
|
||||||
renderComponent();
|
|
||||||
const loadingElement = screen.getByRole('status');
|
|
||||||
|
|
||||||
expect(within(loadingElement).getByText('Loading...')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Displays Connection Error Alert', async () => {
|
|
||||||
await mockStore({ apiStatus: 404, enabled: true });
|
|
||||||
renderComponent();
|
|
||||||
const errorAlert = screen.getByRole('alert');
|
|
||||||
|
|
||||||
expect(within(errorAlert).getByText('We encountered a technical error when loading this page.', { exact: false })).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Displays Permissions Error Alert', async () => {
|
|
||||||
await mockStore({ apiStatus: 403, enabled: true });
|
|
||||||
renderComponent();
|
|
||||||
const errorAlert = screen.getByRole('alert');
|
|
||||||
|
|
||||||
expect(within(errorAlert).getByText('You are not authorized to view this page', { exact: false })).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Displays title, helper text and badge when flexible peer grading button is enabled', async () => {
|
|
||||||
await mockStore({ apiStatus: 200, enabled: true });
|
|
||||||
renderComponent();
|
|
||||||
|
|
||||||
const checkbox = screen.getByRole('checkbox', { name: /Flex Peer Grading/ });
|
|
||||||
expect(checkbox).toBeChecked();
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
const label = screen.getByText(messages.enableFlexPeerGradeLabel.defaultMessage);
|
|
||||||
const enableBadge = screen.getByTestId('enable-badge');
|
|
||||||
|
|
||||||
expect(label).toBeVisible();
|
|
||||||
|
|
||||||
expect(enableBadge).toHaveTextContent('Enabled');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Displays title, helper text and hides badge when flexible peer grading button is disabled', async () => {
|
|
||||||
renderComponent();
|
|
||||||
await mockStore({ apiStatus: 200, enabled: false });
|
|
||||||
|
|
||||||
const label = await screen.findByText(messages.enableFlexPeerGradeLabel.defaultMessage);
|
|
||||||
const enableBadge = screen.queryByTestId('enable-badge');
|
|
||||||
|
|
||||||
expect(label).toBeVisible();
|
|
||||||
|
|
||||||
expect(enableBadge).toBeNull();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`ORASettings should render 1`] = `
|
||||||
|
<AppSettingsModal
|
||||||
|
appId="ora_settings"
|
||||||
|
hideAppToggle={true}
|
||||||
|
initialValues={
|
||||||
|
Object {
|
||||||
|
"enableFlexiblePeerGrade": "abitrary value",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onClose={[MockFunction onClose]}
|
||||||
|
onSettingsSave={[Function]}
|
||||||
|
title={
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
Configure open response assessment
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
className="pt-3"
|
||||||
|
>
|
||||||
|
<withDeprecatedProps(Hyperlink)
|
||||||
|
className="text-primary-500 small"
|
||||||
|
destination="https://learnmore.test"
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
Learn more about open response assessment settings
|
||||||
|
</withDeprecatedProps(Hyperlink)>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
validationSchema={
|
||||||
|
Object {
|
||||||
|
"enableFlexiblePeerGrade": "Yub.boolean",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
[Function]
|
||||||
|
</AppSettingsModal>
|
||||||
|
`;
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
export const courseId = 'course-v1:org+num+run';
|
|
||||||
|
|
||||||
export const inititalState = {
|
|
||||||
courseDetail: {
|
|
||||||
courseId,
|
|
||||||
status: 'successful',
|
|
||||||
},
|
|
||||||
pagesAndResources: {
|
|
||||||
courseAppIds: ['ora_settings'],
|
|
||||||
loadingStatus: 'in-progress',
|
|
||||||
savingStatus: '',
|
|
||||||
courseAppsApiStatus: {},
|
|
||||||
courseAppSettings: {},
|
|
||||||
},
|
|
||||||
models: {
|
|
||||||
courseApps: {
|
|
||||||
ora_settings: {
|
|
||||||
id: 'ora_settings',
|
|
||||||
name: 'Flexible Peer Grading',
|
|
||||||
enabled: true,
|
|
||||||
description: 'Enable flexible peer grading',
|
|
||||||
allowedOperations: {
|
|
||||||
enable: false,
|
|
||||||
configure: true,
|
|
||||||
},
|
|
||||||
documentationLinks: {
|
|
||||||
learnMoreConfiguration: '',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
22
plugins/course-apps/ora_settings/messages.js
Normal file
22
plugins/course-apps/ora_settings/messages.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
heading: {
|
||||||
|
id: 'course-authoring.pages-resources.ora.heading',
|
||||||
|
defaultMessage: 'Configure open response assessment',
|
||||||
|
},
|
||||||
|
ORASettingsHelpLink: {
|
||||||
|
id: 'course-authoring.pages-resources.ora.flex-peer-grading.link',
|
||||||
|
defaultMessage: 'Learn more about open response assessment settings',
|
||||||
|
},
|
||||||
|
enableFlexPeerGradeLabel: {
|
||||||
|
id: 'course-authoring.pages-resources.ora.flex-peer-grading.label',
|
||||||
|
defaultMessage: 'Flex Peer Grading',
|
||||||
|
},
|
||||||
|
enableFlexPeerGradeHelp: {
|
||||||
|
id: 'course-authoring.pages-resources.ora.flex-peer-grading.help',
|
||||||
|
defaultMessage: 'Turn on Flexible Peer Grading for all open response assessments in the course with peer grading.',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default messages;
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
heading: {
|
|
||||||
id: 'course-authoring.pages-resources.ora.heading',
|
|
||||||
defaultMessage: 'Configure Flexible Peer Grading',
|
|
||||||
description: 'Title for the modal dialog header',
|
|
||||||
},
|
|
||||||
ORASettingsHelpLink: {
|
|
||||||
id: 'course-authoring.pages-resources.ora.flex-peer-grading.link',
|
|
||||||
defaultMessage: 'Learn more about open response assessment settings',
|
|
||||||
description: 'Descriptive text for the hyperlink to the docs site',
|
|
||||||
},
|
|
||||||
enableFlexPeerGradeLabel: {
|
|
||||||
id: 'course-authoring.pages-resources.ora.flex-peer-grading.label',
|
|
||||||
defaultMessage: 'Flex Peer Grading',
|
|
||||||
description: 'Label for form switch',
|
|
||||||
},
|
|
||||||
enableFlexPeerGradeHelp: {
|
|
||||||
id: 'course-authoring.pages-resources.ora.flex-peer-grading.help',
|
|
||||||
defaultMessage: 'Turn on Flexible Peer Grading for all open response assessments in the course with peer grading.',
|
|
||||||
description: 'Help text describing what happens when the switch is enabled',
|
|
||||||
},
|
|
||||||
enabledBadgeLabel: {
|
|
||||||
id: 'course-authoring.pages-resources.ora.flex-peer-grading.enabled-badge.label',
|
|
||||||
defaultMessage: 'Enabled',
|
|
||||||
description: 'Label for badge that show users that a setting is enabled',
|
|
||||||
},
|
|
||||||
cancelLabel: {
|
|
||||||
id: 'course-authoring.pages-resources.ora.flex-peer-grading.cancel-button.label',
|
|
||||||
defaultMessage: 'Cancel',
|
|
||||||
description: 'Label for button that cancels user changes',
|
|
||||||
},
|
|
||||||
saveLabel: {
|
|
||||||
id: 'course-authoring.pages-resources.ora.flex-peer-grading.save-button.label',
|
|
||||||
defaultMessage: 'Save',
|
|
||||||
description: 'Label for button that saves user changes',
|
|
||||||
},
|
|
||||||
pendingSaveLabel: {
|
|
||||||
id: 'course-authoring.pages-resources.ora.flex-peer-grading.pending-save-button.label',
|
|
||||||
defaultMessage: 'Saving',
|
|
||||||
description: 'Label for button that has pending api save calls',
|
|
||||||
},
|
|
||||||
errorSavingTitle: {
|
|
||||||
id: 'course-authoring.pages-resources.ora.flex-peer-grading.save-error.title',
|
|
||||||
defaultMessage: 'We couldn\'t apply your changes.',
|
|
||||||
},
|
|
||||||
errorSavingMessage: {
|
|
||||||
id: 'course-authoring.pages-resources.ora.flex-peer-grading.save-error.message',
|
|
||||||
defaultMessage: 'Please check your entries and try again.',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default messages;
|
|
||||||
@@ -3,16 +3,15 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "Open Response Assessment configuration for courses using it",
|
"description": "Open Response Assessment configuration for courses using it",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@edx/frontend-app-authoring": "*",
|
"@edx/frontend-app-course-authoring": "*",
|
||||||
"@edx/frontend-platform": "*",
|
"@edx/frontend-platform": "*",
|
||||||
"@openedx/paragon": "*",
|
"@openedx/paragon": "*",
|
||||||
"prop-types": "*",
|
"prop-types": "*",
|
||||||
"react": "*",
|
"react": "*",
|
||||||
"react-redux": "*",
|
|
||||||
"yup": "*"
|
"yup": "*"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@edx/frontend-app-authoring": {
|
"@edx/frontend-app-course-authoring": {
|
||||||
"optional": true
|
"optional": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import PropTypes from 'prop-types';
|
|||||||
|
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
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 {
|
import {
|
||||||
ActionRow, Alert, Badge, Form, Hyperlink, ModalDialog, StatefulButton,
|
ActionRow, Alert, Badge, Form, Hyperlink, ModalDialog, StatefulButton,
|
||||||
} from '@openedx/paragon';
|
} from '@openedx/paragon';
|
||||||
@@ -22,17 +22,16 @@ import { useModel } from 'CourseAuthoring/generic/model-store';
|
|||||||
import PermissionDeniedAlert from 'CourseAuthoring/generic/PermissionDeniedAlert';
|
import PermissionDeniedAlert from 'CourseAuthoring/generic/PermissionDeniedAlert';
|
||||||
import { useIsMobile } from 'CourseAuthoring/utils';
|
import { useIsMobile } from 'CourseAuthoring/utils';
|
||||||
import { PagesAndResourcesContext } from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
|
import { PagesAndResourcesContext } from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
|
||||||
import { useCourseAuthoringContext } from 'CourseAuthoring/CourseAuthoringContext';
|
|
||||||
|
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
|
|
||||||
const ProctoringSettings = ({ onClose }) => {
|
const ProctoringSettings = ({ intl, onClose }) => {
|
||||||
const intl = useIntl();
|
|
||||||
const initialFormValues = {
|
const initialFormValues = {
|
||||||
enableProctoredExams: false,
|
enableProctoredExams: false,
|
||||||
proctoringProvider: false,
|
proctoringProvider: false,
|
||||||
escalationEmail: '',
|
escalationEmail: '',
|
||||||
allowOptingOut: false,
|
allowOptingOut: false,
|
||||||
|
createZendeskTickets: false,
|
||||||
};
|
};
|
||||||
const [formValues, setFormValues] = useState(initialFormValues);
|
const [formValues, setFormValues] = useState(initialFormValues);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -41,7 +40,6 @@ const ProctoringSettings = ({ onClose }) => {
|
|||||||
const [loadingPermissionError, setLoadingPermissionError] = useState(false);
|
const [loadingPermissionError, setLoadingPermissionError] = useState(false);
|
||||||
const [allowLtiProviders, setAllowLtiProviders] = useState(false);
|
const [allowLtiProviders, setAllowLtiProviders] = useState(false);
|
||||||
const [availableProctoringProviders, setAvailableProctoringProviders] = useState([]);
|
const [availableProctoringProviders, setAvailableProctoringProviders] = useState([]);
|
||||||
const [requiresEscalationEmailProviders, setRequiresEscalationEmailProviders] = useState([]);
|
|
||||||
const [ltiProctoringProviders, setLtiProctoringProviders] = useState([]);
|
const [ltiProctoringProviders, setLtiProctoringProviders] = useState([]);
|
||||||
const [courseStartDate, setCourseStartDate] = useState('');
|
const [courseStartDate, setCourseStartDate] = useState('');
|
||||||
const [saveSuccess, setSaveSuccess] = useState(false);
|
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||||
@@ -66,8 +64,6 @@ const ProctoringSettings = ({ onClose }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { courseId } = useContext(PagesAndResourcesContext);
|
const { courseId } = useContext(PagesAndResourcesContext);
|
||||||
const { courseDetails } = useCourseAuthoringContext();
|
|
||||||
const org = courseDetails?.org;
|
|
||||||
const appInfo = useModel('courseApps', 'proctoring');
|
const appInfo = useModel('courseApps', 'proctoring');
|
||||||
const alertRef = React.createRef();
|
const alertRef = React.createRef();
|
||||||
const saveStatusAlertRef = React.createRef();
|
const saveStatusAlertRef = React.createRef();
|
||||||
@@ -79,14 +75,18 @@ const ProctoringSettings = ({ onClose }) => {
|
|||||||
const value = target.type === 'checkbox' ? target.checked : target.value;
|
const value = target.type === 'checkbox' ? target.checked : target.value;
|
||||||
const { name } = target;
|
const { name } = target;
|
||||||
|
|
||||||
if (['allowOptingOut'].includes(name)) {
|
if (['allowOptingOut', 'createZendeskTickets'].includes(name)) {
|
||||||
// Form.Radio expects string values, so convert back to a boolean here
|
// Form.Radio expects string values, so convert back to a boolean here
|
||||||
setFormValues({ ...formValues, [name]: value === 'true' });
|
setFormValues({ ...formValues, [name]: value === 'true' });
|
||||||
} else if (name === 'proctoringProvider') {
|
} else if (name === 'proctoringProvider') {
|
||||||
const newFormValues = { ...formValues, proctoringProvider: value };
|
const newFormValues = { ...formValues, proctoringProvider: value };
|
||||||
if (requiresEscalationEmailProviders.includes(value)) {
|
|
||||||
setFormValues({ ...newFormValues });
|
if (value === 'proctortrack') {
|
||||||
|
setFormValues({ ...newFormValues, createZendeskTickets: false });
|
||||||
setShowEscalationEmail(true);
|
setShowEscalationEmail(true);
|
||||||
|
} else if (value === 'software_secure') {
|
||||||
|
setFormValues({ ...newFormValues, createZendeskTickets: true });
|
||||||
|
setShowEscalationEmail(false);
|
||||||
} else if (isLtiProvider(value)) {
|
} else if (isLtiProvider(value)) {
|
||||||
setFormValues(newFormValues);
|
setFormValues(newFormValues);
|
||||||
setShowEscalationEmail(true);
|
setShowEscalationEmail(true);
|
||||||
@@ -113,13 +113,14 @@ const ProctoringSettings = ({ onClose }) => {
|
|||||||
enable_proctored_exams: formValues.enableProctoredExams,
|
enable_proctored_exams: formValues.enableProctoredExams,
|
||||||
// lti providers are managed outside edx-platform, lti_external indicates this
|
// lti providers are managed outside edx-platform, lti_external indicates this
|
||||||
proctoring_provider: isLtiProviderSelected ? 'lti_external' : selectedProvider,
|
proctoring_provider: isLtiProviderSelected ? 'lti_external' : selectedProvider,
|
||||||
|
create_zendesk_tickets: formValues.createZendeskTickets,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
if (isEdxStaff) {
|
if (isEdxStaff) {
|
||||||
studioDataToPostBack.proctored_exam_settings.allow_proctoring_opt_out = formValues.allowOptingOut;
|
studioDataToPostBack.proctored_exam_settings.allow_proctoring_opt_out = formValues.allowOptingOut;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (requiresEscalationEmailProviders.includes(formValues.proctoringProvider)) {
|
if (formValues.proctoringProvider === 'proctortrack') {
|
||||||
studioDataToPostBack.proctored_exam_settings.proctoring_escalation_email = formValues.escalationEmail === '' ? null : formValues.escalationEmail;
|
studioDataToPostBack.proctored_exam_settings.proctoring_escalation_email = formValues.escalationEmail === '' ? null : formValues.escalationEmail;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,9 +146,9 @@ const ProctoringSettings = ({ onClose }) => {
|
|||||||
setSaveSuccess(true);
|
setSaveSuccess(true);
|
||||||
setSaveError(false);
|
setSaveError(false);
|
||||||
setSubmissionInProgress(false);
|
setSubmissionInProgress(false);
|
||||||
}).catch((error) => {
|
}).catch(() => {
|
||||||
setSaveSuccess(false);
|
setSaveSuccess(false);
|
||||||
setSaveError(error);
|
setSaveError(true);
|
||||||
setSubmissionInProgress(false);
|
setSubmissionInProgress(false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -156,7 +157,7 @@ const ProctoringSettings = ({ onClose }) => {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const isLtiProviderSelected = isLtiProvider(formValues.proctoringProvider);
|
const isLtiProviderSelected = isLtiProvider(formValues.proctoringProvider);
|
||||||
if (
|
if (
|
||||||
(requiresEscalationEmailProviders.includes(formValues.proctoringProvider) || isLtiProviderSelected)
|
(formValues.proctoringProvider === 'proctortrack' || isLtiProviderSelected)
|
||||||
&& !EmailValidator.validate(formValues.escalationEmail)
|
&& !EmailValidator.validate(formValues.escalationEmail)
|
||||||
&& !(formValues.escalationEmail === '' && !formValues.enableProctoredExams)
|
&& !(formValues.escalationEmail === '' && !formValues.enableProctoredExams)
|
||||||
) {
|
) {
|
||||||
@@ -383,6 +384,29 @@ const ProctoringSettings = ({ onClose }) => {
|
|||||||
</Form.Group>
|
</Form.Group>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* CREATE ZENDESK TICKETS */}
|
||||||
|
{ isEdxStaff && formValues.enableProctoredExams && !isLtiProviderSelected && (
|
||||||
|
<fieldset aria-describedby="createZendeskTicketsText">
|
||||||
|
<Form.Group controlId="formCreateZendeskTickets">
|
||||||
|
<Form.Label as="legend" className="font-weight-bold">
|
||||||
|
{intl.formatMessage(messages['authoring.proctoring.createzendesk.label'])}
|
||||||
|
</Form.Label>
|
||||||
|
<Form.RadioSet
|
||||||
|
name="createZendeskTickets"
|
||||||
|
value={formValues.createZendeskTickets.toString()}
|
||||||
|
onChange={handleChange}
|
||||||
|
>
|
||||||
|
<Form.Radio value="true" data-testid="createZendeskTicketsYes">
|
||||||
|
{intl.formatMessage(messages['authoring.proctoring.yes'])}
|
||||||
|
</Form.Radio>
|
||||||
|
<Form.Radio value="false" data-testid="createZendeskTicketsNo">
|
||||||
|
{intl.formatMessage(messages['authoring.proctoring.no'])}
|
||||||
|
</Form.Radio>
|
||||||
|
</Form.RadioSet>
|
||||||
|
</Form.Group>
|
||||||
|
</fieldset>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -434,44 +458,6 @@ const ProctoringSettings = ({ onClose }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderSaveError() {
|
function renderSaveError() {
|
||||||
let errorMessage = (
|
|
||||||
<FormattedMessage
|
|
||||||
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.
|
|
||||||
`}
|
|
||||||
values={{
|
|
||||||
support_link: (
|
|
||||||
<Alert.Link href={getConfig().SUPPORT_URL}>
|
|
||||||
{intl.formatMessage(messages['authoring.proctoring.support.text'])}
|
|
||||||
</Alert.Link>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (saveError?.response.status === 403) {
|
|
||||||
errorMessage = (
|
|
||||||
<FormattedMessage
|
|
||||||
id="authoring.proctoring.alert.error.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.
|
|
||||||
`}
|
|
||||||
values={{
|
|
||||||
support_link: (
|
|
||||||
<Alert.Link href={getConfig().SUPPORT_URL}>
|
|
||||||
{intl.formatMessage(messages['authoring.proctoring.support.text'])}
|
|
||||||
</Alert.Link>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Alert
|
<Alert
|
||||||
variant="danger"
|
variant="danger"
|
||||||
@@ -481,7 +467,21 @@ const ProctoringSettings = ({ onClose }) => {
|
|||||||
onClose={() => setSaveError(false)}
|
onClose={() => setSaveError(false)}
|
||||||
dismissible
|
dismissible
|
||||||
>
|
>
|
||||||
{errorMessage}
|
<FormattedMessage
|
||||||
|
id="authoring.examsettings.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.
|
||||||
|
`}
|
||||||
|
values={{
|
||||||
|
support_link: (
|
||||||
|
<Alert.Link href={getConfig().SUPPORT_URL}>
|
||||||
|
{intl.formatMessage(messages['authoring.proctoring.support.text'])}
|
||||||
|
</Alert.Link>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Alert>
|
</Alert>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -490,7 +490,7 @@ const ProctoringSettings = ({ onClose }) => {
|
|||||||
Promise.all([
|
Promise.all([
|
||||||
StudioApiService.getProctoredExamSettingsData(courseId),
|
StudioApiService.getProctoredExamSettingsData(courseId),
|
||||||
ExamsApiService.isAvailable() ? ExamsApiService.getCourseExamConfiguration(courseId) : Promise.resolve(),
|
ExamsApiService.isAvailable() ? ExamsApiService.getCourseExamConfiguration(courseId) : Promise.resolve(),
|
||||||
ExamsApiService.isAvailable() ? ExamsApiService.getAvailableProviders(org) : Promise.resolve(),
|
ExamsApiService.isAvailable() ? ExamsApiService.getAvailableProviders() : Promise.resolve(),
|
||||||
])
|
])
|
||||||
.then(
|
.then(
|
||||||
([settingsResponse, examConfigResponse, ltiProvidersResponse]) => {
|
([settingsResponse, examConfigResponse, ltiProvidersResponse]) => {
|
||||||
@@ -500,7 +500,6 @@ const ProctoringSettings = ({ onClose }) => {
|
|||||||
setSubmissionInProgress(false);
|
setSubmissionInProgress(false);
|
||||||
setCourseStartDate(settingsResponse.data.course_start_date);
|
setCourseStartDate(settingsResponse.data.course_start_date);
|
||||||
setAvailableProctoringProviders(settingsResponse.data.available_proctoring_providers);
|
setAvailableProctoringProviders(settingsResponse.data.available_proctoring_providers);
|
||||||
setRequiresEscalationEmailProviders(settingsResponse.data.requires_escalation_email_providers);
|
|
||||||
|
|
||||||
// The list of providers returned by studio settings are the default behavior. If lti_external
|
// The list of providers returned by studio settings are the default behavior. If lti_external
|
||||||
// is available as an option display the list of LTI providers returned by the exam service.
|
// is available as an option display the list of LTI providers returned by the exam service.
|
||||||
@@ -528,11 +527,10 @@ const ProctoringSettings = ({ onClose }) => {
|
|||||||
selectedProvider = proctoredExamSettings.proctoring_provider;
|
selectedProvider = proctoredExamSettings.proctoring_provider;
|
||||||
}
|
}
|
||||||
|
|
||||||
const requiresEscalationEmailProvidersList = settingsResponse.data.requires_escalation_email_providers;
|
const isProctortrack = selectedProvider === 'proctortrack';
|
||||||
const isEscalationEmailRequired = requiresEscalationEmailProvidersList.includes(selectedProvider);
|
|
||||||
const ltiProviderSelected = proctoringProvidersLti.some(p => p.name === selectedProvider);
|
const ltiProviderSelected = proctoringProvidersLti.some(p => p.name === selectedProvider);
|
||||||
|
|
||||||
if (isEscalationEmailRequired || ltiProviderSelected) {
|
if (isProctortrack || ltiProviderSelected) {
|
||||||
setShowEscalationEmail(true);
|
setShowEscalationEmail(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -545,6 +543,7 @@ const ProctoringSettings = ({ onClose }) => {
|
|||||||
proctoringProvider: selectedProvider,
|
proctoringProvider: selectedProvider,
|
||||||
enableProctoredExams: proctoredExamSettings.enable_proctored_exams,
|
enableProctoredExams: proctoredExamSettings.enable_proctored_exams,
|
||||||
allowOptingOut: proctoredExamSettings.allow_proctoring_opt_out,
|
allowOptingOut: proctoredExamSettings.allow_proctoring_opt_out,
|
||||||
|
createZendeskTickets: proctoredExamSettings.create_zendesk_tickets,
|
||||||
// The backend API may return null for the proctoringEscalationEmail value, which is the default.
|
// The backend API may return null for the proctoringEscalationEmail value, which is the default.
|
||||||
// In order to keep our email input component controlled, we use the empty string as the default
|
// In order to keep our email input component controlled, we use the empty string as the default
|
||||||
// and perform this conversion during GETs and POSTs.
|
// and perform this conversion during GETs and POSTs.
|
||||||
@@ -627,9 +626,10 @@ const ProctoringSettings = ({ onClose }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
ProctoringSettings.propTypes = {
|
ProctoringSettings.propTypes = {
|
||||||
|
intl: intlShape.isRequired,
|
||||||
onClose: PropTypes.func.isRequired,
|
onClose: PropTypes.func.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
ProctoringSettings.defaultProps = {};
|
ProctoringSettings.defaultProps = {};
|
||||||
|
|
||||||
export default ProctoringSettings;
|
export default injectIntl(ProctoringSettings);
|
||||||
|
|||||||
@@ -1,73 +1,69 @@
|
|||||||
|
import React from 'react';
|
||||||
import {
|
import {
|
||||||
render, screen, cleanup, waitFor, fireEvent, act,
|
render, screen, cleanup, waitFor, fireEvent, act,
|
||||||
initializeMocks,
|
} from '@testing-library/react';
|
||||||
} from 'CourseAuthoring/testUtils';
|
import MockAdapter from 'axios-mock-adapter';
|
||||||
|
|
||||||
import { mergeConfig } from '@edx/frontend-platform';
|
import { initializeMockApp, mergeConfig } from '@edx/frontend-platform';
|
||||||
|
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||||
|
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
|
||||||
|
import { AppProvider } from '@edx/frontend-platform/react';
|
||||||
|
|
||||||
import StudioApiService from 'CourseAuthoring/data/services/StudioApiService';
|
import StudioApiService from 'CourseAuthoring/data/services/StudioApiService';
|
||||||
import ExamsApiService from 'CourseAuthoring/data/services/ExamsApiService';
|
import ExamsApiService from 'CourseAuthoring/data/services/ExamsApiService';
|
||||||
|
import initializeStore from 'CourseAuthoring/store';
|
||||||
import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
|
import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
|
||||||
import { CourseAuthoringProvider } from 'CourseAuthoring/CourseAuthoringContext';
|
|
||||||
import { getCourseDetailsUrl } from 'CourseAuthoring/data/api';
|
|
||||||
import ProctoredExamSettings from './Settings';
|
import ProctoredExamSettings from './Settings';
|
||||||
|
|
||||||
const courseId = 'course-v1%3AedX%2BDemoX%2BDemo_Course';
|
|
||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
courseId,
|
courseId: 'course-v1%3AedX%2BDemoX%2BDemo_Course',
|
||||||
onClose: () => {},
|
onClose: () => {},
|
||||||
};
|
};
|
||||||
|
const IntlProctoredExamSettings = injectIntl(ProctoredExamSettings);
|
||||||
|
let store;
|
||||||
|
|
||||||
const renderComponent = children => (
|
const intlWrapper = children => (
|
||||||
<CourseAuthoringProvider courseId={defaultProps.courseId}>
|
<AppProvider store={store}>
|
||||||
<PagesAndResourcesProvider courseId={defaultProps.courseId}>
|
<PagesAndResourcesProvider courseId={defaultProps.courseId}>
|
||||||
{children}
|
<IntlProvider locale="en">
|
||||||
|
{children}
|
||||||
|
</IntlProvider>
|
||||||
</PagesAndResourcesProvider>
|
</PagesAndResourcesProvider>
|
||||||
</CourseAuthoringProvider>
|
</AppProvider>
|
||||||
);
|
);
|
||||||
let axiosMock;
|
let axiosMock;
|
||||||
|
|
||||||
describe('ProctoredExamSettings', () => {
|
describe('ProctoredExamSettings', () => {
|
||||||
/**
|
function setupApp(isAdmin = true) {
|
||||||
* @param {boolean} isAdmin
|
|
||||||
* @param {string | undefined} org
|
|
||||||
*/
|
|
||||||
function setupApp(isAdmin = true, org = undefined) {
|
|
||||||
mergeConfig({
|
mergeConfig({
|
||||||
EXAMS_BASE_URL: 'http://exams.testing.co',
|
EXAMS_BASE_URL: 'http://exams.testing.co',
|
||||||
}, 'CourseAuthoringConfig');
|
}, 'CourseAuthoringConfig');
|
||||||
const user = {
|
|
||||||
userId: 3,
|
initializeMockApp({
|
||||||
username: 'abc123',
|
authenticatedUser: {
|
||||||
administrator: isAdmin,
|
userId: 3,
|
||||||
roles: [],
|
username: 'abc123',
|
||||||
};
|
administrator: isAdmin,
|
||||||
const mocks = initializeMocks({
|
roles: [],
|
||||||
user,
|
},
|
||||||
initialState: {
|
});
|
||||||
models: {
|
store = initializeStore({
|
||||||
courseApps: {
|
models: {
|
||||||
proctoring: {},
|
courseApps: {
|
||||||
},
|
proctoring: {},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
axiosMock = mocks.axiosMock;
|
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||||
axiosMock
|
axiosMock.onGet(
|
||||||
.onGet(getCourseDetailsUrl(courseId, user.username))
|
`${ExamsApiService.getExamsBaseUrl()}/api/v1/providers`,
|
||||||
.reply(200, {
|
).reply(200, [
|
||||||
courseId,
|
{
|
||||||
name: 'Course Test',
|
name: 'test_lti',
|
||||||
start: Date(),
|
verbose_name: 'LTI Provider',
|
||||||
...(org ? { org } : {}),
|
},
|
||||||
});
|
]);
|
||||||
axiosMock.onGet(`${ExamsApiService.getExamsBaseUrl()}/api/v1/providers`)
|
|
||||||
.reply(200, [{ name: 'test_lti', verbose_name: 'LTI Provider' }]);
|
|
||||||
if (org) {
|
|
||||||
axiosMock.onGet(`${ExamsApiService.getExamsBaseUrl()}/api/v1/providers?org=${org}`)
|
|
||||||
.reply(200, [{ name: 'test_lti', verbose_name: 'LTI Provider' }]);
|
|
||||||
}
|
|
||||||
axiosMock.onGet(
|
axiosMock.onGet(
|
||||||
`${ExamsApiService.getExamsBaseUrl()}/api/v1/configs/course_id/${defaultProps.courseId}`,
|
`${ExamsApiService.getExamsBaseUrl()}/api/v1/configs/course_id/${defaultProps.courseId}`,
|
||||||
).reply(200, {
|
).reply(200, {
|
||||||
@@ -82,20 +78,60 @@ describe('ProctoredExamSettings', () => {
|
|||||||
allow_proctoring_opt_out: false,
|
allow_proctoring_opt_out: false,
|
||||||
proctoring_provider: 'mockproc',
|
proctoring_provider: 'mockproc',
|
||||||
proctoring_escalation_email: 'test@example.com',
|
proctoring_escalation_email: 'test@example.com',
|
||||||
|
create_zendesk_tickets: true,
|
||||||
},
|
},
|
||||||
available_proctoring_providers: ['software_secure', 'mockproc', 'lti_external'],
|
available_proctoring_providers: ['software_secure', 'proctortrack', 'mockproc', 'lti_external'],
|
||||||
requires_escalation_email_providers: ['test_lti'],
|
|
||||||
course_start_date: '2070-01-01T00:00:00Z',
|
course_start_date: '2070-01-01T00:00:00Z',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
axiosMock.reset();
|
||||||
|
});
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
setupApp();
|
setupApp();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Field dependencies', () => {
|
describe('Field dependencies', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
|
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Updates Zendesk ticket field if proctortrack is provider', async () => {
|
||||||
|
await waitFor(() => {
|
||||||
|
screen.getByDisplayValue('mockproc');
|
||||||
|
});
|
||||||
|
const selectElement = screen.getByDisplayValue('mockproc');
|
||||||
|
await act(async () => {
|
||||||
|
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');
|
||||||
|
await act(async () => {
|
||||||
|
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');
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.change(selectElement, { target: { value: 'mockproc' } });
|
||||||
|
});
|
||||||
|
const zendeskTicketInput = screen.getByTestId('createZendeskTicketsYes');
|
||||||
|
expect(zendeskTicketInput.checked).toEqual(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Hides all other fields when enabledProctorExam is false when first loaded', async () => {
|
it('Hides all other fields when enabledProctorExam is false when first loaded', async () => {
|
||||||
@@ -109,13 +145,13 @@ describe('ProctoredExamSettings', () => {
|
|||||||
allow_proctoring_opt_out: false,
|
allow_proctoring_opt_out: false,
|
||||||
proctoring_provider: 'mockproc',
|
proctoring_provider: 'mockproc',
|
||||||
proctoring_escalation_email: 'test@example.com',
|
proctoring_escalation_email: 'test@example.com',
|
||||||
|
create_zendesk_tickets: true,
|
||||||
},
|
},
|
||||||
available_proctoring_providers: ['software_secure', 'mockproc'],
|
available_proctoring_providers: ['software_secure', 'proctortrack', 'mockproc'],
|
||||||
requires_escalation_email_providers: [],
|
|
||||||
course_start_date: '2070-01-01T00:00:00Z',
|
course_start_date: '2070-01-01T00:00:00Z',
|
||||||
});
|
});
|
||||||
|
|
||||||
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
|
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
screen.getByText('Proctored exams');
|
screen.getByText('Proctored exams');
|
||||||
});
|
});
|
||||||
@@ -124,6 +160,8 @@ describe('ProctoredExamSettings', () => {
|
|||||||
expect(screen.queryByText('Allow Opting Out of Proctored Exams')).toBeNull();
|
expect(screen.queryByText('Allow Opting Out of Proctored Exams')).toBeNull();
|
||||||
expect(screen.queryByDisplayValue('mockproc')).toBeNull();
|
expect(screen.queryByDisplayValue('mockproc')).toBeNull();
|
||||||
expect(screen.queryByTestId('escalationEmail')).toBeNull();
|
expect(screen.queryByTestId('escalationEmail')).toBeNull();
|
||||||
|
expect(screen.queryByTestId('createZendeskTicketsYes')).toBeNull();
|
||||||
|
expect(screen.queryByTestId('createZendeskTicketsNo')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Hides all other fields when enableProctoredExams toggled to false', async () => {
|
it('Hides all other fields when enableProctoredExams toggled to false', async () => {
|
||||||
@@ -133,15 +171,21 @@ describe('ProctoredExamSettings', () => {
|
|||||||
expect(screen.queryByText('Allow opting out of proctored exams')).toBeDefined();
|
expect(screen.queryByText('Allow opting out of proctored exams')).toBeDefined();
|
||||||
expect(screen.queryByDisplayValue('mockproc')).toBeDefined();
|
expect(screen.queryByDisplayValue('mockproc')).toBeDefined();
|
||||||
expect(screen.queryByTestId('escalationEmail')).toBeDefined();
|
expect(screen.queryByTestId('escalationEmail')).toBeDefined();
|
||||||
|
expect(screen.queryByTestId('createZendeskTicketsYes')).toBeDefined();
|
||||||
|
expect(screen.queryByTestId('createZendeskTicketsNo')).toBeDefined();
|
||||||
|
|
||||||
let enabledProctoredExamCheck = screen.getAllByLabelText('Proctored exams', { exact: false })[0];
|
let enabledProctoredExamCheck = screen.getAllByLabelText('Proctored exams', { exact: false })[0];
|
||||||
expect(enabledProctoredExamCheck.checked).toEqual(true);
|
expect(enabledProctoredExamCheck.checked).toEqual(true);
|
||||||
fireEvent.click(enabledProctoredExamCheck, { target: { value: false } });
|
await act(async () => {
|
||||||
|
fireEvent.click(enabledProctoredExamCheck, { target: { value: false } });
|
||||||
|
});
|
||||||
enabledProctoredExamCheck = screen.getByLabelText('Proctored exams');
|
enabledProctoredExamCheck = screen.getByLabelText('Proctored exams');
|
||||||
expect(enabledProctoredExamCheck.checked).toEqual(false);
|
expect(enabledProctoredExamCheck.checked).toEqual(false);
|
||||||
expect(screen.queryByText('Allow opting out of proctored exams')).toBeNull();
|
expect(screen.queryByText('Allow opting out of proctored exams')).toBeNull();
|
||||||
expect(screen.queryByDisplayValue('mockproc')).toBeNull();
|
expect(screen.queryByDisplayValue('mockproc')).toBeNull();
|
||||||
expect(screen.queryByTestId('escalationEmail')).toBeNull();
|
expect(screen.queryByTestId('escalationEmail')).toBeNull();
|
||||||
|
expect(screen.queryByTestId('createZendeskTicketsYes')).toBeNull();
|
||||||
|
expect(screen.queryByTestId('createZendeskTicketsNo')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Hides unsupported fields when lti provider is selected', async () => {
|
it('Hides unsupported fields when lti provider is selected', async () => {
|
||||||
@@ -149,13 +193,17 @@ describe('ProctoredExamSettings', () => {
|
|||||||
screen.getByDisplayValue('mockproc');
|
screen.getByDisplayValue('mockproc');
|
||||||
});
|
});
|
||||||
const selectElement = screen.getByDisplayValue('mockproc');
|
const selectElement = screen.getByDisplayValue('mockproc');
|
||||||
fireEvent.change(selectElement, { target: { value: 'test_lti' } });
|
await act(async () => {
|
||||||
|
fireEvent.change(selectElement, { target: { value: 'test_lti' } });
|
||||||
|
});
|
||||||
expect(screen.queryByTestId('allowOptingOutRadio')).toBeNull();
|
expect(screen.queryByTestId('allowOptingOutRadio')).toBeNull();
|
||||||
|
expect(screen.queryByTestId('createZendeskTicketsYes')).toBeNull();
|
||||||
|
expect(screen.queryByTestId('createZendeskTicketsNo')).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Validation with invalid escalation email', () => {
|
describe('Validation with invalid escalation email', () => {
|
||||||
const proctoringProvidersRequiringEscalationEmail = ['test_lti'];
|
const proctoringProvidersRequiringEscalationEmail = ['proctortrack', 'test_lti'];
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
axiosMock.onGet(
|
axiosMock.onGet(
|
||||||
@@ -164,21 +212,14 @@ describe('ProctoredExamSettings', () => {
|
|||||||
proctored_exam_settings: {
|
proctored_exam_settings: {
|
||||||
enable_proctored_exams: true,
|
enable_proctored_exams: true,
|
||||||
allow_proctoring_opt_out: false,
|
allow_proctoring_opt_out: false,
|
||||||
proctoring_provider: 'lti_external',
|
proctoring_provider: 'proctortrack',
|
||||||
proctoring_escalation_email: 'test@example.com',
|
proctoring_escalation_email: 'test@example.com',
|
||||||
|
create_zendesk_tickets: true,
|
||||||
},
|
},
|
||||||
available_proctoring_providers: ['software_secure', 'mockproc', 'lti_external'],
|
available_proctoring_providers: ['software_secure', 'proctortrack', 'mockproc', 'lti_external'],
|
||||||
requires_escalation_email_providers: ['test_lti'],
|
|
||||||
course_start_date: '2070-01-01T00:00:00Z',
|
course_start_date: '2070-01-01T00:00:00Z',
|
||||||
});
|
});
|
||||||
|
|
||||||
axiosMock.onGet(
|
|
||||||
`${ExamsApiService.getExamsBaseUrl()}/api/v1/configs/course_id/${defaultProps.courseId}`,
|
|
||||||
).reply(200, {
|
|
||||||
provider: 'test_lti',
|
|
||||||
escalation_email: 'test@example.com',
|
|
||||||
});
|
|
||||||
|
|
||||||
axiosMock.onPatch(
|
axiosMock.onPatch(
|
||||||
ExamsApiService.getExamConfigurationUrl(defaultProps.courseId),
|
ExamsApiService.getExamConfigurationUrl(defaultProps.courseId),
|
||||||
).reply(204, {});
|
).reply(204, {});
|
||||||
@@ -187,18 +228,22 @@ describe('ProctoredExamSettings', () => {
|
|||||||
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
|
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
|
||||||
).reply(200, {});
|
).reply(200, {});
|
||||||
|
|
||||||
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
|
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||||
});
|
});
|
||||||
|
|
||||||
proctoringProvidersRequiringEscalationEmail.forEach(provider => {
|
proctoringProvidersRequiringEscalationEmail.forEach(provider => {
|
||||||
it(`Creates an alert when no proctoring escalation email is provided with ${provider} selected`, async () => {
|
it(`Creates an alert when no proctoring escalation email is provided with ${provider} selected`, async () => {
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
screen.getByDisplayValue('LTI Provider');
|
screen.getByDisplayValue('proctortrack');
|
||||||
});
|
});
|
||||||
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
|
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
|
||||||
fireEvent.change(selectEscalationEmailElement, { target: { value: '' } });
|
await act(async () => {
|
||||||
|
fireEvent.change(selectEscalationEmailElement, { target: { value: '' } });
|
||||||
|
});
|
||||||
const selectButton = screen.getByTestId('submissionButton');
|
const selectButton = screen.getByTestId('submissionButton');
|
||||||
fireEvent.click(selectButton);
|
await act(async () => {
|
||||||
|
fireEvent.click(selectButton);
|
||||||
|
});
|
||||||
|
|
||||||
// verify alert content and focus management
|
// verify alert content and focus management
|
||||||
const escalationEmailError = screen.getByTestId('escalationEmailError');
|
const escalationEmailError = screen.getByTestId('escalationEmailError');
|
||||||
@@ -207,23 +252,31 @@ describe('ProctoredExamSettings', () => {
|
|||||||
|
|
||||||
// verify alert link links to offending input
|
// verify alert link links to offending input
|
||||||
const errorLink = screen.getByTestId('escalationEmailErrorLink');
|
const errorLink = screen.getByTestId('escalationEmailErrorLink');
|
||||||
fireEvent.click(errorLink);
|
await act(async () => {
|
||||||
|
fireEvent.click(errorLink);
|
||||||
|
});
|
||||||
const escalationEmailInput = screen.getByTestId('escalationEmail');
|
const escalationEmailInput = screen.getByTestId('escalationEmail');
|
||||||
expect(document.activeElement).toEqual(escalationEmailInput);
|
expect(document.activeElement).toEqual(escalationEmailInput);
|
||||||
});
|
});
|
||||||
|
|
||||||
it(`Creates an alert when invalid proctoring escalation email is provided with ${provider} selected`, async () => {
|
it(`Creates an alert when invalid proctoring escalation email is provided with ${provider} selected`, async () => {
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
screen.getByDisplayValue('LTI Provider');
|
screen.getByDisplayValue('proctortrack');
|
||||||
});
|
});
|
||||||
|
|
||||||
const selectElement = screen.getByDisplayValue('LTI Provider');
|
const selectElement = screen.getByDisplayValue('proctortrack');
|
||||||
fireEvent.change(selectElement, { target: { value: provider } });
|
await act(async () => {
|
||||||
|
fireEvent.change(selectElement, { target: { value: provider } });
|
||||||
|
});
|
||||||
|
|
||||||
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
|
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
|
||||||
fireEvent.change(selectEscalationEmailElement, { target: { value: 'foo.bar' } });
|
await act(async () => {
|
||||||
const proctoringForm = screen.getByTestId('proctoringForm');
|
fireEvent.change(selectEscalationEmailElement, { target: { value: 'foo.bar' } });
|
||||||
fireEvent.submit(proctoringForm);
|
});
|
||||||
|
const selectButton = screen.getByTestId('submissionButton');
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(selectButton);
|
||||||
|
});
|
||||||
|
|
||||||
// verify alert content and focus management
|
// verify alert content and focus management
|
||||||
const escalationEmailError = screen.getByTestId('escalationEmailError');
|
const escalationEmailError = screen.getByTestId('escalationEmailError');
|
||||||
@@ -233,21 +286,27 @@ describe('ProctoredExamSettings', () => {
|
|||||||
|
|
||||||
// verify alert link links to offending input
|
// verify alert link links to offending input
|
||||||
const errorLink = screen.getByTestId('escalationEmailErrorLink');
|
const errorLink = screen.getByTestId('escalationEmailErrorLink');
|
||||||
fireEvent.click(errorLink);
|
await act(async () => {
|
||||||
|
fireEvent.click(errorLink);
|
||||||
|
});
|
||||||
const escalationEmailInput = screen.getByTestId('escalationEmail');
|
const escalationEmailInput = screen.getByTestId('escalationEmail');
|
||||||
expect(document.activeElement).toEqual(escalationEmailInput);
|
expect(document.activeElement).toEqual(escalationEmailInput);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Creates an alert when invalid proctoring escalation email is provided with proctoring disabled', async () => {
|
it('Creates an alert when invalid proctoring escalation email is provided with proctoring disabled', async () => {
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
screen.getByDisplayValue('LTI Provider');
|
screen.getByDisplayValue('proctortrack');
|
||||||
});
|
});
|
||||||
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
|
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
|
||||||
fireEvent.change(selectEscalationEmailElement, { target: { value: 'foo.bar' } });
|
await act(async () => {
|
||||||
|
fireEvent.change(selectEscalationEmailElement, { target: { value: 'foo.bar' } });
|
||||||
|
});
|
||||||
const enableProctoringElement = screen.getByText('Proctored exams');
|
const enableProctoringElement = screen.getByText('Proctored exams');
|
||||||
fireEvent.click(enableProctoringElement);
|
await act(async () => fireEvent.click(enableProctoringElement));
|
||||||
const selectButton = screen.getByTestId('submissionButton');
|
const selectButton = screen.getByTestId('submissionButton');
|
||||||
fireEvent.click(selectButton);
|
await act(async () => {
|
||||||
|
fireEvent.click(selectButton);
|
||||||
|
});
|
||||||
|
|
||||||
// verify alert content and focus management
|
// verify alert content and focus management
|
||||||
const escalationEmailError = screen.getByTestId('escalationEmailError');
|
const escalationEmailError = screen.getByTestId('escalationEmailError');
|
||||||
@@ -258,68 +317,78 @@ describe('ProctoredExamSettings', () => {
|
|||||||
|
|
||||||
it('Has no error when empty proctoring escalation email is provided with proctoring disabled', async () => {
|
it('Has no error when empty proctoring escalation email is provided with proctoring disabled', async () => {
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
screen.getByDisplayValue('LTI Provider');
|
screen.getByDisplayValue('proctortrack');
|
||||||
});
|
});
|
||||||
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
|
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
|
||||||
fireEvent.change(selectEscalationEmailElement, { target: { value: '' } });
|
await act(async () => {
|
||||||
|
fireEvent.change(selectEscalationEmailElement, { target: { value: '' } });
|
||||||
|
});
|
||||||
const enableProctoringElement = screen.getByText('Proctored exams');
|
const enableProctoringElement = screen.getByText('Proctored exams');
|
||||||
fireEvent.click(enableProctoringElement);
|
await act(async () => fireEvent.click(enableProctoringElement));
|
||||||
const selectButton = screen.getByTestId('submissionButton');
|
const selectButton = screen.getByTestId('submissionButton');
|
||||||
fireEvent.click(selectButton);
|
await act(async () => {
|
||||||
|
fireEvent.click(selectButton);
|
||||||
|
});
|
||||||
|
|
||||||
// verify there is no escalation email alert, and focus has been set on save success alert
|
// verify there is no escalation email alert, and focus has been set on save success alert
|
||||||
expect(screen.queryByTestId('escalationEmailError')).toBeNull();
|
expect(screen.queryByTestId('escalationEmailError')).toBeNull();
|
||||||
|
|
||||||
await waitFor(() => {
|
const errorAlert = screen.getByTestId('saveSuccess');
|
||||||
const errorAlert = screen.getByTestId('saveSuccess');
|
expect(errorAlert.textContent).toEqual(
|
||||||
expect(errorAlert.textContent).toEqual(
|
expect.stringContaining('Proctored exam settings saved successfully.'),
|
||||||
expect.stringContaining('Proctored exam settings saved successfully.'),
|
);
|
||||||
);
|
expect(document.activeElement).toEqual(errorAlert);
|
||||||
expect(document.activeElement).toEqual(errorAlert);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it(`Has no error when valid proctoring escalation email is provided with ${provider} selected`, async () => {
|
it(`Has no error when valid proctoring escalation email is provided with ${provider} selected`, async () => {
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
screen.getByDisplayValue('LTI Provider');
|
screen.getByDisplayValue('proctortrack');
|
||||||
});
|
});
|
||||||
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
|
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
|
||||||
fireEvent.change(selectEscalationEmailElement, { target: { value: 'foo@bar.com' } });
|
await act(async () => {
|
||||||
|
fireEvent.change(selectEscalationEmailElement, { target: { value: 'foo@bar.com' } });
|
||||||
|
});
|
||||||
const selectButton = screen.getByTestId('submissionButton');
|
const selectButton = screen.getByTestId('submissionButton');
|
||||||
fireEvent.click(selectButton);
|
await act(async () => {
|
||||||
|
fireEvent.click(selectButton);
|
||||||
|
});
|
||||||
|
|
||||||
// verify there is no escalation email alert, and focus has been set on save success alert
|
// verify there is no escalation email alert, and focus has been set on save success alert
|
||||||
expect(screen.queryByTestId('escalationEmailError')).toBeNull();
|
expect(screen.queryByTestId('escalationEmailError')).toBeNull();
|
||||||
|
|
||||||
await waitFor(() => {
|
const errorAlert = screen.getByTestId('saveSuccess');
|
||||||
const errorAlert = screen.getByTestId('saveSuccess');
|
expect(errorAlert.textContent).toEqual(
|
||||||
expect(errorAlert.textContent).toEqual(
|
expect.stringContaining('Proctored exam settings saved successfully.'),
|
||||||
expect.stringContaining('Proctored exam settings saved successfully.'),
|
);
|
||||||
);
|
expect(document.activeElement).toEqual(errorAlert);
|
||||||
expect(document.activeElement).toEqual(errorAlert);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it(`Escalation email field hidden when proctoring backend is not ${provider}`, async () => {
|
it(`Escalation email field hidden when proctoring backend is not ${provider}`, async () => {
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
screen.getByDisplayValue('LTI Provider');
|
screen.getByDisplayValue('proctortrack');
|
||||||
});
|
});
|
||||||
const proctoringBackendSelect = screen.getByDisplayValue('LTI Provider');
|
const proctoringBackendSelect = screen.getByDisplayValue('proctortrack');
|
||||||
const selectEscalationEmailElement = screen.getByTestId('escalationEmail');
|
const selectEscalationEmailElement = screen.getByTestId('escalationEmail');
|
||||||
expect(selectEscalationEmailElement.value).toEqual('test@example.com');
|
expect(selectEscalationEmailElement.value).toEqual('test@example.com');
|
||||||
fireEvent.change(proctoringBackendSelect, { target: { value: 'software_secure' } });
|
await act(async () => {
|
||||||
|
fireEvent.change(proctoringBackendSelect, { target: { value: 'software_secure' } });
|
||||||
|
});
|
||||||
expect(screen.queryByTestId('escalationEmail')).toBeNull();
|
expect(screen.queryByTestId('escalationEmail')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it(`Escalation email Field Show when proctoring backend is switched back to ${provider}`, async () => {
|
it(`Escalation email Field Show when proctoring backend is switched back to ${provider}`, async () => {
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
screen.getByDisplayValue('LTI Provider');
|
screen.getByDisplayValue('proctortrack');
|
||||||
});
|
});
|
||||||
const proctoringBackendSelect = screen.getByDisplayValue('LTI Provider');
|
const proctoringBackendSelect = screen.getByDisplayValue('proctortrack');
|
||||||
let selectEscalationEmailElement = screen.getByTestId('escalationEmail');
|
let selectEscalationEmailElement = screen.getByTestId('escalationEmail');
|
||||||
fireEvent.change(proctoringBackendSelect, { target: { value: 'software_secure' } });
|
await act(async () => {
|
||||||
|
fireEvent.change(proctoringBackendSelect, { target: { value: 'software_secure' } });
|
||||||
|
});
|
||||||
expect(screen.queryByTestId('escalationEmail')).toBeNull();
|
expect(screen.queryByTestId('escalationEmail')).toBeNull();
|
||||||
fireEvent.change(proctoringBackendSelect, { target: { value: provider } });
|
await act(async () => {
|
||||||
|
fireEvent.change(proctoringBackendSelect, { target: { value: 'proctortrack' } });
|
||||||
|
});
|
||||||
expect(screen.queryByTestId('escalationEmail')).toBeDefined();
|
expect(screen.queryByTestId('escalationEmail')).toBeDefined();
|
||||||
selectEscalationEmailElement = screen.getByTestId('escalationEmail');
|
selectEscalationEmailElement = screen.getByTestId('escalationEmail');
|
||||||
expect(selectEscalationEmailElement.value).toEqual('test@example.com');
|
expect(selectEscalationEmailElement.value).toEqual('test@example.com');
|
||||||
@@ -327,11 +396,15 @@ describe('ProctoredExamSettings', () => {
|
|||||||
|
|
||||||
it('Submits form when "Enter" key is hit in the escalation email field', async () => {
|
it('Submits form when "Enter" key is hit in the escalation email field', async () => {
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
screen.getByDisplayValue('LTI Provider');
|
screen.getByDisplayValue('proctortrack');
|
||||||
});
|
});
|
||||||
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
|
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
|
||||||
fireEvent.change(selectEscalationEmailElement, { target: { value: '' } });
|
await act(async () => {
|
||||||
fireEvent.submit(selectEscalationEmailElement);
|
fireEvent.change(selectEscalationEmailElement, { target: { value: '' } });
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.submit(selectEscalationEmailElement);
|
||||||
|
});
|
||||||
// if the error appears, the form has been submitted
|
// if the error appears, the form has been submitted
|
||||||
expect(screen.getByTestId('escalationEmailError')).toBeDefined();
|
expect(screen.getByTestId('escalationEmailError')).toBeDefined();
|
||||||
});
|
});
|
||||||
@@ -345,9 +418,9 @@ describe('ProctoredExamSettings', () => {
|
|||||||
allow_proctoring_opt_out: false,
|
allow_proctoring_opt_out: false,
|
||||||
proctoring_provider: 'mockproc',
|
proctoring_provider: 'mockproc',
|
||||||
proctoring_escalation_email: 'test@example.com',
|
proctoring_escalation_email: 'test@example.com',
|
||||||
|
create_zendesk_tickets: true,
|
||||||
},
|
},
|
||||||
available_proctoring_providers: ['software_secure', 'mockproc'],
|
available_proctoring_providers: ['software_secure', 'proctortrack', 'mockproc'],
|
||||||
requires_escalation_email_providers: [],
|
|
||||||
course_start_date: '2099-01-01T00:00:00Z',
|
course_start_date: '2099-01-01T00:00:00Z',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -357,9 +430,9 @@ describe('ProctoredExamSettings', () => {
|
|||||||
allow_proctoring_opt_out: false,
|
allow_proctoring_opt_out: false,
|
||||||
proctoring_provider: 'mockproc',
|
proctoring_provider: 'mockproc',
|
||||||
proctoring_escalation_email: 'test@example.com',
|
proctoring_escalation_email: 'test@example.com',
|
||||||
|
create_zendesk_tickets: true,
|
||||||
},
|
},
|
||||||
available_proctoring_providers: ['software_secure', 'mockproc'],
|
available_proctoring_providers: ['software_secure', 'proctortrack', 'mockproc'],
|
||||||
requires_escalation_email_providers: [],
|
|
||||||
course_start_date: '2013-01-01T00:00:00Z',
|
course_start_date: '2013-01-01T00:00:00Z',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -371,8 +444,8 @@ describe('ProctoredExamSettings', () => {
|
|||||||
const isAdmin = false;
|
const isAdmin = false;
|
||||||
setupApp(isAdmin);
|
setupApp(isAdmin);
|
||||||
mockCourseData(mockGetPastCourseData);
|
mockCourseData(mockGetPastCourseData);
|
||||||
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
|
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||||
const providerOption = screen.getByTestId('software_secure');
|
const providerOption = screen.getByTestId('proctortrack');
|
||||||
expect(providerOption.hasAttribute('disabled')).toEqual(true);
|
expect(providerOption.hasAttribute('disabled')).toEqual(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -380,18 +453,8 @@ describe('ProctoredExamSettings', () => {
|
|||||||
const isAdmin = false;
|
const isAdmin = false;
|
||||||
setupApp(isAdmin);
|
setupApp(isAdmin);
|
||||||
mockCourseData(mockGetFutureCourseData);
|
mockCourseData(mockGetFutureCourseData);
|
||||||
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
|
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||||
const providerOption = screen.getByTestId('software_secure');
|
const providerOption = screen.getByTestId('proctortrack');
|
||||||
expect(providerOption.hasAttribute('disabled')).toEqual(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Sends the org to the proctoring provider endpoint', async () => {
|
|
||||||
const isAdmin = false;
|
|
||||||
const org = 'test-org';
|
|
||||||
setupApp(isAdmin, org);
|
|
||||||
mockCourseData(mockGetFutureCourseData);
|
|
||||||
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
|
|
||||||
const providerOption = screen.getByTestId('software_secure');
|
|
||||||
expect(providerOption.hasAttribute('disabled')).toEqual(false);
|
expect(providerOption.hasAttribute('disabled')).toEqual(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -399,8 +462,8 @@ describe('ProctoredExamSettings', () => {
|
|||||||
const isAdmin = true;
|
const isAdmin = true;
|
||||||
setupApp(isAdmin);
|
setupApp(isAdmin);
|
||||||
mockCourseData(mockGetPastCourseData);
|
mockCourseData(mockGetPastCourseData);
|
||||||
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
|
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||||
const providerOption = screen.getByTestId('software_secure');
|
const providerOption = screen.getByTestId('proctortrack');
|
||||||
expect(providerOption.hasAttribute('disabled')).toEqual(false);
|
expect(providerOption.hasAttribute('disabled')).toEqual(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -408,18 +471,18 @@ describe('ProctoredExamSettings', () => {
|
|||||||
const isAdmin = true;
|
const isAdmin = true;
|
||||||
setupApp(isAdmin);
|
setupApp(isAdmin);
|
||||||
mockCourseData(mockGetFutureCourseData);
|
mockCourseData(mockGetFutureCourseData);
|
||||||
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
|
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||||
const providerOption = screen.getByTestId('software_secure');
|
const providerOption = screen.getByTestId('proctortrack');
|
||||||
expect(providerOption.hasAttribute('disabled')).toEqual(false);
|
expect(providerOption.hasAttribute('disabled')).toEqual(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Does not include lti_external as a selectable option', async () => {
|
it('Does not include lti_external as a selectable option', async () => {
|
||||||
const courseData = {
|
const courseData = {
|
||||||
...mockGetFutureCourseData,
|
...mockGetFutureCourseData,
|
||||||
available_proctoring_providers: ['lti_external', 'mockproc'],
|
available_proctoring_providers: ['lti_external', 'proctortrack', 'mockproc'],
|
||||||
};
|
};
|
||||||
mockCourseData(courseData);
|
mockCourseData(courseData);
|
||||||
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
|
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
screen.getByDisplayValue('mockproc');
|
screen.getByDisplayValue('mockproc');
|
||||||
});
|
});
|
||||||
@@ -429,10 +492,10 @@ describe('ProctoredExamSettings', () => {
|
|||||||
it('Includes lti proctoring provider options when lti_external is allowed by studio', async () => {
|
it('Includes lti proctoring provider options when lti_external is allowed by studio', async () => {
|
||||||
const courseData = {
|
const courseData = {
|
||||||
...mockGetFutureCourseData,
|
...mockGetFutureCourseData,
|
||||||
available_proctoring_providers: ['lti_external', 'mockproc'],
|
available_proctoring_providers: ['lti_external', 'proctortrack', 'mockproc'],
|
||||||
};
|
};
|
||||||
mockCourseData(courseData);
|
mockCourseData(courseData);
|
||||||
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
|
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
screen.getByDisplayValue('mockproc');
|
screen.getByDisplayValue('mockproc');
|
||||||
});
|
});
|
||||||
@@ -445,7 +508,7 @@ describe('ProctoredExamSettings', () => {
|
|||||||
const isAdmin = true;
|
const isAdmin = true;
|
||||||
setupApp(isAdmin);
|
setupApp(isAdmin);
|
||||||
mockCourseData(mockGetFutureCourseData);
|
mockCourseData(mockGetFutureCourseData);
|
||||||
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
|
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
screen.getByDisplayValue('mockproc');
|
screen.getByDisplayValue('mockproc');
|
||||||
});
|
});
|
||||||
@@ -459,20 +522,18 @@ describe('ProctoredExamSettings', () => {
|
|||||||
EXAMS_BASE_URL: null,
|
EXAMS_BASE_URL: null,
|
||||||
}, 'CourseAuthoringConfig');
|
}, 'CourseAuthoringConfig');
|
||||||
|
|
||||||
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
|
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
screen.getByDisplayValue('mockproc');
|
screen.getByDisplayValue('mockproc');
|
||||||
});
|
});
|
||||||
// (1) for studio settings
|
// only outgoing request should be for studio settings
|
||||||
// (2) waffle flags
|
expect(axiosMock.history.get.length).toBe(1);
|
||||||
// (3) for course details
|
|
||||||
expect(axiosMock.history.get.length).toBe(3);
|
|
||||||
expect(axiosMock.history.get[0].url.includes('proctored_exam_settings')).toEqual(true);
|
expect(axiosMock.history.get[0].url.includes('proctored_exam_settings')).toEqual(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Selected LTI proctoring provider is shown on page load', async () => {
|
it('Selected LTI proctoring provider is shown on page load', async () => {
|
||||||
const courseData = { ...mockGetFutureCourseData };
|
const courseData = { ...mockGetFutureCourseData };
|
||||||
courseData.available_proctoring_providers = ['lti_external', 'mockproc'];
|
courseData.available_proctoring_providers = ['lti_external', 'proctortrack', 'mockproc'];
|
||||||
courseData.proctored_exam_settings.proctoring_provider = 'lti_external';
|
courseData.proctored_exam_settings.proctoring_provider = 'lti_external';
|
||||||
mockCourseData(courseData);
|
mockCourseData(courseData);
|
||||||
axiosMock.onGet(
|
axiosMock.onGet(
|
||||||
@@ -480,7 +541,7 @@ describe('ProctoredExamSettings', () => {
|
|||||||
).reply(200, {
|
).reply(200, {
|
||||||
provider: 'test_lti',
|
provider: 'test_lti',
|
||||||
});
|
});
|
||||||
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
|
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
screen.getByText('Proctoring provider');
|
screen.getByText('Proctoring provider');
|
||||||
});
|
});
|
||||||
@@ -491,24 +552,29 @@ describe('ProctoredExamSettings', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('Toggles field visibility based on user permissions', () => {
|
describe('Toggles field visibility based on user permissions', () => {
|
||||||
it('Hides opting out for non edX staff', async () => {
|
it('Hides opting out and zendesk tickets for non edX staff', async () => {
|
||||||
setupApp(false);
|
setupApp(false);
|
||||||
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
|
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||||
expect(screen.queryByTestId('allowOptingOutYes')).toBeNull();
|
expect(screen.queryByTestId('allowOptingOutYes')).toBeNull();
|
||||||
|
expect(screen.queryByTestId('createZendeskTicketsYes')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Shows opting out for edX staff', async () => {
|
it('Shows opting out and zendesk tickets for edX staff', async () => {
|
||||||
setupApp(true);
|
setupApp(true);
|
||||||
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
|
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||||
expect(screen.queryByTestId('allowOptingOutYes')).not.toBeNull();
|
expect(screen.queryByTestId('allowOptingOutYes')).not.toBeNull();
|
||||||
|
expect(screen.queryByTestId('createZendeskTicketsYes')).not.toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Connection states', () => {
|
describe('Connection states', () => {
|
||||||
it('Shows the spinner before the connection is complete', async () => {
|
it('Shows the spinner before the connection is complete', async () => {
|
||||||
render(renderComponent(<ProctoredExamSettings {...defaultProps} />));
|
await act(async () => {
|
||||||
const spinner = await screen.findByRole('status');
|
render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />));
|
||||||
expect(spinner.textContent).toEqual('Loading...');
|
// This expectation is _inside_ the `act` intentionally, so that it executes immediately.
|
||||||
|
const spinner = screen.getByRole('status');
|
||||||
|
expect(spinner.textContent).toEqual('Loading...');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Show connection error message when we suffer studio server side error', async () => {
|
it('Show connection error message when we suffer studio server side error', async () => {
|
||||||
@@ -516,7 +582,7 @@ describe('ProctoredExamSettings', () => {
|
|||||||
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
|
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
|
||||||
).reply(500);
|
).reply(500);
|
||||||
|
|
||||||
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
|
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||||
const connectionError = screen.getByTestId('connectionErrorAlert');
|
const connectionError = screen.getByTestId('connectionErrorAlert');
|
||||||
expect(connectionError.textContent).toEqual(
|
expect(connectionError.textContent).toEqual(
|
||||||
expect.stringContaining('We encountered a technical error when loading this page.'),
|
expect.stringContaining('We encountered a technical error when loading this page.'),
|
||||||
@@ -528,7 +594,7 @@ describe('ProctoredExamSettings', () => {
|
|||||||
`${ExamsApiService.getExamsBaseUrl()}/api/v1/providers`,
|
`${ExamsApiService.getExamsBaseUrl()}/api/v1/providers`,
|
||||||
).reply(500);
|
).reply(500);
|
||||||
|
|
||||||
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
|
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||||
const connectionError = screen.getByTestId('connectionErrorAlert');
|
const connectionError = screen.getByTestId('connectionErrorAlert');
|
||||||
expect(connectionError.textContent).toEqual(
|
expect(connectionError.textContent).toEqual(
|
||||||
expect.stringContaining('We encountered a technical error when loading this page.'),
|
expect.stringContaining('We encountered a technical error when loading this page.'),
|
||||||
@@ -540,7 +606,7 @@ describe('ProctoredExamSettings', () => {
|
|||||||
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
|
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
|
||||||
).reply(403);
|
).reply(403);
|
||||||
|
|
||||||
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
|
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||||
const permissionError = screen.getByTestId('permissionDeniedAlert');
|
const permissionError = screen.getByTestId('permissionDeniedAlert');
|
||||||
expect(permissionError.textContent).toEqual(
|
expect(permissionError.textContent).toEqual(
|
||||||
expect.stringContaining('You are not authorized to view this page'),
|
expect.stringContaining('You are not authorized to view this page'),
|
||||||
@@ -559,104 +625,98 @@ describe('ProctoredExamSettings', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('Disable button while submitting', async () => {
|
it('Disable button while submitting', async () => {
|
||||||
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
|
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||||
let submitButton = screen.getByTestId('submissionButton');
|
let submitButton = screen.getByTestId('submissionButton');
|
||||||
expect(screen.queryByTestId('saveInProgress')).toBeFalsy();
|
expect(screen.queryByTestId('saveInProgress')).toBeFalsy();
|
||||||
fireEvent.click(submitButton);
|
act(() => {
|
||||||
|
fireEvent.click(submitButton);
|
||||||
|
});
|
||||||
|
|
||||||
submitButton = screen.getByTestId('submissionButton');
|
submitButton = screen.getByTestId('submissionButton');
|
||||||
expect(submitButton).toHaveAttribute('disabled');
|
expect(submitButton).toHaveAttribute('disabled');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Makes API call successfully with proctoring_escalation_email if test_lti', async () => {
|
it('Makes API call successfully with proctoring_escalation_email if proctortrack', async () => {
|
||||||
// Setup mock to include test_lti as available provider
|
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||||
axiosMock.onGet(
|
// Make a change to the provider to proctortrack and set the email
|
||||||
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
|
|
||||||
).reply(200, {
|
|
||||||
proctored_exam_settings: {
|
|
||||||
enable_proctored_exams: true,
|
|
||||||
allow_proctoring_opt_out: false,
|
|
||||||
proctoring_provider: 'mockproc',
|
|
||||||
proctoring_escalation_email: 'test@example.com',
|
|
||||||
},
|
|
||||||
available_proctoring_providers: ['software_secure', 'mockproc', 'lti_external'],
|
|
||||||
requires_escalation_email_providers: ['test_lti'],
|
|
||||||
course_start_date: '2070-01-01T00:00:00Z',
|
|
||||||
});
|
|
||||||
|
|
||||||
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
|
|
||||||
// Make a change to the provider to test_lti and set the email
|
|
||||||
const selectElement = screen.getByDisplayValue('mockproc');
|
const selectElement = screen.getByDisplayValue('mockproc');
|
||||||
fireEvent.change(selectElement, { target: { value: 'test_lti' } });
|
await act(async () => {
|
||||||
|
fireEvent.change(selectElement, { target: { value: 'proctortrack' } });
|
||||||
|
});
|
||||||
const escalationEmail = screen.getByTestId('escalationEmail');
|
const escalationEmail = screen.getByTestId('escalationEmail');
|
||||||
expect(escalationEmail.value).toEqual('test@example.com');
|
expect(escalationEmail.value).toEqual('test@example.com');
|
||||||
fireEvent.change(escalationEmail, { target: { value: 'test_lti@example.com' } });
|
await act(async () => {
|
||||||
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');
|
const submitButton = screen.getByTestId('submissionButton');
|
||||||
fireEvent.click(submitButton);
|
await act(async () => {
|
||||||
|
fireEvent.click(submitButton);
|
||||||
|
});
|
||||||
expect(axiosMock.history.post.length).toBe(1);
|
expect(axiosMock.history.post.length).toBe(1);
|
||||||
expect(JSON.parse(axiosMock.history.post[0].data)).toEqual({
|
expect(JSON.parse(axiosMock.history.post[0].data)).toEqual({
|
||||||
proctored_exam_settings: {
|
proctored_exam_settings: {
|
||||||
enable_proctored_exams: true,
|
enable_proctored_exams: true,
|
||||||
allow_proctoring_opt_out: false,
|
allow_proctoring_opt_out: false,
|
||||||
proctoring_provider: 'lti_external',
|
proctoring_provider: 'proctortrack',
|
||||||
proctoring_escalation_email: 'test_lti@example.com',
|
proctoring_escalation_email: 'proctortrack@example.com',
|
||||||
|
create_zendesk_tickets: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(axiosMock.history.patch.length).toBe(1);
|
|
||||||
expect(JSON.parse(axiosMock.history.patch[0].data)).toEqual({
|
|
||||||
provider: 'test_lti',
|
|
||||||
escalation_email: 'test_lti@example.com',
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
const errorAlert = screen.getByTestId('saveSuccess');
|
||||||
const errorAlert = screen.getByTestId('saveSuccess');
|
expect(errorAlert.textContent).toEqual(
|
||||||
expect(errorAlert.textContent).toEqual(
|
expect.stringContaining('Proctored exam settings saved successfully.'),
|
||||||
expect.stringContaining('Proctored exam settings saved successfully.'),
|
);
|
||||||
);
|
expect(document.activeElement).toEqual(errorAlert);
|
||||||
expect(document.activeElement).toEqual(errorAlert);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Makes API call successfully without proctoring_escalation_email if not requiring escalation email', async () => {
|
it('Makes API call successfully without proctoring_escalation_email if not proctortrack', async () => {
|
||||||
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
|
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||||
|
|
||||||
// make sure we have not selected a provider requiring escalation email
|
// make sure we have not selected proctortrack as the proctoring provider
|
||||||
expect(screen.getByDisplayValue('mockproc')).toBeDefined();
|
expect(screen.getByDisplayValue('mockproc')).toBeDefined();
|
||||||
|
|
||||||
const submitButton = screen.getByTestId('submissionButton');
|
const submitButton = screen.getByTestId('submissionButton');
|
||||||
fireEvent.click(submitButton);
|
await act(async () => {
|
||||||
|
fireEvent.click(submitButton);
|
||||||
|
});
|
||||||
expect(axiosMock.history.post.length).toBe(1);
|
expect(axiosMock.history.post.length).toBe(1);
|
||||||
expect(JSON.parse(axiosMock.history.post[0].data)).toEqual({
|
expect(JSON.parse(axiosMock.history.post[0].data)).toEqual({
|
||||||
proctored_exam_settings: {
|
proctored_exam_settings: {
|
||||||
enable_proctored_exams: true,
|
enable_proctored_exams: true,
|
||||||
allow_proctoring_opt_out: false,
|
allow_proctoring_opt_out: false,
|
||||||
proctoring_provider: 'mockproc',
|
proctoring_provider: 'mockproc',
|
||||||
|
create_zendesk_tickets: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
const errorAlert = screen.getByTestId('saveSuccess');
|
||||||
const errorAlert = screen.getByTestId('saveSuccess');
|
expect(errorAlert.textContent).toEqual(
|
||||||
expect(errorAlert.textContent).toEqual(
|
expect.stringContaining('Proctored exam settings saved successfully.'),
|
||||||
expect.stringContaining('Proctored exam settings saved successfully.'),
|
);
|
||||||
);
|
expect(document.activeElement).toEqual(errorAlert);
|
||||||
expect(document.activeElement).toEqual(errorAlert);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Successfully updates exam configuration and studio provider is set to "lti_external" for lti providers', async () => {
|
it('Successfully updates exam configuration and studio provider is set to "lti_external" for lti providers', async () => {
|
||||||
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
|
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||||
// Make a change to the provider to test_lti and set the email
|
// Make a change to the provider to test_lti and set the email
|
||||||
const selectElement = screen.getByDisplayValue('mockproc');
|
const selectElement = screen.getByDisplayValue('mockproc');
|
||||||
fireEvent.change(selectElement, { target: { value: 'test_lti' } });
|
await act(async () => {
|
||||||
|
fireEvent.change(selectElement, { target: { value: 'test_lti' } });
|
||||||
|
});
|
||||||
|
|
||||||
const escalationEmail = screen.getByTestId('escalationEmail');
|
const escalationEmail = screen.getByTestId('escalationEmail');
|
||||||
expect(escalationEmail.value).toEqual('test@example.com');
|
expect(escalationEmail.value).toEqual('test@example.com');
|
||||||
fireEvent.change(escalationEmail, { target: { value: 'test_lti@example.com' } });
|
await act(async () => {
|
||||||
|
fireEvent.change(escalationEmail, { target: { value: 'test_lti@example.com' } });
|
||||||
|
});
|
||||||
expect(escalationEmail.value).toEqual('test_lti@example.com');
|
expect(escalationEmail.value).toEqual('test_lti@example.com');
|
||||||
|
|
||||||
const submitButton = screen.getByTestId('submissionButton');
|
const submitButton = screen.getByTestId('submissionButton');
|
||||||
fireEvent.click(submitButton);
|
await act(async () => {
|
||||||
|
fireEvent.click(submitButton);
|
||||||
|
});
|
||||||
|
|
||||||
// update exam service config
|
// update exam service config
|
||||||
expect(axiosMock.history.patch.length).toBe(1);
|
expect(axiosMock.history.patch.length).toBe(1);
|
||||||
@@ -672,23 +732,23 @@ describe('ProctoredExamSettings', () => {
|
|||||||
enable_proctored_exams: true,
|
enable_proctored_exams: true,
|
||||||
allow_proctoring_opt_out: false,
|
allow_proctoring_opt_out: false,
|
||||||
proctoring_provider: 'lti_external',
|
proctoring_provider: 'lti_external',
|
||||||
proctoring_escalation_email: 'test_lti@example.com',
|
create_zendesk_tickets: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
const errorAlert = screen.getByTestId('saveSuccess');
|
||||||
const errorAlert = screen.getByTestId('saveSuccess');
|
expect(errorAlert.textContent).toEqual(
|
||||||
expect(errorAlert.textContent).toEqual(
|
expect.stringContaining('Proctored exam settings saved successfully.'),
|
||||||
expect.stringContaining('Proctored exam settings saved successfully.'),
|
);
|
||||||
);
|
expect(document.activeElement).toEqual(errorAlert);
|
||||||
expect(document.activeElement).toEqual(errorAlert);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Sets exam service provider to null if a non-lti provider is selected', async () => {
|
it('Sets exam service provider to null if a non-lti provider is selected', async () => {
|
||||||
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
|
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||||
const submitButton = screen.getByTestId('submissionButton');
|
const submitButton = screen.getByTestId('submissionButton');
|
||||||
fireEvent.click(submitButton);
|
await act(async () => {
|
||||||
|
fireEvent.click(submitButton);
|
||||||
|
});
|
||||||
// update exam service config
|
// update exam service config
|
||||||
expect(axiosMock.history.patch.length).toBe(1);
|
expect(axiosMock.history.patch.length).toBe(1);
|
||||||
expect(JSON.parse(axiosMock.history.patch[0].data)).toEqual({
|
expect(JSON.parse(axiosMock.history.patch[0].data)).toEqual({
|
||||||
@@ -702,16 +762,15 @@ describe('ProctoredExamSettings', () => {
|
|||||||
enable_proctored_exams: true,
|
enable_proctored_exams: true,
|
||||||
allow_proctoring_opt_out: false,
|
allow_proctoring_opt_out: false,
|
||||||
proctoring_provider: 'mockproc',
|
proctoring_provider: 'mockproc',
|
||||||
|
create_zendesk_tickets: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
const errorAlert = screen.getByTestId('saveSuccess');
|
||||||
const errorAlert = screen.getByTestId('saveSuccess');
|
expect(errorAlert.textContent).toEqual(
|
||||||
expect(errorAlert.textContent).toEqual(
|
expect.stringContaining('Proctored exam settings saved successfully.'),
|
||||||
expect.stringContaining('Proctored exam settings saved successfully.'),
|
);
|
||||||
);
|
expect(document.activeElement).toEqual(errorAlert);
|
||||||
expect(document.activeElement).toEqual(errorAlert);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Does not update exam service if lti is not enabled in studio', async () => {
|
it('Does not update exam service if lti is not enabled in studio', async () => {
|
||||||
@@ -723,15 +782,17 @@ describe('ProctoredExamSettings', () => {
|
|||||||
allow_proctoring_opt_out: false,
|
allow_proctoring_opt_out: false,
|
||||||
proctoring_provider: 'mockproc',
|
proctoring_provider: 'mockproc',
|
||||||
proctoring_escalation_email: 'test@example.com',
|
proctoring_escalation_email: 'test@example.com',
|
||||||
|
create_zendesk_tickets: true,
|
||||||
},
|
},
|
||||||
available_proctoring_providers: ['software_secure', 'mockproc'],
|
available_proctoring_providers: ['software_secure', 'proctortrack', 'mockproc'],
|
||||||
requires_escalation_email_providers: [],
|
|
||||||
course_start_date: '2070-01-01T00:00:00Z',
|
course_start_date: '2070-01-01T00:00:00Z',
|
||||||
});
|
});
|
||||||
|
|
||||||
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
|
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||||
const submitButton = screen.getByTestId('submissionButton');
|
const submitButton = screen.getByTestId('submissionButton');
|
||||||
fireEvent.click(submitButton);
|
await act(async () => {
|
||||||
|
fireEvent.click(submitButton);
|
||||||
|
});
|
||||||
// does not update exam service config
|
// does not update exam service config
|
||||||
expect(axiosMock.history.patch.length).toBe(0);
|
expect(axiosMock.history.patch.length).toBe(0);
|
||||||
// does update studio
|
// does update studio
|
||||||
@@ -741,16 +802,15 @@ describe('ProctoredExamSettings', () => {
|
|||||||
enable_proctored_exams: true,
|
enable_proctored_exams: true,
|
||||||
allow_proctoring_opt_out: false,
|
allow_proctoring_opt_out: false,
|
||||||
proctoring_provider: 'mockproc',
|
proctoring_provider: 'mockproc',
|
||||||
|
create_zendesk_tickets: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
const errorAlert = screen.getByTestId('saveSuccess');
|
||||||
const errorAlert = screen.getByTestId('saveSuccess');
|
expect(errorAlert.textContent).toEqual(
|
||||||
expect(errorAlert.textContent).toEqual(
|
expect.stringContaining('Proctored exam settings saved successfully.'),
|
||||||
expect.stringContaining('Proctored exam settings saved successfully.'),
|
);
|
||||||
);
|
expect(document.activeElement).toEqual(errorAlert);
|
||||||
expect(document.activeElement).toEqual(errorAlert);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Makes studio API call generated error', async () => {
|
it('Makes studio API call generated error', async () => {
|
||||||
@@ -758,17 +818,17 @@ describe('ProctoredExamSettings', () => {
|
|||||||
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
|
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
|
||||||
).reply(500);
|
).reply(500);
|
||||||
|
|
||||||
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
|
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||||
const submitButton = screen.getByTestId('submissionButton');
|
const submitButton = screen.getByTestId('submissionButton');
|
||||||
fireEvent.click(submitButton);
|
await act(async () => {
|
||||||
expect(axiosMock.history.post.length).toBe(1);
|
fireEvent.click(submitButton);
|
||||||
await waitFor(() => {
|
|
||||||
const errorAlert = screen.getByTestId('saveError');
|
|
||||||
expect(errorAlert.textContent).toEqual(
|
|
||||||
expect.stringContaining('We encountered a technical error while trying to save proctored exam settings'),
|
|
||||||
);
|
|
||||||
expect(document.activeElement).toEqual(errorAlert);
|
|
||||||
});
|
});
|
||||||
|
expect(axiosMock.history.post.length).toBe(1);
|
||||||
|
const errorAlert = screen.getByTestId('saveError');
|
||||||
|
expect(errorAlert.textContent).toEqual(
|
||||||
|
expect.stringContaining('We encountered a technical error while trying to save proctored exam settings'),
|
||||||
|
);
|
||||||
|
expect(document.activeElement).toEqual(errorAlert);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Makes exams API call generated error', async () => {
|
it('Makes exams API call generated error', async () => {
|
||||||
@@ -776,35 +836,17 @@ describe('ProctoredExamSettings', () => {
|
|||||||
`${ExamsApiService.getExamsBaseUrl()}/api/v1/configs/course_id/${defaultProps.courseId}`,
|
`${ExamsApiService.getExamsBaseUrl()}/api/v1/configs/course_id/${defaultProps.courseId}`,
|
||||||
).reply(500, 'error');
|
).reply(500, 'error');
|
||||||
|
|
||||||
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
|
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||||
const submitButton = screen.getByTestId('submissionButton');
|
const submitButton = screen.getByTestId('submissionButton');
|
||||||
fireEvent.click(submitButton);
|
await act(async () => {
|
||||||
expect(axiosMock.history.post.length).toBe(1);
|
fireEvent.click(submitButton);
|
||||||
await waitFor(() => {
|
|
||||||
const errorAlert = screen.getByTestId('saveError');
|
|
||||||
expect(errorAlert.textContent).toEqual(
|
|
||||||
expect.stringContaining('We encountered a technical error while trying to save proctored exam settings'),
|
|
||||||
);
|
|
||||||
expect(document.activeElement).toEqual(errorAlert);
|
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
test('Exams API permission error', async () => {
|
|
||||||
axiosMock.onPatch(
|
|
||||||
`${ExamsApiService.getExamsBaseUrl()}/api/v1/configs/course_id/${defaultProps.courseId}`,
|
|
||||||
).reply(403, 'error');
|
|
||||||
|
|
||||||
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
|
|
||||||
const submitButton = screen.getByTestId('submissionButton');
|
|
||||||
fireEvent.click(submitButton);
|
|
||||||
expect(axiosMock.history.post.length).toBe(1);
|
expect(axiosMock.history.post.length).toBe(1);
|
||||||
await waitFor(() => {
|
const errorAlert = screen.getByTestId('saveError');
|
||||||
const errorAlert = screen.getByTestId('saveError');
|
expect(errorAlert.textContent).toEqual(
|
||||||
expect(errorAlert.textContent).toEqual(
|
expect.stringContaining('We encountered a technical error while trying to save proctored exam settings'),
|
||||||
expect.stringContaining('You do not have permission to edit proctored exam settings for this course'),
|
);
|
||||||
);
|
expect(document.activeElement).toEqual(errorAlert);
|
||||||
expect(document.activeElement).toEqual(errorAlert);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Manages focus correctly after different save statuses', async () => {
|
it('Manages focus correctly after different save statuses', async () => {
|
||||||
@@ -813,31 +855,57 @@ describe('ProctoredExamSettings', () => {
|
|||||||
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
|
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
|
||||||
).reply(500);
|
).reply(500);
|
||||||
|
|
||||||
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
|
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||||
const submitButton = screen.getByTestId('submissionButton');
|
const submitButton = screen.getByTestId('submissionButton');
|
||||||
fireEvent.click(submitButton);
|
await act(async () => {
|
||||||
expect(axiosMock.history.post.length).toBe(1);
|
fireEvent.click(submitButton);
|
||||||
await waitFor(() => {
|
|
||||||
const errorAlert = screen.getByTestId('saveError');
|
|
||||||
expect(errorAlert.textContent).toEqual(
|
|
||||||
expect.stringContaining('We encountered a technical error while trying to save proctored exam settings'),
|
|
||||||
);
|
|
||||||
expect(document.activeElement).toEqual(errorAlert);
|
|
||||||
});
|
});
|
||||||
|
expect(axiosMock.history.post.length).toBe(1);
|
||||||
|
const errorAlert = screen.getByTestId('saveError');
|
||||||
|
expect(errorAlert.textContent).toEqual(
|
||||||
|
expect.stringContaining('We encountered a technical error while trying to save proctored exam settings'),
|
||||||
|
);
|
||||||
|
expect(document.activeElement).toEqual(errorAlert);
|
||||||
|
|
||||||
// now make a call that will allow for a successful save
|
// now make a call that will allow for a successful save
|
||||||
axiosMock.onPost(
|
axiosMock.onPost(
|
||||||
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
|
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
|
||||||
).reply(200, 'success');
|
).reply(200, 'success');
|
||||||
fireEvent.click(submitButton);
|
await act(async () => {
|
||||||
|
fireEvent.click(submitButton);
|
||||||
|
});
|
||||||
|
|
||||||
expect(axiosMock.history.post.length).toBe(2);
|
expect(axiosMock.history.post.length).toBe(2);
|
||||||
await waitFor(() => {
|
const successAlert = screen.getByTestId('saveSuccess');
|
||||||
const successAlert = screen.getByTestId('saveSuccess');
|
expect(successAlert.textContent).toEqual(
|
||||||
expect(successAlert.textContent).toEqual(
|
expect.stringContaining('Proctored exam settings saved successfully.'),
|
||||||
expect.stringContaining('Proctored exam settings saved successfully.'),
|
);
|
||||||
);
|
expect(document.activeElement).toEqual(successAlert);
|
||||||
expect(document.activeElement).toEqual(successAlert);
|
});
|
||||||
|
|
||||||
|
it('Include Zendesk ticket in post request if user is not an admin', async () => {
|
||||||
|
// use non-admin user for test
|
||||||
|
const isAdmin = false;
|
||||||
|
setupApp(isAdmin);
|
||||||
|
|
||||||
|
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||||
|
// Make a change to the proctoring provider
|
||||||
|
const selectElement = screen.getByDisplayValue('mockproc');
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.change(selectElement, { target: { value: 'proctortrack' } });
|
||||||
|
});
|
||||||
|
const submitButton = screen.getByTestId('submissionButton');
|
||||||
|
await act(async () => {
|
||||||
|
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,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
'authoring.proctoring.alert.forbidden': {
|
|
||||||
id: 'authoring.proctoring.alert.forbidden',
|
|
||||||
defaultMessage: 'You do not have permission to edit proctored exam settings for this course. If you are a course team member and this problem persists, please go to the {support_link} for help.',
|
|
||||||
description: 'Alert message for proctoring settings permission error.',
|
|
||||||
},
|
|
||||||
'authoring.proctoring.no': {
|
'authoring.proctoring.no': {
|
||||||
id: 'authoring.proctoring.no',
|
id: 'authoring.proctoring.no',
|
||||||
defaultMessage: 'No',
|
defaultMessage: 'No',
|
||||||
@@ -81,6 +76,11 @@ const messages = defineMessages({
|
|||||||
defaultMessage: 'Allow learners to opt out of proctoring on proctored exams',
|
defaultMessage: 'Allow learners to opt out of proctoring on proctored exams',
|
||||||
description: 'Label for radio selection allowing proctored exam opt out',
|
description: 'Label for radio selection allowing proctored exam opt out',
|
||||||
},
|
},
|
||||||
|
'authoring.proctoring.createzendesk.label': {
|
||||||
|
id: 'authoring.proctoring.createzendesk.label',
|
||||||
|
defaultMessage: 'Create Zendesk tickets for suspicious attempts',
|
||||||
|
description: 'Label for Zendesk ticket creation radio select.',
|
||||||
|
},
|
||||||
'authoring.proctoring.error.single': {
|
'authoring.proctoring.error.single': {
|
||||||
id: 'authoring.proctoring.error.single',
|
id: 'authoring.proctoring.error.single',
|
||||||
defaultMessage: 'There is 1 error in this form.',
|
defaultMessage: 'There is 1 error in this form.',
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "Proctoring configuration for courses using it",
|
"description": "Proctoring configuration for courses using it",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@edx/frontend-app-authoring": "*",
|
"@edx/frontend-app-course-authoring": "*",
|
||||||
"@edx/frontend-platform": "*",
|
"@edx/frontend-platform": "*",
|
||||||
"@openedx/paragon": "*",
|
"@openedx/paragon": "*",
|
||||||
"classnames": "*",
|
"classnames": "*",
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
"moment": "*"
|
"moment": "*"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@edx/frontend-app-authoring": {
|
"@edx/frontend-app-course-authoring": {
|
||||||
"optional": true
|
"optional": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 PropTypes from 'prop-types';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import * as Yup from 'yup';
|
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 AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
|
|
||||||
const ProgressSettings = ({ onClose }) => {
|
const ProgressSettings = ({ intl, onClose }) => {
|
||||||
const intl = useIntl();
|
|
||||||
const [disableProgressGraph, saveSetting] = useAppSetting('disableProgressGraph');
|
const [disableProgressGraph, saveSetting] = useAppSetting('disableProgressGraph');
|
||||||
const showProgressGraphSetting = getConfig().ENABLE_PROGRESS_GRAPH_SETTINGS.toString().toLowerCase() === 'true';
|
const showProgressGraphSetting = getConfig().ENABLE_PROGRESS_GRAPH_SETTINGS.toString().toLowerCase() === 'true';
|
||||||
|
|
||||||
@@ -49,7 +48,8 @@ const ProgressSettings = ({ onClose }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
ProgressSettings.propTypes = {
|
ProgressSettings.propTypes = {
|
||||||
|
intl: intlShape.isRequired,
|
||||||
onClose: PropTypes.func.isRequired,
|
onClose: PropTypes.func.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ProgressSettings;
|
export default injectIntl(ProgressSettings);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "Progress configuration for courses using it",
|
"description": "Progress configuration for courses using it",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@edx/frontend-app-authoring": "*",
|
"@edx/frontend-app-course-authoring": "*",
|
||||||
"@edx/frontend-platform": "*",
|
"@edx/frontend-platform": "*",
|
||||||
"@openedx/paragon": "*",
|
"@openedx/paragon": "*",
|
||||||
"prop-types": "*",
|
"prop-types": "*",
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
"yup": "*"
|
"yup": "*"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@edx/frontend-app-authoring": {
|
"@edx/frontend-app-course-authoring": {
|
||||||
"optional": true
|
"optional": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { Button, Form, TransitionReplace } from '@openedx/paragon';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
@@ -30,9 +30,8 @@ const TeamTypeNameMessage = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const GroupEditor = ({
|
const GroupEditor = ({
|
||||||
group, onDelete, onChange, onBlur, fieldNameCommonBase, errors,
|
intl, group, onDelete, onChange, onBlur, fieldNameCommonBase, errors,
|
||||||
}) => {
|
}) => {
|
||||||
const intl = useIntl();
|
|
||||||
const [isDeleting, setDeleting] = useState(false);
|
const [isDeleting, setDeleting] = useState(false);
|
||||||
const [isOpen, setOpen] = useState(group.id === null);
|
const [isOpen, setOpen] = useState(group.id === null);
|
||||||
const initiateDeletion = () => setDeleting(true);
|
const initiateDeletion = () => setDeleting(true);
|
||||||
@@ -150,6 +149,7 @@ export const groupShape = PropTypes.shape({
|
|||||||
});
|
});
|
||||||
|
|
||||||
GroupEditor.propTypes = {
|
GroupEditor.propTypes = {
|
||||||
|
intl: intlShape.isRequired,
|
||||||
fieldNameCommonBase: PropTypes.string.isRequired,
|
fieldNameCommonBase: PropTypes.string.isRequired,
|
||||||
errors: PropTypes.shape({
|
errors: PropTypes.shape({
|
||||||
name: PropTypes.string,
|
name: PropTypes.string,
|
||||||
@@ -170,4 +170,4 @@ GroupEditor.defaultProps = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default GroupEditor;
|
export default injectIntl(GroupEditor);
|
||||||
|
|||||||
@@ -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 { Button, Form } from '@openedx/paragon';
|
||||||
import { Add } from '@openedx/paragon/icons';
|
import { Add } from '@openedx/paragon/icons';
|
||||||
|
|
||||||
@@ -17,16 +17,15 @@ import messages from './messages';
|
|||||||
setupYupExtensions();
|
setupYupExtensions();
|
||||||
|
|
||||||
const TeamSettings = ({
|
const TeamSettings = ({
|
||||||
|
intl,
|
||||||
onClose,
|
onClose,
|
||||||
}) => {
|
}) => {
|
||||||
const intl = useIntl();
|
|
||||||
const [teamsConfiguration, saveSettings] = useAppSetting('teamsConfiguration');
|
const [teamsConfiguration, saveSettings] = useAppSetting('teamsConfiguration');
|
||||||
const blankNewGroup = {
|
const blankNewGroup = {
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
type: GroupTypes.OPEN,
|
type: GroupTypes.OPEN,
|
||||||
maxTeamSize: null,
|
maxTeamSize: null,
|
||||||
userPartitionId: null,
|
|
||||||
id: null,
|
id: null,
|
||||||
key: uuid(),
|
key: uuid(),
|
||||||
};
|
};
|
||||||
@@ -39,7 +38,6 @@ const TeamSettings = ({
|
|||||||
type: group.type,
|
type: group.type,
|
||||||
description: group.description,
|
description: group.description,
|
||||||
max_team_size: group.maxTeamSize,
|
max_team_size: group.maxTeamSize,
|
||||||
user_partition_id: group.userPartitionId,
|
|
||||||
}));
|
}));
|
||||||
return saveSettings({
|
return saveSettings({
|
||||||
team_sets: groups,
|
team_sets: groups,
|
||||||
@@ -107,7 +105,6 @@ const TeamSettings = ({
|
|||||||
)
|
)
|
||||||
.when('enabled', {
|
.when('enabled', {
|
||||||
is: true,
|
is: true,
|
||||||
// oxlint-disable-next-line unicorn/no-thenable
|
|
||||||
then: Yup.array().min(1),
|
then: Yup.array().min(1),
|
||||||
})
|
})
|
||||||
.default([])
|
.default([])
|
||||||
@@ -167,7 +164,8 @@ const TeamSettings = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
TeamSettings.propTypes = {
|
TeamSettings.propTypes = {
|
||||||
|
intl: intlShape.isRequired,
|
||||||
onClose: PropTypes.func.isRequired,
|
onClose: PropTypes.func.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default TeamSettings;
|
export default injectIntl(TeamSettings);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "Teams configuration for courses using it",
|
"description": "Teams configuration for courses using it",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@edx/frontend-app-authoring": "*",
|
"@edx/frontend-app-course-authoring": "*",
|
||||||
"@edx/frontend-platform": "*",
|
"@edx/frontend-platform": "*",
|
||||||
"@openedx/paragon": "*",
|
"@openedx/paragon": "*",
|
||||||
"formik": "*",
|
"formik": "*",
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
"yup": "*"
|
"yup": "*"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@edx/frontend-app-authoring": {
|
"@edx/frontend-app-course-authoring": {
|
||||||
"optional": true
|
"optional": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable import/prefer-default-export */
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
|
|
||||||
import { GroupTypes } from 'CourseAuthoring/data/constants';
|
import { GroupTypes } from 'CourseAuthoring/data/constants';
|
||||||
|
|||||||
@@ -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 PropTypes from 'prop-types';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import * as Yup from 'yup';
|
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 AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
|
|
||||||
const WikiSettings = ({ onClose }) => {
|
const WikiSettings = ({ intl, onClose }) => {
|
||||||
const intl = useIntl();
|
|
||||||
const [enablePublicWiki, saveSetting] = useAppSetting('allowPublicWikiAccess');
|
const [enablePublicWiki, saveSetting] = useAppSetting('allowPublicWikiAccess');
|
||||||
const handleSettingsSave = (values) => saveSetting(values.enablePublicWiki);
|
const handleSettingsSave = (values) => saveSetting(values.enablePublicWiki);
|
||||||
|
|
||||||
@@ -33,7 +32,7 @@ const WikiSettings = ({ onClose }) => {
|
|||||||
label={intl.formatMessage(messages.enablePublicWikiLabel)}
|
label={intl.formatMessage(messages.enablePublicWikiLabel)}
|
||||||
helpText={intl.formatMessage(messages.enablePublicWikiHelp)}
|
helpText={intl.formatMessage(messages.enablePublicWikiHelp)}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
onBlur={handleBlur}
|
onBlue={handleBlur}
|
||||||
checked={values.enablePublicWiki}
|
checked={values.enablePublicWiki}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -43,7 +42,8 @@ const WikiSettings = ({ onClose }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
WikiSettings.propTypes = {
|
WikiSettings.propTypes = {
|
||||||
|
intl: intlShape.isRequired,
|
||||||
onClose: PropTypes.func.isRequired,
|
onClose: PropTypes.func.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default WikiSettings;
|
export default injectIntl(WikiSettings);
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ const messages = defineMessages({
|
|||||||
},
|
},
|
||||||
enablePublicWikiHelp: {
|
enablePublicWikiHelp: {
|
||||||
id: 'course-authoring.pages-resources.wiki.enable-public-wiki.help',
|
id: 'course-authoring.pages-resources.wiki.enable-public-wiki.help',
|
||||||
defaultMessage: `If enabled, any registered user can view the course wiki
|
defaultMessage: `If enabled, edX users can view the course wiki even when
|
||||||
even if they are not enrolled in the course`,
|
they're not enrolled in the course.`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "Wiki configuration for courses using it",
|
"description": "Wiki configuration for courses using it",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@edx/frontend-app-authoring": "*",
|
"@edx/frontend-app-course-authoring": "*",
|
||||||
"@edx/frontend-platform": "*",
|
"@edx/frontend-platform": "*",
|
||||||
"@openedx/paragon": "*",
|
"@openedx/paragon": "*",
|
||||||
"prop-types": "*",
|
"prop-types": "*",
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
"yup": "*"
|
"yup": "*"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@edx/frontend-app-authoring": {
|
"@edx/frontend-app-course-authoring": {
|
||||||
"optional": true
|
"optional": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useCallback, useContext, useEffect } from 'react';
|
import React, { useCallback, useContext, useEffect } from 'react';
|
||||||
import { useDispatch } from 'react-redux';
|
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 { PagesAndResourcesContext } from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
@@ -9,8 +10,7 @@ import messages from './messages';
|
|||||||
|
|
||||||
import { fetchXpertSettings } from './data/thunks';
|
import { fetchXpertSettings } from './data/thunks';
|
||||||
|
|
||||||
const XpertUnitSummarySettings = () => {
|
const XpertUnitSummarySettings = ({ intl }) => {
|
||||||
const intl = useIntl();
|
|
||||||
const { path: pagesAndResourcesPath, courseId } = useContext(PagesAndResourcesContext);
|
const { path: pagesAndResourcesPath, courseId } = useContext(PagesAndResourcesContext);
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -38,4 +38,8 @@ const XpertUnitSummarySettings = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default XpertUnitSummarySettings;
|
XpertUnitSummarySettings.propTypes = {
|
||||||
|
intl: intlShape.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default injectIntl(XpertUnitSummarySettings);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||||
import { AppProvider, PageWrap } from '@edx/frontend-platform/react';
|
import { AppProvider, PageWrap } from '@edx/frontend-platform/react';
|
||||||
import {
|
import {
|
||||||
findByTestId, queryByTestId, render, waitFor, getByText, fireEvent,
|
queryByTestId, render, waitFor, getByText, fireEvent,
|
||||||
} from '@testing-library/react';
|
} from '@testing-library/react';
|
||||||
import MockAdapter from 'axios-mock-adapter';
|
import MockAdapter from 'axios-mock-adapter';
|
||||||
import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
|
import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
|
||||||
@@ -106,9 +106,8 @@ describe('XpertUnitSummarySettings', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('Shows switch on if enabled from backend', async () => {
|
test('Shows switch on if enabled from backend', async () => {
|
||||||
const enableBadge = await findByTestId(container, 'enable-badge');
|
|
||||||
expect(container.querySelector('#enable-xpert-unit-summary-toggle').checked).toBeTruthy();
|
expect(container.querySelector('#enable-xpert-unit-summary-toggle').checked).toBeTruthy();
|
||||||
expect(enableBadge).toBeTruthy();
|
expect(queryByTestId(container, 'enable-badge')).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Shows switch on if disabled from backend', async () => {
|
test('Shows switch on if disabled from backend', async () => {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export function updateXpertSettings(courseId, state) {
|
|||||||
}
|
}
|
||||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||||
return false;
|
return false;
|
||||||
} catch {
|
} catch (error) {
|
||||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -33,7 +33,7 @@ export function fetchXpertPluginConfigurable(courseId) {
|
|||||||
try {
|
try {
|
||||||
const { response } = await getXpertPluginConfigurable(courseId);
|
const { response } = await getXpertPluginConfigurable(courseId);
|
||||||
enabled = response?.enabled;
|
enabled = response?.enabled;
|
||||||
} catch {
|
} catch (e) {
|
||||||
enabled = undefined;
|
enabled = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,7 +55,7 @@ export function fetchXpertSettings(courseId) {
|
|||||||
try {
|
try {
|
||||||
const { response } = await getXpertSettings(courseId);
|
const { response } = await getXpertSettings(courseId);
|
||||||
enabled = response?.enabled;
|
enabled = response?.enabled;
|
||||||
} catch {
|
} catch (e) {
|
||||||
enabled = undefined;
|
enabled = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,7 +86,7 @@ export function removeXpertSettings(courseId) {
|
|||||||
}
|
}
|
||||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||||
return false;
|
return false;
|
||||||
} catch {
|
} catch (error) {
|
||||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -105,7 +105,7 @@ export function resetXpertSettings(courseId, state) {
|
|||||||
}
|
}
|
||||||
dispatch(updateResetStatus({ status: RequestStatus.FAILED }));
|
dispatch(updateResetStatus({ status: RequestStatus.FAILED }));
|
||||||
return false;
|
return false;
|
||||||
} catch {
|
} catch (error) {
|
||||||
dispatch(updateResetStatus({ status: RequestStatus.FAILED }));
|
dispatch(updateResetStatus({ status: RequestStatus.FAILED }));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "Xpert Unit Summaries configuration for courses using it",
|
"description": "Xpert Unit Summaries configuration for courses using it",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@edx/frontend-app-authoring": "*",
|
"@edx/frontend-app-course-authoring": "*",
|
||||||
"@edx/frontend-platform": "*",
|
"@edx/frontend-platform": "*",
|
||||||
"@openedx/paragon": "*",
|
"@openedx/paragon": "*",
|
||||||
"formik": "*",
|
"formik": "*",
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
"react-router-dom": "*"
|
"react-router-dom": "*"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@edx/frontend-app-authoring": {
|
"@edx/frontend-app-course-authoring": {
|
||||||
"optional": true
|
"optional": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||||
import { getExternalLinkUrl } from '@edx/frontend-platform';
|
|
||||||
import {
|
import {
|
||||||
ActionRow,
|
ActionRow,
|
||||||
Alert,
|
Alert,
|
||||||
@@ -71,40 +70,38 @@ AppSettingsForm.defaultProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const SettingsModalBase = ({
|
const SettingsModalBase = ({
|
||||||
title, onClose, variant, isMobile, children, footer,
|
intl, title, onClose, variant, isMobile, children, footer,
|
||||||
}) => {
|
}) => (
|
||||||
const intl = useIntl();
|
<ModalDialog
|
||||||
return (
|
title={title}
|
||||||
<ModalDialog
|
isOpen
|
||||||
title={title}
|
onClose={onClose}
|
||||||
isOpen
|
size="lg"
|
||||||
onClose={onClose}
|
variant={variant}
|
||||||
size="lg"
|
hasCloseButton={isMobile}
|
||||||
variant={variant}
|
isFullscreenOnMobile
|
||||||
hasCloseButton={isMobile}
|
>
|
||||||
isFullscreenOnMobile
|
<ModalDialog.Header>
|
||||||
>
|
<ModalDialog.Title data-testid="modal-title">
|
||||||
<ModalDialog.Header>
|
{title}
|
||||||
<ModalDialog.Title data-testid="modal-title">
|
</ModalDialog.Title>
|
||||||
{title}
|
</ModalDialog.Header>
|
||||||
</ModalDialog.Title>
|
<ModalDialog.Body>
|
||||||
</ModalDialog.Header>
|
{children}
|
||||||
<ModalDialog.Body>
|
</ModalDialog.Body>
|
||||||
{children}
|
<ModalDialog.Footer className="p-4">
|
||||||
</ModalDialog.Body>
|
<ActionRow>
|
||||||
<ModalDialog.Footer className="p-4">
|
<ModalDialog.CloseButton variant="tertiary">
|
||||||
<ActionRow>
|
{intl.formatMessage(messages.cancel)}
|
||||||
<ModalDialog.CloseButton variant="tertiary">
|
</ModalDialog.CloseButton>
|
||||||
{intl.formatMessage(messages.cancel)}
|
{footer}
|
||||||
</ModalDialog.CloseButton>
|
</ActionRow>
|
||||||
{footer}
|
</ModalDialog.Footer>
|
||||||
</ActionRow>
|
</ModalDialog>
|
||||||
</ModalDialog.Footer>
|
);
|
||||||
</ModalDialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
SettingsModalBase.propTypes = {
|
SettingsModalBase.propTypes = {
|
||||||
|
intl: intlShape.isRequired,
|
||||||
title: PropTypes.string.isRequired,
|
title: PropTypes.string.isRequired,
|
||||||
onClose: PropTypes.func.isRequired,
|
onClose: PropTypes.func.isRequired,
|
||||||
variant: PropTypes.oneOf(['default', 'dark']).isRequired,
|
variant: PropTypes.oneOf(['default', 'dark']).isRequired,
|
||||||
@@ -118,11 +115,11 @@ SettingsModalBase.defaultProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ResetUnitsButton = ({
|
const ResetUnitsButton = ({
|
||||||
|
intl,
|
||||||
courseId,
|
courseId,
|
||||||
checked,
|
checked,
|
||||||
visible,
|
visible,
|
||||||
}) => {
|
}) => {
|
||||||
const intl = useIntl();
|
|
||||||
const resetStatusRequestStatus = useSelector(getResetStatus);
|
const resetStatusRequestStatus = useSelector(getResetStatus);
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
@@ -140,12 +137,12 @@ const ResetUnitsButton = ({
|
|||||||
|
|
||||||
const getResetButtonState = () => {
|
const getResetButtonState = () => {
|
||||||
switch (resetStatusRequestStatus) {
|
switch (resetStatusRequestStatus) {
|
||||||
case RequestStatus.PENDING:
|
case RequestStatus.PENDING:
|
||||||
return 'pending';
|
return 'pending';
|
||||||
case RequestStatus.SUCCESSFUL:
|
case RequestStatus.SUCCESSFUL:
|
||||||
return 'finish';
|
return 'finish';
|
||||||
default:
|
default:
|
||||||
return 'default';
|
return 'default';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -188,6 +185,7 @@ const ResetUnitsButton = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
ResetUnitsButton.propTypes = {
|
ResetUnitsButton.propTypes = {
|
||||||
|
intl: intlShape.isRequired,
|
||||||
courseId: PropTypes.string.isRequired,
|
courseId: PropTypes.string.isRequired,
|
||||||
checked: PropTypes.oneOf(['true', 'false']).isRequired,
|
checked: PropTypes.oneOf(['true', 'false']).isRequired,
|
||||||
visible: PropTypes.bool,
|
visible: PropTypes.bool,
|
||||||
@@ -198,6 +196,7 @@ ResetUnitsButton.defaultProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const SettingsModal = ({
|
const SettingsModal = ({
|
||||||
|
intl,
|
||||||
appId,
|
appId,
|
||||||
title,
|
title,
|
||||||
children,
|
children,
|
||||||
@@ -214,7 +213,6 @@ const SettingsModal = ({
|
|||||||
allUnitsEnabledText,
|
allUnitsEnabledText,
|
||||||
noUnitsEnabledText,
|
noUnitsEnabledText,
|
||||||
}) => {
|
}) => {
|
||||||
const intl = useIntl();
|
|
||||||
const { courseId } = useContext(PagesAndResourcesContext);
|
const { courseId } = useContext(PagesAndResourcesContext);
|
||||||
const loadingStatus = useSelector(getLoadingStatus);
|
const loadingStatus = useSelector(getLoadingStatus);
|
||||||
const updateSettingsRequestStatus = useSelector(getSavingStatus);
|
const updateSettingsRequestStatus = useSelector(getSavingStatus);
|
||||||
@@ -239,10 +237,8 @@ const SettingsModal = ({
|
|||||||
const values = { ...rest, enabled: enabled ? checked === 'true' : undefined };
|
const values = { ...rest, enabled: enabled ? checked === 'true' : undefined };
|
||||||
|
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
// oxlint-disable-next-line @typescript-eslint/await-thenable - this dispatch() IS returning a promise.
|
|
||||||
success = await dispatch(updateXpertSettings(courseId, values));
|
success = await dispatch(updateXpertSettings(courseId, values));
|
||||||
} else {
|
} else {
|
||||||
// oxlint-disable-next-line @typescript-eslint/await-thenable - this dispatch() IS returning a promise.
|
|
||||||
success = await dispatch(removeXpertSettings(courseId));
|
success = await dispatch(removeXpertSettings(courseId));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,7 +246,7 @@ const SettingsModal = ({
|
|||||||
success = success && await onSettingsSave(values);
|
success = success && await onSettingsSave(values);
|
||||||
}
|
}
|
||||||
setSaveError(!success);
|
setSaveError(!success);
|
||||||
!success && alertRef?.current.scrollIntoView(); // eslint-disable-line @typescript-eslint/no-unused-expressions
|
!success && alertRef?.current.scrollIntoView(); // eslint-disable-line no-unused-expressions
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFormikSubmit = ({ handleSubmit, errors }) => async (event) => {
|
const handleFormikSubmit = ({ handleSubmit, errors }) => async (event) => {
|
||||||
@@ -279,7 +275,7 @@ const SettingsModal = ({
|
|||||||
<div className="py-1">
|
<div className="py-1">
|
||||||
<Hyperlink
|
<Hyperlink
|
||||||
className="text-primary-500"
|
className="text-primary-500"
|
||||||
destination={getExternalLinkUrl('https://openai.com/api-data-privacy')}
|
destination="https://openai.com/api-data-privacy"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer noopener"
|
rel="noreferrer noopener"
|
||||||
>
|
>
|
||||||
@@ -376,6 +372,7 @@ const SettingsModal = ({
|
|||||||
>
|
>
|
||||||
{allUnitsEnabledText}
|
{allUnitsEnabledText}
|
||||||
<ResetUnitsButton
|
<ResetUnitsButton
|
||||||
|
intl={intl}
|
||||||
courseId={courseId}
|
courseId={courseId}
|
||||||
checked={formikProps.values.checked}
|
checked={formikProps.values.checked}
|
||||||
visible={formikProps.values.checked === 'true'}
|
visible={formikProps.values.checked === 'true'}
|
||||||
@@ -388,6 +385,7 @@ const SettingsModal = ({
|
|||||||
>
|
>
|
||||||
{noUnitsEnabledText}
|
{noUnitsEnabledText}
|
||||||
<ResetUnitsButton
|
<ResetUnitsButton
|
||||||
|
intl={intl}
|
||||||
courseId={courseId}
|
courseId={courseId}
|
||||||
checked={formikProps.values.checked}
|
checked={formikProps.values.checked}
|
||||||
visible={formikProps.values.checked === 'false'}
|
visible={formikProps.values.checked === 'false'}
|
||||||
@@ -425,6 +423,7 @@ const SettingsModal = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
SettingsModal.propTypes = {
|
SettingsModal.propTypes = {
|
||||||
|
intl: intlShape.isRequired,
|
||||||
title: PropTypes.string.isRequired,
|
title: PropTypes.string.isRequired,
|
||||||
appId: PropTypes.string.isRequired,
|
appId: PropTypes.string.isRequired,
|
||||||
children: PropTypes.func,
|
children: PropTypes.func,
|
||||||
@@ -451,4 +450,4 @@ SettingsModal.defaultProps = {
|
|||||||
enableReinitialize: false,
|
enableReinitialize: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default SettingsModal;
|
export default injectIntl(SettingsModal);
|
||||||
|
|||||||
@@ -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 {
|
.summary-radio {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -19,6 +19,15 @@
|
|||||||
"matchPackagePatterns": ["@edx", "@openedx"],
|
"matchPackagePatterns": ["@edx", "@openedx"],
|
||||||
"matchUpdateTypes": ["minor", "patch"],
|
"matchUpdateTypes": ["minor", "patch"],
|
||||||
"automerge": false
|
"automerge": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matchPackagePatterns": ["@edx/frontend-lib-content-components"],
|
||||||
|
"matchUpdateTypes": ["minor", "patch"],
|
||||||
|
"automerge": false,
|
||||||
|
"schedule": [
|
||||||
|
"after 1am",
|
||||||
|
"before 11pm"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
100
src/CourseAuthoringPage.jsx
Normal file
100
src/CourseAuthoringPage.jsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
|
||||||
|
import {
|
||||||
|
useLocation,
|
||||||
|
} from 'react-router-dom';
|
||||||
|
import { StudioFooter } from '@edx/frontend-component-footer';
|
||||||
|
import Header from './header';
|
||||||
|
import { fetchCourseDetail } from './data/thunks';
|
||||||
|
import { useModel } from './generic/model-store';
|
||||||
|
import NotFoundAlert from './generic/NotFoundAlert';
|
||||||
|
import PermissionDeniedAlert from './generic/PermissionDeniedAlert';
|
||||||
|
import { getCourseAppsApiStatus } from './pages-and-resources/data/selectors';
|
||||||
|
import { RequestStatus } from './data/constants';
|
||||||
|
import Loading from './generic/Loading';
|
||||||
|
|
||||||
|
const AppHeader = ({
|
||||||
|
courseNumber, courseOrg, courseTitle, courseId,
|
||||||
|
}) => (
|
||||||
|
<Header
|
||||||
|
courseNumber={courseNumber}
|
||||||
|
courseOrg={courseOrg}
|
||||||
|
courseTitle={courseTitle}
|
||||||
|
courseId={courseId}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
AppHeader.propTypes = {
|
||||||
|
courseId: PropTypes.string.isRequired,
|
||||||
|
courseNumber: PropTypes.string,
|
||||||
|
courseOrg: PropTypes.string,
|
||||||
|
courseTitle: PropTypes.string.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
AppHeader.defaultProps = {
|
||||||
|
courseNumber: null,
|
||||||
|
courseOrg: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const CourseAuthoringPage = ({ courseId, children }) => {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(fetchCourseDetail(courseId));
|
||||||
|
}, [courseId]);
|
||||||
|
|
||||||
|
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');
|
||||||
|
|
||||||
|
if (courseDetailStatus === RequestStatus.NOT_FOUND && !isEditor) {
|
||||||
|
return (
|
||||||
|
<NotFoundAlert />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (courseAppsApiStatus === RequestStatus.DENIED) {
|
||||||
|
return (
|
||||||
|
<PermissionDeniedAlert />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className={pathname.includes('/editor/') ? '' : 'bg-light-200'}>
|
||||||
|
{/* While V2 Editors are temporarily served from their own pages
|
||||||
|
using url pattern containing /editor/,
|
||||||
|
we shouldn't have the header and footer on these pages.
|
||||||
|
This functionality will be removed in TNL-9591 */}
|
||||||
|
{inProgress ? !isEditor && <Loading />
|
||||||
|
: (!isEditor && (
|
||||||
|
<AppHeader
|
||||||
|
courseNumber={courseNumber}
|
||||||
|
courseOrg={courseOrg}
|
||||||
|
courseTitle={courseTitle}
|
||||||
|
courseId={courseId}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
{!inProgress && !isEditor && <StudioFooter />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
CourseAuthoringPage.propTypes = {
|
||||||
|
children: PropTypes.node,
|
||||||
|
courseId: PropTypes.string.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
CourseAuthoringPage.defaultProps = {
|
||||||
|
children: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CourseAuthoringPage;
|
||||||
@@ -1,12 +1,18 @@
|
|||||||
import { getConfig } from '@edx/frontend-platform';
|
import React from 'react';
|
||||||
|
|
||||||
|
import { render } from '@testing-library/react';
|
||||||
|
|
||||||
|
import { getConfig, 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 CourseAuthoringPage from './CourseAuthoringPage';
|
import CourseAuthoringPage from './CourseAuthoringPage';
|
||||||
import PagesAndResources from './pages-and-resources/PagesAndResources';
|
import PagesAndResources from './pages-and-resources/PagesAndResources';
|
||||||
import { executeThunk } from './utils';
|
import { executeThunk } from './utils';
|
||||||
import { fetchCourseApps } from './pages-and-resources/data/thunks';
|
import { fetchCourseApps } from './pages-and-resources/data/thunks';
|
||||||
import { getApiWaffleFlagsUrl } from './data/api';
|
import { fetchCourseDetail } from './data/thunks';
|
||||||
import { initializeMocks, render } from './testUtils';
|
|
||||||
import { CourseAuthoringProvider } from './CourseAuthoringContext';
|
|
||||||
|
|
||||||
const courseId = 'course-v1:edX+TestX+Test_Course';
|
const courseId = 'course-v1:edX+TestX+Test_Course';
|
||||||
let mockPathname = '/evilguy/';
|
let mockPathname = '/evilguy/';
|
||||||
@@ -19,19 +25,17 @@ jest.mock('react-router-dom', () => ({
|
|||||||
let axiosMock;
|
let axiosMock;
|
||||||
let store;
|
let store;
|
||||||
|
|
||||||
const renderComponent = children => render(
|
beforeEach(() => {
|
||||||
<CourseAuthoringProvider courseId={courseId}>
|
initializeMockApp({
|
||||||
{children}
|
authenticatedUser: {
|
||||||
</CourseAuthoringProvider>,
|
userId: 3,
|
||||||
);
|
username: 'abc123',
|
||||||
|
administrator: true,
|
||||||
beforeEach(async () => {
|
roles: [],
|
||||||
const mocks = initializeMocks();
|
},
|
||||||
store = mocks.reduxStore;
|
});
|
||||||
axiosMock = mocks.axiosMock;
|
store = initializeStore();
|
||||||
axiosMock
|
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||||
.onGet(getApiWaffleFlagsUrl(courseId))
|
|
||||||
.reply(200, {});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Editor Pages Load no header', () => {
|
describe('Editor Pages Load no header', () => {
|
||||||
@@ -41,14 +45,19 @@ describe('Editor Pages Load no header', () => {
|
|||||||
axiosMock.onGet(`${courseAppsApiUrl}/${courseId}`).reply(200, {
|
axiosMock.onGet(`${courseAppsApiUrl}/${courseId}`).reply(200, {
|
||||||
response: { status: 200 },
|
response: { status: 200 },
|
||||||
});
|
});
|
||||||
|
await executeThunk(fetchCourseApps(courseId), store.dispatch);
|
||||||
};
|
};
|
||||||
test('renders no loading wheel on editor pages', async () => {
|
test('renders no loading wheel on editor pages', async () => {
|
||||||
mockPathname = '/editor/';
|
mockPathname = '/editor/';
|
||||||
await mockStoreSuccess();
|
await mockStoreSuccess();
|
||||||
const wrapper = renderComponent(
|
const wrapper = render(
|
||||||
<CourseAuthoringPage>
|
<AppProvider store={store}>
|
||||||
<PagesAndResources />
|
<IntlProvider locale="en">
|
||||||
</CourseAuthoringPage>
|
<CourseAuthoringPage courseId={courseId}>
|
||||||
|
<PagesAndResources courseId={courseId} />
|
||||||
|
</CourseAuthoringPage>
|
||||||
|
</IntlProvider>
|
||||||
|
</AppProvider>
|
||||||
,
|
,
|
||||||
);
|
);
|
||||||
expect(wrapper.queryByRole('status')).not.toBeInTheDocument();
|
expect(wrapper.queryByRole('status')).not.toBeInTheDocument();
|
||||||
@@ -56,10 +65,14 @@ describe('Editor Pages Load no header', () => {
|
|||||||
test('renders loading wheel on non editor pages', async () => {
|
test('renders loading wheel on non editor pages', async () => {
|
||||||
mockPathname = '/evilguy/';
|
mockPathname = '/evilguy/';
|
||||||
await mockStoreSuccess();
|
await mockStoreSuccess();
|
||||||
const wrapper = renderComponent(
|
const wrapper = render(
|
||||||
<CourseAuthoringPage>
|
<AppProvider store={store}>
|
||||||
<PagesAndResources />
|
<IntlProvider locale="en">
|
||||||
</CourseAuthoringPage>
|
<CourseAuthoringPage courseId={courseId}>
|
||||||
|
<PagesAndResources courseId={courseId} />
|
||||||
|
</CourseAuthoringPage>
|
||||||
|
</IntlProvider>
|
||||||
|
</AppProvider>
|
||||||
,
|
,
|
||||||
);
|
);
|
||||||
expect(wrapper.queryByRole('status')).toBeInTheDocument();
|
expect(wrapper.queryByRole('status')).toBeInTheDocument();
|
||||||
@@ -75,6 +88,7 @@ describe('Course authoring page', () => {
|
|||||||
).reply(404, {
|
).reply(404, {
|
||||||
response: { status: 404 },
|
response: { status: 404 },
|
||||||
});
|
});
|
||||||
|
await executeThunk(fetchCourseDetail(courseId), store.dispatch);
|
||||||
};
|
};
|
||||||
const mockStoreError = async () => {
|
const mockStoreError = async () => {
|
||||||
axiosMock.onGet(
|
axiosMock.onGet(
|
||||||
@@ -82,10 +96,18 @@ describe('Course authoring page', () => {
|
|||||||
).reply(500, {
|
).reply(500, {
|
||||||
response: { status: 500 },
|
response: { status: 500 },
|
||||||
});
|
});
|
||||||
|
await executeThunk(fetchCourseDetail(courseId), store.dispatch);
|
||||||
};
|
};
|
||||||
test('renders not found page on non-existent course key', async () => {
|
test('renders not found page on non-existent course key', async () => {
|
||||||
await mockStoreNotFound();
|
await mockStoreNotFound();
|
||||||
const wrapper = renderComponent(<CourseAuthoringPage />);
|
const wrapper = render(
|
||||||
|
<AppProvider store={store}>
|
||||||
|
<IntlProvider locale="en">
|
||||||
|
<CourseAuthoringPage courseId={courseId} />
|
||||||
|
</IntlProvider>
|
||||||
|
</AppProvider>
|
||||||
|
,
|
||||||
|
);
|
||||||
expect(await wrapper.findByTestId('notFoundAlert')).toBeInTheDocument();
|
expect(await wrapper.findByTestId('notFoundAlert')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
test('does not render not found page on other kinds of error', async () => {
|
test('does not render not found page on other kinds of error', async () => {
|
||||||
@@ -95,29 +117,17 @@ describe('Course authoring page', () => {
|
|||||||
// IN_PROGRESS but also not NOT_FOUND or DENIED- then check that the not
|
// IN_PROGRESS but also not NOT_FOUND or DENIED- then check that the not
|
||||||
// found alert is not present.
|
// found alert is not present.
|
||||||
const contentTestId = 'courseAuthoringPageContent';
|
const contentTestId = 'courseAuthoringPageContent';
|
||||||
const wrapper = renderComponent(
|
const wrapper = render(
|
||||||
<CourseAuthoringPage>
|
<AppProvider store={store}>
|
||||||
<div data-testid={contentTestId} />
|
<IntlProvider locale="en">
|
||||||
</CourseAuthoringPage>
|
<CourseAuthoringPage courseId={courseId}>
|
||||||
|
<div data-testid={contentTestId} />
|
||||||
|
</CourseAuthoringPage>
|
||||||
|
</IntlProvider>
|
||||||
|
</AppProvider>
|
||||||
,
|
,
|
||||||
);
|
);
|
||||||
expect(await wrapper.findByTestId(contentTestId)).toBeInTheDocument();
|
expect(await wrapper.findByTestId(contentTestId)).toBeInTheDocument();
|
||||||
expect(wrapper.queryByTestId('notFoundAlert')).not.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();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
import React, { useEffect } from 'react';
|
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
|
||||||
|
|
||||||
import {
|
|
||||||
useLocation,
|
|
||||||
} from 'react-router-dom';
|
|
||||||
import { StudioFooterSlot } from '@edx/frontend-component-footer';
|
|
||||||
import Header from './header';
|
|
||||||
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 dispatch = useDispatch();
|
|
||||||
|
|
||||||
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 courseAppsApiStatus = useSelector(getCourseAppsApiStatus);
|
|
||||||
const { pathname } = useLocation();
|
|
||||||
const isEditor = pathname.includes('/editor');
|
|
||||||
|
|
||||||
if (courseDetailStatus === RequestStatus.NOT_FOUND && !isEditor) {
|
|
||||||
return (
|
|
||||||
<NotFoundAlert />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (courseAppsApiStatus === RequestStatus.DENIED) {
|
|
||||||
return (
|
|
||||||
<PermissionDeniedAlert />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{/* While V2 Editors are temporarily served from their own pages
|
|
||||||
using url pattern containing /editor/,
|
|
||||||
we shouldn't have the header and footer on these pages.
|
|
||||||
This functionality will be removed in TNL-9591 */}
|
|
||||||
{inProgress ? !isEditor && <Loading />
|
|
||||||
: (!isEditor && (
|
|
||||||
<Header
|
|
||||||
number={courseNumber}
|
|
||||||
org={courseOrg}
|
|
||||||
title={courseTitle}
|
|
||||||
contextId={courseId}
|
|
||||||
containerProps={{
|
|
||||||
size: 'fluid',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
{children}
|
|
||||||
{!inProgress && !isEditor && <StudioFooterSlot />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CourseAuthoringPage;
|
|
||||||
123
src/CourseAuthoringRoutes.jsx
Normal file
123
src/CourseAuthoringRoutes.jsx
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
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 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 CourseExportPage from './export-page/CourseExportPage';
|
||||||
|
import CourseImportPage from './import-page/CourseImportPage';
|
||||||
|
import { DECODED_ROUTES } from './constants';
|
||||||
|
import CourseChecklist from './course-checklist';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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="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><CourseUnit courseId={courseId} /></PageWrap>}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<Route
|
||||||
|
path="editor/course-videos/:blockId"
|
||||||
|
element={getConfig().ENABLE_NEW_EDITOR_PAGES === 'true' ? <PageWrap><VideoSelectorContainer courseId={courseId} /></PageWrap> : null}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="editor/:blockType/:blockId?"
|
||||||
|
element={getConfig().ENABLE_NEW_EDITOR_PAGES === 'true' ? <PageWrap><EditorContainer courseId={courseId} /></PageWrap> : null}
|
||||||
|
/>
|
||||||
|
<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="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="checklists"
|
||||||
|
element={<PageWrap><CourseChecklist courseId={courseId} /></PageWrap>}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
</CourseAuthoringPage>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CourseAuthoringRoutes;
|
||||||
116
src/CourseAuthoringRoutes.test.jsx
Normal file
116
src/CourseAuthoringRoutes.test.jsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { AppProvider } from '@edx/frontend-platform/react';
|
||||||
|
import { initializeMockApp } from '@edx/frontend-platform';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
import CourseAuthoringRoutes from './CourseAuthoringRoutes';
|
||||||
|
import initializeStore from './store';
|
||||||
|
|
||||||
|
const courseId = 'course-v1:edX+TestX+Test_Course';
|
||||||
|
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', () => ({
|
||||||
|
...jest.requireActual('react-router-dom'),
|
||||||
|
useParams: () => ({
|
||||||
|
courseId,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the TinyMceWidget from frontend-lib-content-components
|
||||||
|
jest.mock('@edx/frontend-lib-content-components', () => ({
|
||||||
|
TinyMceWidget: () => <div>Widget</div>,
|
||||||
|
Footer: () => <div>Footer</div>,
|
||||||
|
prepareEditorRef: jest.fn(() => ({
|
||||||
|
refReady: true,
|
||||||
|
setEditorRef: jest.fn().mockName('prepareEditorRef.setEditorRef'),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('./pages-and-resources/PagesAndResources', () => (props) => {
|
||||||
|
mockComponentFn(props);
|
||||||
|
return pagesAndResourcesMockText;
|
||||||
|
});
|
||||||
|
jest.mock('./editors/EditorContainer', () => (props) => {
|
||||||
|
mockComponentFn(props);
|
||||||
|
return editorContainerMockText;
|
||||||
|
});
|
||||||
|
jest.mock('./selectors/VideoSelectorContainer', () => (props) => {
|
||||||
|
mockComponentFn(props);
|
||||||
|
return videoSelectorContainerMockText;
|
||||||
|
});
|
||||||
|
jest.mock('./custom-pages/CustomPages', () => (props) => {
|
||||||
|
mockComponentFn(props);
|
||||||
|
return customPagesMockText;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('<CourseAuthoringRoutes>', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
initializeMockApp({
|
||||||
|
authenticatedUser: {
|
||||||
|
userId: 3,
|
||||||
|
username: 'abc123',
|
||||||
|
administrator: true,
|
||||||
|
roles: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
store = initializeStore();
|
||||||
|
});
|
||||||
|
|
||||||
|
fit('renders the PagesAndResources component when the pages and resources route is active', () => {
|
||||||
|
render(
|
||||||
|
<AppProvider store={store} wrapWithRouter={false}>
|
||||||
|
<MemoryRouter initialEntries={['/pages-and-resources']}>
|
||||||
|
<CourseAuthoringRoutes />
|
||||||
|
</MemoryRouter>
|
||||||
|
</AppProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText(pagesAndResourcesMockText)).toBeVisible();
|
||||||
|
expect(mockComponentFn).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
courseId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the EditorContainer component when the course editor route is active', () => {
|
||||||
|
render(
|
||||||
|
<AppProvider store={store} wrapWithRouter={false}>
|
||||||
|
<MemoryRouter initialEntries={['/editor/video/block-id']}>
|
||||||
|
<CourseAuthoringRoutes />
|
||||||
|
</MemoryRouter>
|
||||||
|
</AppProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByText(editorContainerMockText)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(pagesAndResourcesMockText)).not.toBeInTheDocument();
|
||||||
|
expect(mockComponentFn).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
courseId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the VideoSelectorContainer component when the course videos route is active', () => {
|
||||||
|
render(
|
||||||
|
<AppProvider store={store} wrapWithRouter={false}>
|
||||||
|
<MemoryRouter initialEntries={['/editor/course-videos/block-id']}>
|
||||||
|
<CourseAuthoringRoutes />
|
||||||
|
</MemoryRouter>
|
||||||
|
</AppProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByText(videoSelectorContainerMockText)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(pagesAndResourcesMockText)).not.toBeInTheDocument();
|
||||||
|
expect(mockComponentFn).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
courseId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
import CourseAuthoringRoutes from './CourseAuthoringRoutes';
|
|
||||||
import { getApiWaffleFlagsUrl } from './data/api';
|
|
||||||
import {
|
|
||||||
screen, initializeMocks, render, waitFor,
|
|
||||||
} from './testUtils';
|
|
||||||
|
|
||||||
const courseId = 'course-v1:edX+TestX+Test_Course';
|
|
||||||
const pagesAndResourcesMockText = 'Pages And Resources';
|
|
||||||
const editorContainerMockText = 'Editor Container';
|
|
||||||
const videoSelectorContainerMockText = 'Video Selector Container';
|
|
||||||
const customPagesMockText = 'Custom Pages';
|
|
||||||
const mockComponentFn = jest.fn();
|
|
||||||
|
|
||||||
jest.mock('react-router-dom', () => ({
|
|
||||||
...jest.requireActual('react-router-dom'),
|
|
||||||
useParams: () => ({
|
|
||||||
courseId,
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock the TinyMceWidget
|
|
||||||
jest.mock('./editors/sharedComponents/TinyMceWidget', () => ({
|
|
||||||
__esModule: true, // Required to mock a default export
|
|
||||||
default: () => <div>Widget</div>,
|
|
||||||
Footer: () => <div>Footer</div>,
|
|
||||||
prepareEditorRef: jest.fn(() => ({
|
|
||||||
refReady: true,
|
|
||||||
setEditorRef: jest.fn().mockName('prepareEditorRef.setEditorRef'),
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('./pages-and-resources/PagesAndResources', () => (props) => {
|
|
||||||
mockComponentFn(props);
|
|
||||||
return pagesAndResourcesMockText;
|
|
||||||
});
|
|
||||||
jest.mock('./editors/EditorContainer', () => (props) => {
|
|
||||||
mockComponentFn(props);
|
|
||||||
return editorContainerMockText;
|
|
||||||
});
|
|
||||||
jest.mock('./selectors/VideoSelectorContainer', () => (props) => {
|
|
||||||
mockComponentFn(props);
|
|
||||||
return videoSelectorContainerMockText;
|
|
||||||
});
|
|
||||||
jest.mock('./custom-pages/CustomPages', () => (props) => {
|
|
||||||
mockComponentFn(props);
|
|
||||||
return customPagesMockText;
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('<CourseAuthoringRoutes>', () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
const user = {
|
|
||||||
userId: 1,
|
|
||||||
username: 'username',
|
|
||||||
};
|
|
||||||
const { axiosMock } = initializeMocks({ user });
|
|
||||||
axiosMock
|
|
||||||
.onGet(getApiWaffleFlagsUrl(courseId))
|
|
||||||
.reply(200, {});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders the PagesAndResources component when the pages and resources route is active', async () => {
|
|
||||||
render(
|
|
||||||
<CourseAuthoringRoutes />,
|
|
||||||
{ routerProps: { initialEntries: ['/pages-and-resources'] } },
|
|
||||||
);
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText(pagesAndResourcesMockText)).toBeVisible();
|
|
||||||
expect(mockComponentFn).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders the EditorContainer component when the course editor route is active', async () => {
|
|
||||||
render(
|
|
||||||
<CourseAuthoringRoutes />,
|
|
||||||
{ routerProps: { initialEntries: ['/editor/video/block-id'] } },
|
|
||||||
);
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.queryByText(editorContainerMockText)).toBeInTheDocument();
|
|
||||||
expect(screen.queryByText(pagesAndResourcesMockText)).not.toBeInTheDocument();
|
|
||||||
expect(mockComponentFn).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
learningContextId: courseId,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders the VideoSelectorContainer component when the course videos route is active', async () => {
|
|
||||||
render(
|
|
||||||
<CourseAuthoringRoutes />,
|
|
||||||
{ routerProps: { initialEntries: ['/editor/course-videos/block-id'] } },
|
|
||||||
);
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.queryByText(videoSelectorContainerMockText)).toBeInTheDocument();
|
|
||||||
expect(screen.queryByText(pagesAndResourcesMockText)).not.toBeInTheDocument();
|
|
||||||
expect(mockComponentFn).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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;
|
|
||||||
@@ -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',
|
|
||||||
};
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
export default {
|
|
||||||
content: {
|
|
||||||
id: 67,
|
|
||||||
userId: 3,
|
|
||||||
created: '2024-01-16T13:09:11.540615Z',
|
|
||||||
purpose: 'clipboard',
|
|
||||||
status: 'ready',
|
|
||||||
blockType: 'sequential',
|
|
||||||
blockTypeDisplay: 'Subsection',
|
|
||||||
olxUrl: 'http://localhost:18010/api/content-staging/v1/staged-content/67/olx',
|
|
||||||
displayName: 'Sequences',
|
|
||||||
},
|
|
||||||
sourceUsageKey: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@sequential_0270f6de40fc',
|
|
||||||
sourceContextTitle: 'Demonstration Course',
|
|
||||||
sourceEditUrl: 'http://localhost:18010/container/block-v1:edX+DemoX+Demo_Course+type@sequential+block@sequential_0270f6de40fc',
|
|
||||||
};
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
export default {
|
|
||||||
content: {
|
|
||||||
id: 67,
|
|
||||||
userId: 3,
|
|
||||||
created: '2024-01-16T13:09:11.540615Z',
|
|
||||||
purpose: 'clipboard',
|
|
||||||
status: 'ready',
|
|
||||||
blockType: 'vertical',
|
|
||||||
blockTypeDisplay: 'Unit',
|
|
||||||
olxUrl: 'http://localhost:18010/api/content-staging/v1/staged-content/67/olx',
|
|
||||||
displayName: 'Introduction: Video and Sequences',
|
|
||||||
},
|
|
||||||
sourceUsageKey: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_0270f6de40fc',
|
|
||||||
sourceContextTitle: 'Demonstration Course',
|
|
||||||
sourceEditUrl: 'http://localhost:18010/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_0270f6de40fc',
|
|
||||||
};
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
export default {
|
|
||||||
content: {
|
|
||||||
id: 69,
|
|
||||||
userId: 3,
|
|
||||||
created: '2024-01-16T13:33:21.314439Z',
|
|
||||||
purpose: 'clipboard',
|
|
||||||
status: 'ready',
|
|
||||||
blockType: 'html',
|
|
||||||
blockTypeDisplay: 'Text',
|
|
||||||
olxUrl: 'http://localhost:18010/api/content-staging/v1/staged-content/69/olx',
|
|
||||||
displayName: 'Blank HTML Page',
|
|
||||||
},
|
|
||||||
sourceUsageKey: 'block-v1:edX+DemoX+Demo_Course+type@html+block@html1',
|
|
||||||
sourceContextTitle: 'Demonstration Course',
|
|
||||||
sourceEditUrl: 'http://localhost:18010/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical1',
|
|
||||||
};
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
export { default as clipboardUnit } from './clipboardUnit';
|
|
||||||
export { default as clipboardSubsection } from './clipboardSubsection';
|
|
||||||
export { default as clipboardXBlock } from './clipboardXBlock';
|
|
||||||
export { default as clipboardSection } from './clipboardSection';
|
|
||||||
@@ -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 { Hyperlink, MailtoLink, Stack } from '@openedx/paragon';
|
||||||
|
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
@@ -6,9 +8,6 @@ import messages from './messages';
|
|||||||
const AccessibilityBody = ({
|
const AccessibilityBody = ({
|
||||||
communityAccessibilityLink,
|
communityAccessibilityLink,
|
||||||
email,
|
email,
|
||||||
}: {
|
|
||||||
communityAccessibilityLink: string,
|
|
||||||
email: string,
|
|
||||||
}) => (
|
}) => (
|
||||||
<div className="mt-5">
|
<div className="mt-5">
|
||||||
<header>
|
<header>
|
||||||
@@ -91,4 +90,9 @@ const AccessibilityBody = ({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
export default AccessibilityBody;
|
AccessibilityBody.propTypes = {
|
||||||
|
communityAccessibilityLink: PropTypes.string.isRequired,
|
||||||
|
email: PropTypes.string.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default injectIntl(AccessibilityBody);
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,26 +1,33 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
import {
|
import {
|
||||||
FormattedMessage, FormattedDate, FormattedTime, useIntl,
|
injectIntl, FormattedMessage, intlShape, FormattedDate, FormattedTime,
|
||||||
} from '@edx/frontend-platform/i18n';
|
} from '@edx/frontend-platform/i18n';
|
||||||
import {
|
import {
|
||||||
ActionRow, Alert, Form, Stack, StatefulButton,
|
ActionRow, Alert, Form, Stack, StatefulButton,
|
||||||
} from '@openedx/paragon';
|
} from '@openedx/paragon';
|
||||||
|
|
||||||
import { STATEFUL_BUTTON_STATES } from '@src/constants';
|
import { RequestStatus } from '../../data/constants';
|
||||||
|
import { STATEFUL_BUTTON_STATES } from '../../constants';
|
||||||
|
import submitAccessibilityForm from '../data/thunks';
|
||||||
import useAccessibility from './hooks';
|
import useAccessibility from './hooks';
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
|
|
||||||
const AccessibilityForm = ({ accessibilityEmail }: { accessibilityEmail: string }) => {
|
const AccessibilityForm = ({
|
||||||
const intl = useIntl();
|
accessibilityEmail,
|
||||||
|
// injected
|
||||||
|
intl,
|
||||||
|
}) => {
|
||||||
const {
|
const {
|
||||||
errors,
|
errors,
|
||||||
values,
|
values,
|
||||||
isFormFilled,
|
isFormFilled,
|
||||||
mutation,
|
dispatch,
|
||||||
handleBlur,
|
handleBlur,
|
||||||
handleChange,
|
handleChange,
|
||||||
hasErrorField,
|
hasErrorField,
|
||||||
savingStatus,
|
savingStatus,
|
||||||
} = useAccessibility({ name: '', email: '', message: '' });
|
} = useAccessibility({ name: '', email: '', message: '' }, intl);
|
||||||
|
|
||||||
const formFields = [
|
const formFields = [
|
||||||
{
|
{
|
||||||
@@ -49,7 +56,7 @@ const AccessibilityForm = ({ accessibilityEmail }: { accessibilityEmail: string
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
mutation.mutateAsync(values).catch(() => {});
|
dispatch(submitAccessibilityForm(values));
|
||||||
};
|
};
|
||||||
|
|
||||||
const start = new Date('Mon Jan 29 2018 13:00:00 GMT (UTC)');
|
const start = new Date('Mon Jan 29 2018 13:00:00 GMT (UTC)');
|
||||||
@@ -60,7 +67,7 @@ const AccessibilityForm = ({ accessibilityEmail }: { accessibilityEmail: string
|
|||||||
<h2 className="my-4">
|
<h2 className="my-4">
|
||||||
<FormattedMessage {...messages.accessibilityPolicyFormHeader} />
|
<FormattedMessage {...messages.accessibilityPolicyFormHeader} />
|
||||||
</h2>
|
</h2>
|
||||||
{savingStatus === 'success' && (
|
{savingStatus === RequestStatus.SUCCESSFUL && (
|
||||||
<Alert variant="success">
|
<Alert variant="success">
|
||||||
<Stack gap={2}>
|
<Stack gap={2}>
|
||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
@@ -80,7 +87,7 @@ const AccessibilityForm = ({ accessibilityEmail }: { accessibilityEmail: string
|
|||||||
</Stack>
|
</Stack>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
{savingStatus === 'error' && (
|
{savingStatus === RequestStatus.FAILED && (
|
||||||
<Alert variant="danger">
|
<Alert variant="danger">
|
||||||
<div data-testid="rate-limit-alert">
|
<div data-testid="rate-limit-alert">
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
@@ -119,7 +126,7 @@ const AccessibilityForm = ({ accessibilityEmail }: { accessibilityEmail: string
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={!isFormFilled}
|
disabled={!isFormFilled}
|
||||||
state={
|
state={
|
||||||
savingStatus === 'pending'
|
savingStatus === RequestStatus.IN_PROGRESS
|
||||||
? STATEFUL_BUTTON_STATES.pending
|
? STATEFUL_BUTTON_STATES.pending
|
||||||
: STATEFUL_BUTTON_STATES.default
|
: STATEFUL_BUTTON_STATES.default
|
||||||
}
|
}
|
||||||
@@ -130,4 +137,10 @@ const AccessibilityForm = ({ accessibilityEmail }: { accessibilityEmail: string
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default AccessibilityForm;
|
AccessibilityForm.propTypes = {
|
||||||
|
accessibilityEmail: PropTypes.string.isRequired,
|
||||||
|
// injected
|
||||||
|
intl: intlShape.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default injectIntl(AccessibilityForm);
|
||||||
@@ -1,31 +1,57 @@
|
|||||||
import {
|
import {
|
||||||
initializeMocks,
|
|
||||||
render,
|
render,
|
||||||
|
act,
|
||||||
screen,
|
screen,
|
||||||
} from '@src/testUtils';
|
} from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { initializeMockApp } from '@edx/frontend-platform';
|
||||||
|
import MockAdapter from 'axios-mock-adapter';
|
||||||
|
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||||
|
import { AppProvider } from '@edx/frontend-platform/react';
|
||||||
|
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
|
import initializeStore from '../../store';
|
||||||
|
import { RequestStatus } from '../../data/constants';
|
||||||
|
|
||||||
import AccessibilityForm from './index';
|
import AccessibilityForm from './index';
|
||||||
import { getZendeskrUrl } from '../data/api';
|
import { getZendeskrUrl } from '../data/api';
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
|
|
||||||
let axiosMock;
|
let axiosMock;
|
||||||
|
let store;
|
||||||
|
|
||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
accessibilityEmail: 'accessibilityTest@test.com',
|
accessibilityEmail: 'accessibilityTest@test.com',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const initialState = {
|
||||||
|
accessibilityPage: {
|
||||||
|
savingStatus: '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const renderComponent = () => {
|
const renderComponent = () => {
|
||||||
render(
|
render(
|
||||||
<AccessibilityForm {...defaultProps} />,
|
<IntlProvider locale="en">
|
||||||
|
<AppProvider store={store}>
|
||||||
|
<AccessibilityForm {...defaultProps} />
|
||||||
|
</AppProvider>
|
||||||
|
</IntlProvider>,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('<AccessibilityPolicyForm />', () => {
|
describe('<AccessibilityPolicyForm />', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const mocks = initializeMocks();
|
initializeMockApp({
|
||||||
|
authenticatedUser: {
|
||||||
axiosMock = mocks.axiosMock;
|
userId: 3,
|
||||||
|
username: 'abc123',
|
||||||
|
administrator: false,
|
||||||
|
roles: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
store = initializeStore(initialState);
|
||||||
|
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('renders', () => {
|
describe('renders', () => {
|
||||||
@@ -48,35 +74,24 @@ describe('<AccessibilityPolicyForm />', () => {
|
|||||||
describe('statusAlert', () => {
|
describe('statusAlert', () => {
|
||||||
let formSections;
|
let formSections;
|
||||||
let submitButton;
|
let submitButton;
|
||||||
let user;
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
user = userEvent.setup();
|
|
||||||
renderComponent();
|
renderComponent();
|
||||||
formSections = screen.getAllByRole('textbox');
|
formSections = screen.getAllByRole('textbox');
|
||||||
|
await act(async () => {
|
||||||
await user.type(formSections[0], 'email@email.com');
|
userEvent.type(formSections[0], 'email@email.com');
|
||||||
await user.type(formSections[1], 'test name');
|
userEvent.type(formSections[1], 'test name');
|
||||||
await user.type(formSections[2], 'feedback message');
|
userEvent.type(formSections[2], 'feedback message');
|
||||||
|
});
|
||||||
submitButton = screen.getByText(messages.accessibilityPolicyFormSubmitLabel.defaultMessage);
|
submitButton = screen.getByText(messages.accessibilityPolicyFormSubmitLabel.defaultMessage);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders in progress state', async () => {
|
|
||||||
axiosMock.onPost(getZendeskrUrl()).reply(
|
|
||||||
() => new Promise(() => {
|
|
||||||
// always in pending
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
await user.click(submitButton);
|
|
||||||
|
|
||||||
expect(screen.getByRole('button', { name: /submitting/i })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows correct success message', async () => {
|
it('shows correct success message', async () => {
|
||||||
axiosMock.onPost(getZendeskrUrl()).reply(200);
|
axiosMock.onPost(getZendeskrUrl()).reply(200);
|
||||||
|
await act(async () => {
|
||||||
await user.click(submitButton);
|
userEvent.click(submitButton);
|
||||||
|
});
|
||||||
|
const { savingStatus } = store.getState().accessibilityPage;
|
||||||
|
expect(savingStatus).toEqual(RequestStatus.SUCCESSFUL);
|
||||||
|
|
||||||
expect(screen.getAllByRole('alert')).toHaveLength(1);
|
expect(screen.getAllByRole('alert')).toHaveLength(1);
|
||||||
|
|
||||||
@@ -89,8 +104,11 @@ describe('<AccessibilityPolicyForm />', () => {
|
|||||||
|
|
||||||
it('shows correct rate limiting message', async () => {
|
it('shows correct rate limiting message', async () => {
|
||||||
axiosMock.onPost(getZendeskrUrl()).reply(429);
|
axiosMock.onPost(getZendeskrUrl()).reply(429);
|
||||||
|
await act(async () => {
|
||||||
await user.click(submitButton);
|
userEvent.click(submitButton);
|
||||||
|
});
|
||||||
|
const { savingStatus } = store.getState().accessibilityPage;
|
||||||
|
expect(savingStatus).toEqual(RequestStatus.FAILED);
|
||||||
|
|
||||||
expect(screen.getAllByRole('alert')).toHaveLength(1);
|
expect(screen.getAllByRole('alert')).toHaveLength(1);
|
||||||
|
|
||||||
@@ -105,24 +123,23 @@ describe('<AccessibilityPolicyForm />', () => {
|
|||||||
describe('input validation', () => {
|
describe('input validation', () => {
|
||||||
let formSections;
|
let formSections;
|
||||||
let submitButton;
|
let submitButton;
|
||||||
let user;
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
user = userEvent.setup();
|
|
||||||
renderComponent();
|
renderComponent();
|
||||||
formSections = screen.getAllByRole('textbox');
|
formSections = screen.getAllByRole('textbox');
|
||||||
|
await act(async () => {
|
||||||
await user.type(formSections[0], 'email@email.com');
|
userEvent.type(formSections[0], 'email@email.com');
|
||||||
await user.type(formSections[1], 'test name');
|
userEvent.type(formSections[1], 'test name');
|
||||||
await user.type(formSections[2], 'feedback message');
|
userEvent.type(formSections[2], 'feedback message');
|
||||||
|
});
|
||||||
submitButton = screen.getByText(messages.accessibilityPolicyFormSubmitLabel.defaultMessage);
|
submitButton = screen.getByText(messages.accessibilityPolicyFormSubmitLabel.defaultMessage);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('adds validation checking on each input field', async () => {
|
it('adds validation checking on each input field', async () => {
|
||||||
await user.clear(formSections[0]);
|
await act(async () => {
|
||||||
await user.clear(formSections[1]);
|
userEvent.clear(formSections[0]);
|
||||||
await user.clear(formSections[2]);
|
userEvent.clear(formSections[1]);
|
||||||
|
userEvent.clear(formSections[2]);
|
||||||
|
});
|
||||||
const emailError = screen.getByTestId('error-feedback-email');
|
const emailError = screen.getByTestId('error-feedback-email');
|
||||||
expect(emailError).toBeVisible();
|
expect(emailError).toBeVisible();
|
||||||
|
|
||||||
@@ -134,10 +151,12 @@ describe('<AccessibilityPolicyForm />', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('sumbit button is disabled when trying to submit with all empty fields', async () => {
|
it('sumbit button is disabled when trying to submit with all empty fields', async () => {
|
||||||
await user.clear(formSections[0]);
|
await act(async () => {
|
||||||
await user.clear(formSections[1]);
|
userEvent.clear(formSections[0]);
|
||||||
await user.clear(formSections[2]);
|
userEvent.clear(formSections[1]);
|
||||||
await user.click(submitButton);
|
userEvent.clear(formSections[2]);
|
||||||
|
userEvent.click(submitButton);
|
||||||
|
});
|
||||||
|
|
||||||
expect(submitButton.closest('button')).toBeDisabled();
|
expect(submitButton.closest('button')).toBeDisabled();
|
||||||
});
|
});
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
import { useFormik } from 'formik';
|
import { useFormik } from 'formik';
|
||||||
import * as Yup from 'yup';
|
import * as Yup from 'yup';
|
||||||
|
|
||||||
|
import { RequestStatus } from '../../data/constants';
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
import { useSubmitAccessibilityForm } from '../data/apiHooks';
|
|
||||||
import { AccessibilityFormData } from '../data/api';
|
|
||||||
|
|
||||||
const useAccessibility = (initialValues: AccessibilityFormData) => {
|
const useAccessibility = (initialValues, intl) => {
|
||||||
const intl = useIntl();
|
const dispatch = useDispatch();
|
||||||
|
const savingStatus = useSelector(state => state.accessibilityPage.savingStatus);
|
||||||
const [isFormFilled, setFormFilled] = useState(false);
|
const [isFormFilled, setFormFilled] = useState(false);
|
||||||
const validationSchema = Yup.object().shape({
|
const validationSchema = Yup.object().shape({
|
||||||
name: Yup.string().required(
|
name: Yup.string().required(
|
||||||
@@ -29,27 +29,29 @@ const useAccessibility = (initialValues: AccessibilityFormData) => {
|
|||||||
enableReinitialize: true,
|
enableReinitialize: true,
|
||||||
validateOnBlur: false,
|
validateOnBlur: false,
|
||||||
validationSchema,
|
validationSchema,
|
||||||
/* istanbul ignore next */
|
|
||||||
onSubmit: () => {},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const mutation = useSubmitAccessibilityForm(handleReset);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setFormFilled(Object.values(values).every((i) => i));
|
setFormFilled(Object.values(values).every((i) => i));
|
||||||
}, [values]);
|
}, [values]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (savingStatus === RequestStatus.SUCCESSFUL) {
|
||||||
|
handleReset();
|
||||||
|
}
|
||||||
|
}, [savingStatus]);
|
||||||
|
|
||||||
const hasErrorField = (fieldName) => !!errors[fieldName] && !!touched[fieldName];
|
const hasErrorField = (fieldName) => !!errors[fieldName] && !!touched[fieldName];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
errors,
|
errors,
|
||||||
values,
|
values,
|
||||||
isFormFilled,
|
isFormFilled,
|
||||||
mutation,
|
dispatch,
|
||||||
handleBlur,
|
handleBlur,
|
||||||
handleChange,
|
handleChange,
|
||||||
hasErrorField,
|
hasErrorField,
|
||||||
savingStatus: mutation.status,
|
savingStatus,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
42
src/accessibility-page/AccessibilityPage.jsx
Normal file
42
src/accessibility-page/AccessibilityPage.jsx
Normal 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 { StudioFooter } from '@edx/frontend-component-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>
|
||||||
|
<StudioFooter />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
AccessibilityPage.propTypes = {
|
||||||
|
// injected
|
||||||
|
intl: intlShape.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default injectIntl(AccessibilityPage);
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user