Compare commits
77 Commits
CourseRole
...
feat--home
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af494d5e13 | ||
|
|
b58a673bba | ||
|
|
371876b4db | ||
|
|
17fe28a813 | ||
|
|
2b74321125 | ||
|
|
784a811ff8 | ||
|
|
497b861d82 | ||
|
|
4701c721b8 | ||
|
|
c973c2a9f9 | ||
|
|
071aee5b02 | ||
|
|
da68fb8e9d | ||
|
|
972a7f324c | ||
|
|
c809dfb2e4 | ||
|
|
1b2c65fae6 | ||
|
|
b09729b55e | ||
|
|
60917c6ab5 | ||
|
|
d57ecc6779 | ||
|
|
6ae9cdac00 | ||
|
|
9740974bbd | ||
|
|
a88a88e9af | ||
|
|
6baec5b6a3 | ||
|
|
8acd27d7bf | ||
|
|
d76aaa73a4 | ||
|
|
4e70813fa9 | ||
|
|
7f5687f175 | ||
|
|
c8434b87c0 | ||
|
|
9299f4cf93 | ||
|
|
59d2dcaacb | ||
|
|
42f8c3d95f | ||
|
|
9ff77945e3 | ||
|
|
584823b879 | ||
|
|
6e83e90cf0 | ||
|
|
ad7ba2f302 | ||
|
|
bec59e5bbe | ||
|
|
1fdddfb869 | ||
|
|
b5a287639d | ||
|
|
dad4bd5282 | ||
|
|
17b1360c07 | ||
|
|
c39b52a6bf | ||
|
|
642b4e4052 | ||
|
|
423a3f3f72 | ||
|
|
f1f036576e | ||
|
|
deb76a0609 | ||
|
|
912c42e802 | ||
|
|
bdd641225f | ||
|
|
9021fccdb7 | ||
|
|
e4d88fb1fa | ||
|
|
073e191273 | ||
|
|
f8095e6670 | ||
|
|
e4c3997d17 | ||
|
|
da7fe95f24 | ||
|
|
896969c7de | ||
|
|
8100281fb4 | ||
|
|
f035391c2f | ||
|
|
3607e6423d | ||
|
|
4395607074 | ||
|
|
f717cdac86 | ||
|
|
40c9d6ee0d | ||
|
|
3c661e15cb | ||
|
|
49fce4622c | ||
|
|
608b2f79f8 | ||
|
|
6b57ce3e53 | ||
|
|
6aff1c1168 | ||
|
|
2b11df9eb5 | ||
|
|
7fcc501d2e | ||
|
|
90fb3d8edc | ||
|
|
8d3dc9a6a9 | ||
|
|
f31cae24ea | ||
|
|
0fc0ce0829 | ||
|
|
6d6bd12e68 | ||
|
|
f5624c9981 | ||
|
|
16d2f38325 | ||
|
|
76bb8e88c1 | ||
|
|
1f6d30ff23 | ||
|
|
d6e2e91b9a | ||
|
|
458e2191cf | ||
|
|
94be906dda |
3
.env
3
.env
@@ -32,6 +32,7 @@ ENABLE_PROGRESS_GRAPH_SETTINGS=false
|
||||
ENABLE_TEAM_TYPE_SETTING=false
|
||||
ENABLE_NEW_EDITOR_PAGES=true
|
||||
ENABLE_UNIT_PAGE=false
|
||||
ENABLE_ASSETS_PAGE=false
|
||||
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=false
|
||||
ENABLE_TAGGING_TAXONOMY_PAGES=false
|
||||
BBB_LEARN_MORE_URL=''
|
||||
@@ -40,3 +41,5 @@ HOTJAR_VERSION=6
|
||||
HOTJAR_DEBUG=false
|
||||
INVITE_STUDENTS_EMAIL_TO=''
|
||||
AI_TRANSLATIONS_BASE_URL=''
|
||||
ENABLE_HOME_PAGE_COURSE_API_V2='true'
|
||||
ENABLE_CHECKLIST_QUALITY=''
|
||||
|
||||
@@ -34,6 +34,7 @@ ENABLE_PROGRESS_GRAPH_SETTINGS=false
|
||||
ENABLE_TEAM_TYPE_SETTING=false
|
||||
ENABLE_NEW_EDITOR_PAGES=true
|
||||
ENABLE_UNIT_PAGE=false
|
||||
ENABLE_ASSETS_PAGE=false
|
||||
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=false
|
||||
ENABLE_TAGGING_TAXONOMY_PAGES=true
|
||||
BBB_LEARN_MORE_URL=''
|
||||
@@ -42,3 +43,6 @@ HOTJAR_VERSION=6
|
||||
HOTJAR_DEBUG=true
|
||||
INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
|
||||
AI_TRANSLATIONS_BASE_URL='http://localhost:18760'
|
||||
ENABLE_HOME_PAGE_COURSE_API_V2='true'
|
||||
ENABLE_HOME_PAGE_LIBRARY_API_V2=true
|
||||
ENABLE_CHECKLIST_QUALITY=true
|
||||
|
||||
@@ -30,7 +30,10 @@ ENABLE_PROGRESS_GRAPH_SETTINGS=false
|
||||
ENABLE_TEAM_TYPE_SETTING=false
|
||||
ENABLE_NEW_EDITOR_PAGES=true
|
||||
ENABLE_UNIT_PAGE=true
|
||||
ENABLE_ASSETS_PAGE=false
|
||||
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=true
|
||||
ENABLE_TAGGING_TAXONOMY_PAGES=true
|
||||
BBB_LEARN_MORE_URL=''
|
||||
INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
|
||||
ENABLE_HOME_PAGE_COURSE_API_V2='true'
|
||||
ENABLE_CHECKLIST_QUALITY=true
|
||||
|
||||
19
.eslintrc.js
19
.eslintrc.js
@@ -1,5 +1,6 @@
|
||||
const path = require('path');
|
||||
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||
const { createConfig } = require('@edx/frontend-build');
|
||||
const { createConfig } = require('@openedx/frontend-build');
|
||||
|
||||
module.exports = createConfig(
|
||||
'eslint',
|
||||
@@ -13,5 +14,21 @@ module.exports = createConfig(
|
||||
indent: ['error', 2],
|
||||
'no-restricted-exports': 'off',
|
||||
},
|
||||
settings: {
|
||||
// Import URLs should be resolved using aliases
|
||||
'import/resolver': {
|
||||
webpack: {
|
||||
config: path.resolve(__dirname, 'webpack.dev.config.js'),
|
||||
},
|
||||
},
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
files: ['plugins/**/*.test.jsx'],
|
||||
rules: {
|
||||
'import/no-extraneous-dependencies': 'off',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
27
.github/pull_request_template.md
vendored
Normal file
27
.github/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
## Description
|
||||
|
||||
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
|
||||
|
||||
Useful information to include:
|
||||
- Which edX user roles will this change impact? Common user roles are "Learner", "Course Author",
|
||||
"Developer", and "Operator".
|
||||
- Include screenshots for changes to the UI (ideally, both "before" and "after" screenshots, if applicable).
|
||||
- Provide links to the description of corresponding configuration changes. Remember to correctly annotate these
|
||||
changes.
|
||||
|
||||
## Supporting information
|
||||
|
||||
Link to other information about the change, such as Jira issues, GitHub issues, or Discourse discussions.
|
||||
Be sure to check they are publicly readable, or if not, repeat the information here.
|
||||
|
||||
## Testing instructions
|
||||
|
||||
Please provide detailed step-by-step instructions for testing this change.
|
||||
|
||||
|
||||
## Other information
|
||||
|
||||
Include anything else that will help reviewers and consumers understand the change.
|
||||
- Does this change depend on other changes elsewhere?
|
||||
- Any special concerns or limitations? For example: deprecations, migrations, security, or accessibility.
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -23,3 +23,6 @@ temp/babel-plugin-react-intl
|
||||
|
||||
# Local environment overrides
|
||||
.env.private
|
||||
|
||||
# Messages .json files fetched by atlas
|
||||
src/i18n/messages/
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
[main]
|
||||
host = https://www.transifex.com
|
||||
|
||||
[o:open-edx:p:edx-platform:r:frontend-app-course-authoring]
|
||||
file_filter = src/i18n/messages/<lang>.json
|
||||
source_file = src/i18n/transifex_input.json
|
||||
source_lang = en
|
||||
type = KEYVALUEJSON
|
||||
|
||||
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
|
||||
22
Makefile
22
Makefile
@@ -1,7 +1,3 @@
|
||||
transifex_resource = frontend-app-course-authoring
|
||||
export TRANSIFEX_RESOURCE = ${transifex_resource}
|
||||
transifex_langs = "ar,de,de_DE,es_419,fa_IR,fr,fr_CA,hi,it,it_IT,pt,pt_PT,ru,uk,zh_CN"
|
||||
|
||||
intl_imports = ./node_modules/.bin/intl-imports.js
|
||||
transifex_utils = ./node_modules/.bin/transifex-utils.js
|
||||
i18n = ./src/i18n
|
||||
@@ -33,23 +29,6 @@ detect_changed_source_translations:
|
||||
# Checking for changed translations...
|
||||
git diff --exit-code $(i18n)
|
||||
|
||||
# Pushes translations to Transifex. You must run make extract_translations first.
|
||||
push_translations:
|
||||
# Pushing strings to Transifex...
|
||||
tx push -s
|
||||
# Fetching hashes from Transifex...
|
||||
./node_modules/@edx/reactifex/bash_scripts/get_hashed_strings_v3.sh
|
||||
# Writing out comments to file...
|
||||
$(transifex_utils) $(transifex_temp) --comments --v3-scripts-path
|
||||
# Pushing comments to Transifex...
|
||||
./node_modules/@edx/reactifex/bash_scripts/put_comments_v3.sh
|
||||
|
||||
ifeq ($(OPENEDX_ATLAS_PULL),)
|
||||
# Pulls translations from Transifex.
|
||||
pull_translations:
|
||||
tx pull -t -f --mode reviewed --languages=$(transifex_langs)
|
||||
else
|
||||
# Pulls translations using atlas.
|
||||
pull_translations:
|
||||
rm -rf src/i18n/messages
|
||||
mkdir src/i18n/messages
|
||||
@@ -63,7 +42,6 @@ pull_translations:
|
||||
translations/frontend-app-course-authoring/src/i18n/messages:frontend-app-course-authoring
|
||||
|
||||
$(intl_imports) frontend-component-ai-translations frontend-lib-content-components frontend-platform paragon frontend-component-footer frontend-app-course-authoring
|
||||
endif
|
||||
|
||||
# This target is used by Travis.
|
||||
validate-no-uncommitted-package-lock-changes:
|
||||
|
||||
18
catalog-info.yaml
Normal file
18
catalog-info.yaml
Normal file
@@ -0,0 +1,18 @@
|
||||
# This file records information about this repo. Its use is described in OEP-55:
|
||||
# https://open-edx-proposals.readthedocs.io/en/latest/processes/oep-0055-proc-project-maintainers.html
|
||||
|
||||
apiVersion: backstage.io/v1alpha1
|
||||
kind: Component
|
||||
metadata:
|
||||
name: 'frontend-app-course-authoring'
|
||||
description: "The frontend (MFE) for Open edX Course Authoring (aka Studio)"
|
||||
links:
|
||||
- url: "https://github.com/openedx/frontend-app-course-authoring"
|
||||
title: "Frontend app course authoring"
|
||||
icon: "Web"
|
||||
annotations:
|
||||
openedx.org/arch-interest-groups: ""
|
||||
spec:
|
||||
owner: group:2u-tnl
|
||||
type: 'website'
|
||||
lifecycle: 'production'
|
||||
@@ -1,21 +0,0 @@
|
||||
Background
|
||||
==========
|
||||
|
||||
This is a summary of the technical decisions made for the Roles & Permissions
|
||||
project as we implemented the permissions check system in the ``frontend-app-course-authoring``.
|
||||
|
||||
The ``frontend-app-course-authoring`` was already created when the
|
||||
Permissions project started, so it already had a coding style, store
|
||||
management and its own best practices.
|
||||
We aligned to these requirements.
|
||||
|
||||
Frontend Architecture
|
||||
---------------------
|
||||
|
||||
* `Readme <https://github.com/openedx/frontend-app-course-authoring#readme>`__
|
||||
* Developing locally:
|
||||
https://github.com/openedx/frontend-app-course-authoring#readme
|
||||
* **React.js** application ``version: 17.0.2``
|
||||
* **Redux** store management ``version: 4.0.5``
|
||||
* It uses **Thunk** for adding to Redux the ability of returning
|
||||
functions.
|
||||
@@ -1,66 +0,0 @@
|
||||
Local Development & Testing
|
||||
===========================
|
||||
|
||||
Backend
|
||||
~~~~~~~
|
||||
|
||||
The backend endpoints lives in the ``edx-platform`` repo, specifically
|
||||
in this file: ``openedx/core/djangoapps/course_roles/views.py``
|
||||
|
||||
For quickly testing the different permissions and the flag change you
|
||||
can tweak the values directly in the above file.
|
||||
|
||||
* ``UserPermissionsView`` is in charge of returning the permissions, so
|
||||
for sending the permissions you want to check, you could do something
|
||||
like this:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
permissions = {
|
||||
'user_id': user_id,
|
||||
'course_key': str(course_key),
|
||||
#'permissions': sorted(permission.value.name for permission in permissions_set),
|
||||
'permissions': ['the_permissions_being_tested']
|
||||
}
|
||||
return Response(permissions)
|
||||
|
||||
By making this change, the permissions object will be bypassed and
|
||||
send a plain array with the specific permissions being tested.
|
||||
|
||||
|
||||
* ``UserPermissionsFlagView`` is in charge of returning the flag value
|
||||
(boolean), so you can easily turn the boolean like this:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
#payload = {'enabled': use_permission_checks()}
|
||||
payload = {'enabled': true}
|
||||
return Response(payload)
|
||||
|
||||
Flags
|
||||
~~~~~
|
||||
|
||||
You’ll need at least 2 flags to start:
|
||||
|
||||
* The basic flag for enabling the backend permissions system: ``course_roles.use_permission_checks``.
|
||||
|
||||
* The flag for enabling the page you want to test, for instance Course Team: ``contentstore.new_studio_mfe.use_new_course_team_page``.
|
||||
|
||||
All flags for enabling pages in the Studio MFE are listed
|
||||
`here <https://2u-internal.atlassian.net/wiki/x/CQCcHQ>`__.
|
||||
|
||||
Flags can be added by:
|
||||
^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
* Enter to ``http://localhost:18000/admin/``.
|
||||
* Log in as an admin.
|
||||
* Go to ``http://localhost:18000/admin/waffle/flag/``.
|
||||
* Click on ``+ADD FLAG`` button at the top right of the page and add
|
||||
the flag you need.
|
||||
|
||||
Testing
|
||||
~~~~~~~
|
||||
|
||||
For unit testing you run the npm script included in the ``package.json``, you can use it plainly for testing all components at once: ``npm run test``.
|
||||
|
||||
Or you can test one file at a time: ``npm run test path-to-file``.
|
||||
@@ -1,62 +0,0 @@
|
||||
Permissions Check implementation
|
||||
================================
|
||||
|
||||
For the permissions checks we basically hit 2 endpoints from the
|
||||
``edx-platform`` repo:
|
||||
|
||||
* **Permissions**:
|
||||
``/api/course_roles/v1/user_permissions/?course_id=[course_key]&user_id=[user_id]``
|
||||
|
||||
Which will return this structure:
|
||||
|
||||
.. code-block:: js
|
||||
|
||||
permissions = {
|
||||
'user_id': [user_id],
|
||||
'course_key': [course_key],
|
||||
'permissions': ['permission_1', 'permission_2']
|
||||
}
|
||||
|
||||
* **Permissions enabled** (which returns the boolean flag value): ``/api/course_roles/v1/user_permissions/enabled/``
|
||||
|
||||
The basic scaffolding for *fetching* and *storing* the permissions is located in the ``src/generic/data`` folder:
|
||||
|
||||
* ``api.js``: Exposes the ``getUserPermissions(courseId)`` and ``getUserPermissionsEnabledFlag()`` methods.
|
||||
* ``selectors.js``: Exposes the selectors ``getUserPermissions`` and ``getUserPermissionsEnabled`` to be used by ``useSelector()``.
|
||||
* ``slice.js``: Exposes the ``updateUserPermissions`` and ``updateUserPermissionsEnabled`` methods that will be used by the ``thunks.js`` file for dispatching and storing.
|
||||
* ``thunks.js``: Exposes the ``fetchUserPermissionsQuery(courseId)`` and ``fetchUserPermissionsEnabledFlag()`` methods for fetching.
|
||||
|
||||
In the ``src/generic/hooks.jsx`` we created a custom hook for exposing the ``checkPermission`` method, so that way we can call
|
||||
this method from any page and pass the permission we want to check for the current logged in user.
|
||||
|
||||
In this example on the ``src/course-team/CourseTeam.jsx`` page, we use the hook for checking if the current user has the ``manage_all_users``
|
||||
permission:
|
||||
|
||||
1. First, we import the hook (line 1).
|
||||
|
||||
2. Then we call the ``checkPermission`` method and assign it to a const (line 2).
|
||||
|
||||
3. Finally we use the const for showing or hiding a button (line 8).
|
||||
|
||||
.. code-block:: js
|
||||
|
||||
1. import { useUserPermissions } from '../generic/hooks';
|
||||
2. const hasManageAllUsersPerm = checkPermission('manage_all_users');
|
||||
|
||||
3. <SubHeader
|
||||
4. title={intl.formatMessage(messages.headingTitle)}
|
||||
5. subtitle={intl.formatMessage(messages.headingSubtitle)}
|
||||
6. headerActions={(
|
||||
7. isAllowActions ||
|
||||
8. hasManageAllUsersPerm
|
||||
9. ) && (
|
||||
10. <Button
|
||||
11. variant="primary"
|
||||
12. iconBefore={IconAdd}
|
||||
13. size="sm"
|
||||
14. onClick={openForm}
|
||||
15. >
|
||||
16. {intl.formatMessage(messages.addNewMemberButton)}
|
||||
17. </Button>
|
||||
18. )}
|
||||
19. />
|
||||
@@ -1,4 +1,4 @@
|
||||
const { createConfig } = require('@edx/frontend-build');
|
||||
const { createConfig } = require('@openedx/frontend-build');
|
||||
|
||||
module.exports = createConfig('jest', {
|
||||
setupFilesAfterEnv: [
|
||||
@@ -11,6 +11,7 @@ module.exports = createConfig('jest', {
|
||||
],
|
||||
moduleNameMapper: {
|
||||
'^lodash-es$': 'lodash',
|
||||
'^CourseAuthoring/(.*)$': '<rootDir>/src/$1',
|
||||
},
|
||||
modulePathIgnorePatterns: [
|
||||
'/src/pages-and-resources/utils.test.jsx',
|
||||
|
||||
18160
package-lock.json
generated
18160
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
36
package.json
36
package.json
@@ -36,21 +36,34 @@
|
||||
"url": "https://github.com/openedx/frontend-app-course-authoring/issues"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.1.0",
|
||||
"@dnd-kit/modifiers": "^7.0.0",
|
||||
"@dnd-kit/sortable": "^8.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
|
||||
"@edx/frontend-component-ai-translations": "^1.4.0",
|
||||
"@edx/frontend-component-footer": "^12.3.0",
|
||||
"@edx/frontend-component-header": "^4.7.0",
|
||||
"@edx/frontend-component-ai-translations": "^2.0.0",
|
||||
"@edx/frontend-component-footer": "^13.0.2",
|
||||
"@edx/frontend-component-header": "^5.0.2",
|
||||
"@edx/frontend-enterprise-hotjar": "^2.0.0",
|
||||
"@edx/frontend-lib-content-components": "^1.178.2",
|
||||
"@edx/frontend-platform": "5.6.1",
|
||||
"@edx/frontend-lib-content-components": "^2.1.4",
|
||||
"@edx/frontend-platform": "7.0.1",
|
||||
"@edx/openedx-atlas": "^0.6.0",
|
||||
"@edx/paragon": "^21.5.6",
|
||||
"@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-edxnotes": "file:plugins/course-apps/edxnotes",
|
||||
"@openedx-plugins/course-app-learning_assistant": "file:plugins/course-apps/learning_assistant",
|
||||
"@openedx-plugins/course-app-live": "file:plugins/course-apps/live",
|
||||
"@openedx-plugins/course-app-ora_settings": "file:plugins/course-apps/ora_settings",
|
||||
"@openedx-plugins/course-app-proctoring": "file:plugins/course-apps/proctoring",
|
||||
"@openedx-plugins/course-app-progress": "file:plugins/course-apps/progress",
|
||||
"@openedx-plugins/course-app-teams": "file:plugins/course-apps/teams",
|
||||
"@openedx-plugins/course-app-wiki": "file:plugins/course-apps/wiki",
|
||||
"@openedx-plugins/course-app-xpert_unit_summary": "file:plugins/course-apps/xpert_unit_summary",
|
||||
"@openedx/paragon": "^21.5.7",
|
||||
"@reduxjs/toolkit": "1.9.7",
|
||||
"@tanstack/react-query": "4.36.1",
|
||||
"broadcast-channel": "^7.0.0",
|
||||
@@ -71,6 +84,7 @@
|
||||
"react-responsive": "9.0.2",
|
||||
"react-router": "6.16.0",
|
||||
"react-router-dom": "6.16.0",
|
||||
"react-select": "5.8.0",
|
||||
"react-textarea-autosize": "^8.4.1",
|
||||
"react-transition-group": "4.4.5",
|
||||
"redux": "4.0.5",
|
||||
@@ -81,18 +95,20 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@edx/browserslist-config": "1.2.0",
|
||||
"@edx/frontend-build": "13.0.5",
|
||||
"@edx/react-unit-test-utils": "^1.7.0",
|
||||
"@edx/react-unit-test-utils": "^2.0.0",
|
||||
"@edx/reactifex": "^1.0.3",
|
||||
"@edx/stylelint-config-edx": "^2.3.0",
|
||||
"@edx/stylelint-config-edx": "2.3.0",
|
||||
"@edx/typescript-config": "^1.0.1",
|
||||
"@openedx/frontend-build": "13.0.27",
|
||||
"@testing-library/jest-dom": "5.17.0",
|
||||
"@testing-library/react": "12.1.5",
|
||||
"@testing-library/react-hooks": "^8.0.1",
|
||||
"@testing-library/user-event": "^13.2.1",
|
||||
"axios": "^0.27.2",
|
||||
"axios-mock-adapter": "1.22.0",
|
||||
"eslint-import-resolver-webpack": "^0.13.8",
|
||||
"glob": "7.2.3",
|
||||
"husky": "^7.0.4",
|
||||
"husky": "7.0.4",
|
||||
"jest-canvas-mock": "^2.5.2",
|
||||
"jest-expect-message": "^1.1.3",
|
||||
"react-test-renderer": "17.0.2",
|
||||
|
||||
31
plugins/course-apps/calculator/Settings.jsx
Normal file
31
plugins/course-apps/calculator/Settings.jsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
|
||||
import messages from './messages';
|
||||
|
||||
/**
|
||||
* Settings widget for the "calculator" Course App.
|
||||
* @param {{onClose: () => void}} props
|
||||
*/
|
||||
const CalculatorSettings = ({ onClose }) => {
|
||||
const intl = useIntl();
|
||||
return (
|
||||
<AppSettingsModal
|
||||
appId="calculator"
|
||||
title={intl.formatMessage(messages.heading)}
|
||||
enableAppHelp={intl.formatMessage(messages.enableCalculatorHelp)}
|
||||
enableAppLabel={intl.formatMessage(messages.enableCalculatorLabel)}
|
||||
learnMoreText={intl.formatMessage(messages.enableCalculatorLink)}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
CalculatorSettings.propTypes = {
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default CalculatorSettings;
|
||||
17
plugins/course-apps/calculator/package.json
Normal file
17
plugins/course-apps/calculator/package.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "@openedx-plugins/course-app-calculator",
|
||||
"version": "0.1.0",
|
||||
"description": "Calculator configuration for courses using it",
|
||||
"peerDependencies": {
|
||||
"@edx/frontend-app-course-authoring": "*",
|
||||
"@edx/frontend-platform": "*",
|
||||
"@openedx/paragon": "*",
|
||||
"prop-types": "*",
|
||||
"react": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edx/frontend-app-course-authoring": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
31
plugins/course-apps/edxnotes/Settings.jsx
Normal file
31
plugins/course-apps/edxnotes/Settings.jsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
|
||||
import messages from './messages';
|
||||
|
||||
/**
|
||||
* Settings widget for the "edxnotes" Course App.
|
||||
* @param {{onClose: () => void}} props
|
||||
*/
|
||||
const NotesSettings = ({ onClose }) => {
|
||||
const intl = useIntl();
|
||||
return (
|
||||
<AppSettingsModal
|
||||
appId="edxnotes"
|
||||
title={intl.formatMessage(messages.heading)}
|
||||
enableAppHelp={intl.formatMessage(messages.enableNotesHelp)}
|
||||
enableAppLabel={intl.formatMessage(messages.enableNotesLabel)}
|
||||
learnMoreText={intl.formatMessage(messages.enableNotesLink)}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
NotesSettings.propTypes = {
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default NotesSettings;
|
||||
17
plugins/course-apps/edxnotes/package.json
Normal file
17
plugins/course-apps/edxnotes/package.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "@openedx-plugins/course-app-edxnotes",
|
||||
"version": "0.1.0",
|
||||
"description": "edxnotes configuration for courses using it",
|
||||
"peerDependencies": {
|
||||
"@edx/frontend-app-course-authoring": "*",
|
||||
"@edx/frontend-platform": "*",
|
||||
"@openedx/paragon": "*",
|
||||
"prop-types": "*",
|
||||
"react": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edx/frontend-app-course-authoring": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,18 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Hyperlink } from '@edx/paragon';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Hyperlink } from '@openedx/paragon';
|
||||
|
||||
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
|
||||
import { useModel } from 'CourseAuthoring/generic/model-store';
|
||||
|
||||
import AppSettingsModal from '../app-settings-modal/AppSettingsModal';
|
||||
import messages from './messages';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
|
||||
const LearningAssistantSettings = ({ intl, onClose }) => {
|
||||
const LearningAssistantSettings = ({ onClose }) => {
|
||||
const appId = 'learning_assistant';
|
||||
const appInfo = useModel('courseApps', appId);
|
||||
const intl = useIntl();
|
||||
|
||||
// We need to render more than one link, so we use the bodyChildren prop.
|
||||
const bodyChildren = (
|
||||
@@ -55,8 +57,7 @@ const LearningAssistantSettings = ({ intl, onClose }) => {
|
||||
};
|
||||
|
||||
LearningAssistantSettings.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(LearningAssistantSettings);
|
||||
export default LearningAssistantSettings;
|
||||
@@ -1,9 +1,9 @@
|
||||
import React from 'react';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
|
||||
import { RequestStatus } from 'CourseAuthoring/data/constants';
|
||||
import { render } from 'CourseAuthoring/pages-and-resources/utils.test';
|
||||
import LearningAssistantSettings from './Settings';
|
||||
import { render } from '../utils.test';
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
|
||||
const onClose = () => { };
|
||||
|
||||
19
plugins/course-apps/learning_assistant/package.json
Normal file
19
plugins/course-apps/learning_assistant/package.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@openedx-plugins/course-app-learning_assistant",
|
||||
"version": "0.1.0",
|
||||
"description": "Learning Assistant configuration for courses using it",
|
||||
"peerDependencies": {
|
||||
"@edx/frontend-app-course-authoring": "*",
|
||||
"@edx/frontend-platform": "*",
|
||||
"@openedx/paragon": "*",
|
||||
"prop-types": "*",
|
||||
"react": "*",
|
||||
"yup": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edx/frontend-app-course-authoring": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Form, Hyperlink } from '@edx/paragon';
|
||||
import { Form, Hyperlink } from '@openedx/paragon';
|
||||
import PropTypes from 'prop-types';
|
||||
import messages from './messages';
|
||||
import AppConfigFormDivider from 'CourseAuthoring/pages-and-resources/discussions/app-config-form/apps/shared/AppConfigFormDivider';
|
||||
import { useModel } from 'CourseAuthoring/generic/model-store';
|
||||
|
||||
import { providerNames, bbbPlanTypes } from './constants';
|
||||
import AppConfigFormDivider from '../discussions/app-config-form/apps/shared/AppConfigFormDivider';
|
||||
import LiveCommonFields from './LiveCommonFields';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
import messages from './messages';
|
||||
|
||||
const BbbSettings = ({
|
||||
intl,
|
||||
@@ -15,8 +15,10 @@ import { AppProvider, PageWrap } from '@edx/frontend-platform/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import initializeStore from '../../store';
|
||||
import { executeThunk } from '../../utils';
|
||||
import initializeStore from 'CourseAuthoring/store';
|
||||
import { executeThunk } from 'CourseAuthoring/utils';
|
||||
import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
|
||||
|
||||
import LiveSettings from './Settings';
|
||||
import {
|
||||
generateLiveConfigurationApiResponse,
|
||||
@@ -24,11 +26,9 @@ import {
|
||||
initialState,
|
||||
configurationProviders,
|
||||
} from './factories/mockApiResponses';
|
||||
|
||||
import { fetchLiveConfiguration, fetchLiveProviders } from './data/thunks';
|
||||
import { providerConfigurationApiUrl, providersApiUrl } from './data/api';
|
||||
import messages from './messages';
|
||||
import PagesAndResourcesProvider from '../PagesAndResourcesProvider';
|
||||
|
||||
let axiosMock;
|
||||
let container;
|
||||
@@ -1,8 +1,9 @@
|
||||
import React from 'react';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import PropTypes from 'prop-types';
|
||||
import FormikControl from 'CourseAuthoring/generic/FormikControl';
|
||||
|
||||
import messages from './messages';
|
||||
import FormikControl from '../../generic/FormikControl';
|
||||
|
||||
const LiveCommonFields = ({
|
||||
intl,
|
||||
@@ -1,18 +1,20 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { camelCase } from 'lodash';
|
||||
import { SelectableBox, Icon } from '@edx/paragon';
|
||||
import { Icon } from '@openedx/paragon';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { SelectableBox } from '@edx/frontend-lib-content-components';
|
||||
import PropTypes from 'prop-types';
|
||||
import * as Yup from 'yup';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
|
||||
import { useModel } from 'CourseAuthoring/generic/model-store';
|
||||
import Loading from 'CourseAuthoring/generic/Loading';
|
||||
import { RequestStatus } from 'CourseAuthoring/data/constants';
|
||||
|
||||
import { fetchLiveData, saveLiveConfiguration, saveLiveConfigurationAsDraft } from './data/thunks';
|
||||
import { selectApp } from './data/slice';
|
||||
import AppSettingsModal from '../app-settings-modal/AppSettingsModal';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
import Loading from '../../generic/Loading';
|
||||
import { iconsSrc, bbbPlanTypes } from './constants';
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import messages from './messages';
|
||||
import ZoomSettings from './ZoomSettings';
|
||||
import BBBSettings from './BBBSettings';
|
||||
@@ -18,8 +18,10 @@ 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 '../../store';
|
||||
import { executeThunk } from '../../utils';
|
||||
import initializeStore from 'CourseAuthoring/store';
|
||||
import { executeThunk } from 'CourseAuthoring/utils';
|
||||
import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
|
||||
|
||||
import LiveSettings from './Settings';
|
||||
import {
|
||||
generateLiveConfigurationApiResponse,
|
||||
@@ -31,7 +33,6 @@ import {
|
||||
import { fetchLiveConfiguration, fetchLiveProviders } from './data/thunks';
|
||||
import { providerConfigurationApiUrl, providersApiUrl } from './data/api';
|
||||
import messages from './messages';
|
||||
import PagesAndResourcesProvider from '../PagesAndResourcesProvider';
|
||||
|
||||
let axiosMock;
|
||||
let container;
|
||||
@@ -1,10 +1,11 @@
|
||||
import React from 'react';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import PropTypes from 'prop-types';
|
||||
import FormikControl from 'CourseAuthoring/generic/FormikControl';
|
||||
|
||||
import messages from './messages';
|
||||
import { providerNames } from './constants';
|
||||
import LiveCommonFields from './LiveCommonFields';
|
||||
import FormikControl from '../../generic/FormikControl';
|
||||
|
||||
const ZoomSettings = ({
|
||||
intl,
|
||||
@@ -13,8 +13,9 @@ 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 '../../store';
|
||||
import { executeThunk } from '../../utils';
|
||||
import initializeStore from 'CourseAuthoring/store';
|
||||
import { executeThunk } from 'CourseAuthoring/utils';
|
||||
import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
|
||||
import LiveSettings from './Settings';
|
||||
import {
|
||||
generateLiveConfigurationApiResponse,
|
||||
@@ -26,7 +27,6 @@ import {
|
||||
import { fetchLiveConfiguration, fetchLiveProviders } from './data/thunks';
|
||||
import { providerConfigurationApiUrl, providersApiUrl } from './data/api';
|
||||
import messages from './messages';
|
||||
import PagesAndResourcesProvider from '../PagesAndResourcesProvider';
|
||||
|
||||
let axiosMock;
|
||||
let container;
|
||||
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
GoogleMeet, MicrosoftTeams, Zoom, Bbb,
|
||||
} from '@edx/paragon/icons';
|
||||
} from '@openedx/paragon/icons';
|
||||
|
||||
export const iconsSrc = {
|
||||
googleMeet: GoogleMeet,
|
||||
@@ -1,6 +1,6 @@
|
||||
/* eslint-disable no-param-reassign */
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
import { RequestStatus } from '../../../data/constants';
|
||||
import { RequestStatus } from 'CourseAuthoring/data/constants';
|
||||
|
||||
const slice = createSlice({
|
||||
name: 'live',
|
||||
@@ -1,4 +1,6 @@
|
||||
import { addModel, addModels, updateModel } from '../../../generic/model-store';
|
||||
import { addModel, addModels, updateModel } from 'CourseAuthoring/generic/model-store';
|
||||
import { RequestStatus } from 'CourseAuthoring/data/constants';
|
||||
|
||||
import {
|
||||
getLiveConfiguration,
|
||||
getLiveProviders,
|
||||
@@ -7,7 +9,6 @@ import {
|
||||
deNormalizeSettings,
|
||||
} from './api';
|
||||
import { loadApps, updateStatus, updateSaveStatus } from './slice';
|
||||
import { RequestStatus } from '../../../data/constants';
|
||||
|
||||
function updateLiveSettingsState({
|
||||
appConfig,
|
||||
23
plugins/course-apps/live/package.json
Normal file
23
plugins/course-apps/live/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@openedx-plugins/course-app-live",
|
||||
"version": "0.1.0",
|
||||
"description": "Live course configuration for courses using it",
|
||||
"peerDependencies": {
|
||||
"@edx/frontend-app-course-authoring": "*",
|
||||
"@edx/frontend-lib-content-components": "*",
|
||||
"@edx/frontend-platform": "*",
|
||||
"@openedx/paragon": "*",
|
||||
"@reduxjs/toolkit": "*",
|
||||
"lodash": "*",
|
||||
"prop-types": "*",
|
||||
"react": "*",
|
||||
"react-redux": "*",
|
||||
"react-router-dom": "*",
|
||||
"yup": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edx/frontend-app-course-authoring": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,12 @@ import * as Yup from 'yup';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { Hyperlink } from '@edx/paragon';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
import { Hyperlink } from '@openedx/paragon';
|
||||
import { useModel } from 'CourseAuthoring/generic/model-store';
|
||||
|
||||
import FormSwitchGroup from '../../generic/FormSwitchGroup';
|
||||
import { useAppSetting } from '../../utils';
|
||||
import AppSettingsModal from '../app-settings-modal/AppSettingsModal';
|
||||
import FormSwitchGroup from 'CourseAuthoring/generic/FormSwitchGroup';
|
||||
import { useAppSetting } from 'CourseAuthoring/utils';
|
||||
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
|
||||
import messages from './messages';
|
||||
|
||||
const ORASettings = ({ intl, onClose }) => {
|
||||
@@ -9,14 +9,14 @@ jest.mock('@edx/frontend-platform/i18n', () => ({
|
||||
jest.mock('yup', () => ({
|
||||
boolean: jest.fn().mockReturnValue('Yub.boolean'),
|
||||
}));
|
||||
jest.mock('../../generic/model-store', () => ({
|
||||
jest.mock('CourseAuthoring/generic/model-store', () => ({
|
||||
useModel: jest.fn().mockReturnValue({ documentationLinks: { learnMoreConfiguration: 'https://learnmore.test' } }),
|
||||
}));
|
||||
jest.mock('../../generic/FormSwitchGroup', () => 'FormSwitchGroup');
|
||||
jest.mock('../../utils', () => ({
|
||||
jest.mock('CourseAuthoring/generic/FormSwitchGroup', () => 'FormSwitchGroup');
|
||||
jest.mock('CourseAuthoring/utils', () => ({
|
||||
useAppSetting: jest.fn().mockReturnValue(['abitrary value', jest.fn().mockName('saveSetting')]),
|
||||
}));
|
||||
jest.mock('../app-settings-modal/AppSettingsModal', () => 'AppSettingsModal');
|
||||
jest.mock('CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal', () => 'AppSettingsModal');
|
||||
|
||||
const props = {
|
||||
onClose: jest.fn().mockName('onClose'),
|
||||
19
plugins/course-apps/ora_settings/package.json
Normal file
19
plugins/course-apps/ora_settings/package.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@openedx-plugins/course-app-ora_settings",
|
||||
"version": "0.1.0",
|
||||
"description": "Open Response Assessment configuration for courses using it",
|
||||
"peerDependencies": {
|
||||
"@edx/frontend-app-course-authoring": "*",
|
||||
"@edx/frontend-platform": "*",
|
||||
"@openedx/paragon": "*",
|
||||
"prop-types": "*",
|
||||
"react": "*",
|
||||
"yup": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edx/frontend-app-course-authoring": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,17 +11,18 @@ import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
ActionRow, Alert, Badge, Form, Hyperlink, ModalDialog, StatefulButton,
|
||||
} from '@edx/paragon';
|
||||
} from '@openedx/paragon';
|
||||
|
||||
import ExamsApiService from 'CourseAuthoring/data/services/ExamsApiService';
|
||||
import StudioApiService from 'CourseAuthoring/data/services/StudioApiService';
|
||||
import Loading from 'CourseAuthoring/generic/Loading';
|
||||
import ConnectionErrorAlert from 'CourseAuthoring/generic/ConnectionErrorAlert';
|
||||
import FormSwitchGroup from 'CourseAuthoring/generic/FormSwitchGroup';
|
||||
import { useModel } from 'CourseAuthoring/generic/model-store';
|
||||
import PermissionDeniedAlert from 'CourseAuthoring/generic/PermissionDeniedAlert';
|
||||
import { useIsMobile } from 'CourseAuthoring/utils';
|
||||
import { PagesAndResourcesContext } from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
|
||||
|
||||
import ExamsApiService from '../../data/services/ExamsApiService';
|
||||
import StudioApiService from '../../data/services/StudioApiService';
|
||||
import Loading from '../../generic/Loading';
|
||||
import ConnectionErrorAlert from '../../generic/ConnectionErrorAlert';
|
||||
import FormSwitchGroup from '../../generic/FormSwitchGroup';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
import PermissionDeniedAlert from '../../generic/PermissionDeniedAlert';
|
||||
import { useIsMobile } from '../../utils';
|
||||
import { PagesAndResourcesContext } from '../PagesAndResourcesProvider';
|
||||
import messages from './messages';
|
||||
|
||||
const ProctoringSettings = ({ intl, onClose }) => {
|
||||
@@ -9,10 +9,10 @@ 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 '../../data/services/StudioApiService';
|
||||
import ExamsApiService from '../../data/services/ExamsApiService';
|
||||
import initializeStore from '../../store';
|
||||
import PagesAndResourcesProvider from '../PagesAndResourcesProvider';
|
||||
import StudioApiService from 'CourseAuthoring/data/services/StudioApiService';
|
||||
import ExamsApiService from 'CourseAuthoring/data/services/ExamsApiService';
|
||||
import initializeStore from 'CourseAuthoring/store';
|
||||
import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
|
||||
import ProctoredExamSettings from './Settings';
|
||||
|
||||
const defaultProps = {
|
||||
20
plugins/course-apps/proctoring/package.json
Normal file
20
plugins/course-apps/proctoring/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "@openedx-plugins/course-app-proctoring",
|
||||
"version": "0.1.0",
|
||||
"description": "Proctoring configuration for courses using it",
|
||||
"peerDependencies": {
|
||||
"@edx/frontend-app-course-authoring": "*",
|
||||
"@edx/frontend-platform": "*",
|
||||
"@openedx/paragon": "*",
|
||||
"classnames": "*",
|
||||
"email-validator": "*",
|
||||
"react": "*",
|
||||
"prop-types": "*",
|
||||
"moment": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edx/frontend-app-course-authoring": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,9 @@ import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import * as Yup from 'yup';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import FormSwitchGroup from '../../generic/FormSwitchGroup';
|
||||
import { useAppSetting } from '../../utils';
|
||||
import AppSettingsModal from '../app-settings-modal/AppSettingsModal';
|
||||
import FormSwitchGroup from 'CourseAuthoring/generic/FormSwitchGroup';
|
||||
import { useAppSetting } from 'CourseAuthoring/utils';
|
||||
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
|
||||
import messages from './messages';
|
||||
|
||||
const ProgressSettings = ({ intl, onClose }) => {
|
||||
18
plugins/course-apps/progress/package.json
Normal file
18
plugins/course-apps/progress/package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@openedx-plugins/course-app-progress",
|
||||
"version": "0.1.0",
|
||||
"description": "Progress configuration for courses using it",
|
||||
"peerDependencies": {
|
||||
"@edx/frontend-app-course-authoring": "*",
|
||||
"@edx/frontend-platform": "*",
|
||||
"@openedx/paragon": "*",
|
||||
"prop-types": "*",
|
||||
"react": "*",
|
||||
"yup": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edx/frontend-app-course-authoring": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Button, Form, TransitionReplace } from '@edx/paragon';
|
||||
import { Button, Form, TransitionReplace } from '@openedx/paragon';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { useState } from 'react';
|
||||
import { GroupTypes, TeamSizes } from '../../data/constants';
|
||||
import { GroupTypes, TeamSizes } from 'CourseAuthoring/data/constants';
|
||||
|
||||
import CollapsableEditor from '../../generic/CollapsableEditor';
|
||||
import FormikControl from '../../generic/FormikControl';
|
||||
import CollapsableEditor from 'CourseAuthoring/generic/CollapsableEditor';
|
||||
import FormikControl from 'CourseAuthoring/generic/FormikControl';
|
||||
import messages from './messages';
|
||||
import { isGroupTypeEnabled } from './utils';
|
||||
|
||||
// Maps a team type to its corresponding intl message
|
||||
const TeamTypeNameMessage = {
|
||||
@@ -14,6 +15,10 @@ const TeamTypeNameMessage = {
|
||||
label: messages.groupTypeOpen,
|
||||
description: messages.groupTypeOpenDescription,
|
||||
},
|
||||
[GroupTypes.OPEN_MANAGED]: {
|
||||
label: messages.groupTypeOpenManaged,
|
||||
description: messages.groupTypeOpenManagedDescription,
|
||||
},
|
||||
[GroupTypes.PUBLIC_MANAGED]: {
|
||||
label: messages.groupTypePublicManaged,
|
||||
description: messages.groupTypePublicManagedDescription,
|
||||
@@ -105,7 +110,7 @@ const GroupEditor = ({
|
||||
onChange={onChange}
|
||||
onBlur={onBlur}
|
||||
>
|
||||
{Object.values(GroupTypes).map(groupType => (
|
||||
{Object.values(GroupTypes).map(groupType => isGroupTypeEnabled(groupType) && (
|
||||
<Form.Radio
|
||||
key={groupType}
|
||||
value={groupType}
|
||||
102
plugins/course-apps/teams/GroupEditor.test.jsx
Normal file
102
plugins/course-apps/teams/GroupEditor.test.jsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import React from 'react';
|
||||
import { render, fireEvent } from '@testing-library/react';
|
||||
import { useFormikContext } from 'formik';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import GroupEditor from './GroupEditor';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
jest.mock('formik', () => ({
|
||||
...jest.requireActual('formik'),
|
||||
useFormikContext: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('GroupEditor', () => {
|
||||
const mockIntl = { formatMessage: jest.fn() };
|
||||
|
||||
const mockGroup = {
|
||||
id: '1',
|
||||
name: 'Test Group',
|
||||
description: 'Test Group Description',
|
||||
type: 'open',
|
||||
maxTeamSize: 5,
|
||||
};
|
||||
|
||||
const mockProps = {
|
||||
intl: mockIntl,
|
||||
fieldNameCommonBase: 'test',
|
||||
group: mockGroup,
|
||||
onDelete: jest.fn(),
|
||||
onChange: jest.fn(),
|
||||
onBlur: jest.fn(),
|
||||
errors: {},
|
||||
};
|
||||
|
||||
const renderComponent = (overrideProps = {}) => render(
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
<GroupEditor {...mockProps} {...overrideProps} />
|
||||
</IntlProvider>,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
useFormikContext.mockReturnValue({
|
||||
touched: {},
|
||||
errors: {},
|
||||
handleChange: jest.fn(),
|
||||
handleBlur: jest.fn(),
|
||||
setFieldError: jest.fn(),
|
||||
});
|
||||
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('renders without errors', () => {
|
||||
renderComponent();
|
||||
});
|
||||
|
||||
test('renders the group name and description', () => {
|
||||
const { getByText } = renderComponent();
|
||||
expect(getByText('Test Group')).toBeInTheDocument();
|
||||
expect(getByText('Test Group Description')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('group types messages', () => {
|
||||
test('group type open message', () => {
|
||||
const { getByLabelText, getByText } = renderComponent();
|
||||
const expandButton = getByLabelText('Expand group editor');
|
||||
expect(expandButton).toBeInTheDocument();
|
||||
fireEvent.click(expandButton);
|
||||
expect(getByText(messages.groupTypeOpenDescription.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('group type public_managed message', () => {
|
||||
const publicManagedGroupMock = {
|
||||
id: '2',
|
||||
name: 'Test Group',
|
||||
description: 'Test Group Description',
|
||||
type: 'public_managed',
|
||||
maxTeamSize: 5,
|
||||
};
|
||||
const { getByLabelText, getByText } = renderComponent({ group: publicManagedGroupMock });
|
||||
const expandButton = getByLabelText('Expand group editor');
|
||||
expect(expandButton).toBeInTheDocument();
|
||||
fireEvent.click(expandButton);
|
||||
expect(getByText(messages.groupTypePublicManagedDescription.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('group type private_managed message', () => {
|
||||
const privateManagedGroupMock = {
|
||||
id: '3',
|
||||
name: 'Test Group',
|
||||
description: 'Test Group Description',
|
||||
type: 'private_managed',
|
||||
maxTeamSize: 5,
|
||||
};
|
||||
const { getByLabelText, getByText } = renderComponent({ group: privateManagedGroupMock });
|
||||
const expandButton = getByLabelText('Expand group editor');
|
||||
expect(expandButton).toBeInTheDocument();
|
||||
fireEvent.click(expandButton);
|
||||
expect(getByText(messages.groupTypePrivateManagedDescription.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,16 @@
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Button, Form } from '@edx/paragon';
|
||||
import { Add } from '@edx/paragon/icons';
|
||||
import { Button, Form } from '@openedx/paragon';
|
||||
import { Add } from '@openedx/paragon/icons';
|
||||
|
||||
import { FieldArray } from 'formik';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import * as Yup from 'yup';
|
||||
import { GroupTypes, TeamSizes } from '../../data/constants';
|
||||
import FormikControl from '../../generic/FormikControl';
|
||||
import { setupYupExtensions, useAppSetting } from '../../utils';
|
||||
import AppSettingsModal from '../app-settings-modal/AppSettingsModal';
|
||||
import { GroupTypes, TeamSizes } from 'CourseAuthoring/data/constants';
|
||||
import FormikControl from 'CourseAuthoring/generic/FormikControl';
|
||||
import { setupYupExtensions, useAppSetting } from 'CourseAuthoring/utils';
|
||||
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
|
||||
import GroupEditor from './GroupEditor';
|
||||
import messages from './messages';
|
||||
|
||||
@@ -93,6 +93,14 @@ const messages = defineMessages({
|
||||
id: 'authoring.pagesAndResources.teams.group.types.open',
|
||||
defaultMessage: 'Open',
|
||||
},
|
||||
groupTypeOpenManaged: {
|
||||
id: 'authoring.pagesAndResources.teams.group.types.open_managed',
|
||||
defaultMessage: 'Open managed',
|
||||
},
|
||||
groupTypeOpenManagedDescription: {
|
||||
id: 'authoring.pagesAndResources.teams.group.types.open_managed.description',
|
||||
defaultMessage: 'Only course staff can create teams. Learners can see, join and leave teams.',
|
||||
},
|
||||
groupTypeOpenDescription: {
|
||||
id: 'authoring.pagesAndResources.teams.group.types.open.description',
|
||||
defaultMessage: 'Learners can create, join, leave, and see other teams',
|
||||
20
plugins/course-apps/teams/package.json
Normal file
20
plugins/course-apps/teams/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "@openedx-plugins/course-app-teams",
|
||||
"version": "0.1.0",
|
||||
"description": "Teams configuration for courses using it",
|
||||
"peerDependencies": {
|
||||
"@edx/frontend-app-course-authoring": "*",
|
||||
"@edx/frontend-platform": "*",
|
||||
"@openedx/paragon": "*",
|
||||
"formik": "*",
|
||||
"prop-types": "*",
|
||||
"react": "*",
|
||||
"uuid": "*",
|
||||
"yup": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edx/frontend-app-course-authoring": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
23
plugins/course-apps/teams/utils.js
Normal file
23
plugins/course-apps/teams/utils.js
Normal file
@@ -0,0 +1,23 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
import { GroupTypes } from 'CourseAuthoring/data/constants';
|
||||
|
||||
/**
|
||||
* Check if a group type is enabled by the current configuration.
|
||||
* This is a temporary workaround to disable the OPEN MANAGED team type until it is fully adopted.
|
||||
* For more information, see: https://openedx.atlassian.net/wiki/spaces/COMM/pages/3885760525/Open+Managed+Group+Type
|
||||
* @param {string} groupType - the group type to check
|
||||
* @returns {boolean} - true if the group type is enabled
|
||||
*/
|
||||
export const isGroupTypeEnabled = (groupType) => {
|
||||
const enabledTypesByDefault = [
|
||||
GroupTypes.OPEN,
|
||||
GroupTypes.PUBLIC_MANAGED,
|
||||
GroupTypes.PRIVATE_MANAGED,
|
||||
];
|
||||
const enabledTypesByConfig = {
|
||||
[GroupTypes.OPEN_MANAGED]: getConfig().ENABLE_OPEN_MANAGED_TEAM_TYPE,
|
||||
};
|
||||
return enabledTypesByDefault.includes(groupType) || enabledTypesByConfig[groupType] || false;
|
||||
};
|
||||
39
plugins/course-apps/teams/utils.test.js
Normal file
39
plugins/course-apps/teams/utils.test.js
Normal file
@@ -0,0 +1,39 @@
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { GroupTypes } from 'CourseAuthoring/data/constants';
|
||||
import { isGroupTypeEnabled } from './utils';
|
||||
|
||||
jest.mock('@edx/frontend-platform', () => ({ getConfig: jest.fn() }));
|
||||
|
||||
describe('teams utils', () => {
|
||||
describe('isGroupTypeEnabled', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('returns true if the group type is enabled', () => {
|
||||
getConfig.mockReturnValue({ ENABLE_OPEN_MANAGED_TEAM_TYPE: false });
|
||||
expect(isGroupTypeEnabled(GroupTypes.OPEN)).toBe(true);
|
||||
expect(isGroupTypeEnabled(GroupTypes.PUBLIC_MANAGED)).toBe(true);
|
||||
expect(isGroupTypeEnabled(GroupTypes.PRIVATE_MANAGED)).toBe(true);
|
||||
});
|
||||
test('returns false if the OPEN_MANAGED group is not enabled', () => {
|
||||
getConfig.mockReturnValue({ ENABLE_OPEN_MANAGED_TEAM_TYPE: false });
|
||||
expect(isGroupTypeEnabled(GroupTypes.OPEN_MANAGED)).toBe(false);
|
||||
});
|
||||
|
||||
test('returns true if the OPEN_MANAGED group is enabled', () => {
|
||||
getConfig.mockReturnValue({ ENABLE_OPEN_MANAGED_TEAM_TYPE: true });
|
||||
expect(isGroupTypeEnabled(GroupTypes.OPEN_MANAGED)).toBe(true);
|
||||
});
|
||||
|
||||
test('returns false if the group is invalid', () => {
|
||||
getConfig.mockReturnValue({ ENABLE_OPEN_MANAGED_TEAM_TYPE: true });
|
||||
expect(isGroupTypeEnabled('FOO')).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false if the group is null', () => {
|
||||
getConfig.mockReturnValue({ ENABLE_OPEN_MANAGED_TEAM_TYPE: true });
|
||||
expect(isGroupTypeEnabled(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,9 +3,9 @@ import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
import FormSwitchGroup from '../../generic/FormSwitchGroup';
|
||||
import { useAppSetting } from '../../utils';
|
||||
import AppSettingsModal from '../app-settings-modal/AppSettingsModal';
|
||||
import FormSwitchGroup from 'CourseAuthoring/generic/FormSwitchGroup';
|
||||
import { useAppSetting } from 'CourseAuthoring/utils';
|
||||
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
|
||||
import messages from './messages';
|
||||
|
||||
const WikiSettings = ({ intl, onClose }) => {
|
||||
18
plugins/course-apps/wiki/package.json
Normal file
18
plugins/course-apps/wiki/package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@openedx-plugins/course-app-wiki",
|
||||
"version": "0.1.0",
|
||||
"description": "Wiki configuration for courses using it",
|
||||
"peerDependencies": {
|
||||
"@edx/frontend-app-course-authoring": "*",
|
||||
"@edx/frontend-platform": "*",
|
||||
"@openedx/paragon": "*",
|
||||
"prop-types": "*",
|
||||
"react": "*",
|
||||
"yup": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edx/frontend-app-course-authoring": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
4
plugins/course-apps/xpert_unit_summary/README.rst
Normal file
4
plugins/course-apps/xpert_unit_summary/README.rst
Normal file
@@ -0,0 +1,4 @@
|
||||
Xpert Unit Summaries Configuration Plugin
|
||||
=========================================
|
||||
|
||||
Install this using ``npm install plugins/course-apps/xpert_unit_summary/ --no-save``.
|
||||
@@ -2,8 +2,8 @@ import React, { useCallback, useContext, useEffect } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { PagesAndResourcesContext } from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { PagesAndResourcesContext } from '../PagesAndResourcesProvider';
|
||||
|
||||
import SettingsModal from './settings-modal/SettingsModal';
|
||||
import messages from './messages';
|
||||
@@ -10,12 +10,13 @@ import {
|
||||
queryByTestId, render, waitFor, getByText, fireEvent,
|
||||
} from '@testing-library/react';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import PagesAndResourcesProvider from '../PagesAndResourcesProvider';
|
||||
import { XpertUnitSummarySettings } from './index';
|
||||
import initializeStore from '../../store';
|
||||
import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
|
||||
import initializeStore from 'CourseAuthoring/store';
|
||||
import { executeThunk } from 'CourseAuthoring/utils';
|
||||
|
||||
import XpertUnitSummarySettings from './Settings';
|
||||
import * as API from './data/api';
|
||||
import * as Thunks from './data/thunks';
|
||||
import { executeThunk } from '../../utils';
|
||||
|
||||
const courseId = 'course-v1:edX+TestX+Test_Course';
|
||||
let axiosMock;
|
||||
@@ -1,12 +1,11 @@
|
||||
import { updateSavingStatus, updateLoadingStatus, updateResetStatus } from 'CourseAuthoring/pages-and-resources/data/slice';
|
||||
import { RequestStatus } from 'CourseAuthoring/data/constants';
|
||||
import { addModel, updateModel } from 'CourseAuthoring/generic/model-store';
|
||||
|
||||
import {
|
||||
getXpertSettings, postXpertSettings, getXpertPluginConfigurable, deleteXpertSettings,
|
||||
} from './api';
|
||||
|
||||
import { updateSavingStatus, updateLoadingStatus, updateResetStatus } from '../../data/slice';
|
||||
import { RequestStatus } from '../../../data/constants';
|
||||
|
||||
import { addModel, updateModel } from '../../../generic/model-store';
|
||||
|
||||
export function updateXpertSettings(courseId, state) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.IN_PROGRESS }));
|
||||
21
plugins/course-apps/xpert_unit_summary/package.json
Normal file
21
plugins/course-apps/xpert_unit_summary/package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@openedx-plugins/course-app-xpert_unit_summary",
|
||||
"version": "0.1.0",
|
||||
"description": "Xpert Unit Summaries configuration for courses using it",
|
||||
"peerDependencies": {
|
||||
"@edx/frontend-app-course-authoring": "*",
|
||||
"@edx/frontend-platform": "*",
|
||||
"@openedx/paragon": "*",
|
||||
"formik": "*",
|
||||
"prop-types": "*",
|
||||
"yup": "*",
|
||||
"react": "*",
|
||||
"react-redux": "*",
|
||||
"react-router-dom": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edx/frontend-app-course-authoring": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,10 +11,10 @@ import {
|
||||
Tooltip,
|
||||
TransitionReplace,
|
||||
Hyperlink,
|
||||
} from '@edx/paragon';
|
||||
} from '@openedx/paragon';
|
||||
import {
|
||||
Info, CheckCircleOutline, SpinnerSimple,
|
||||
} from '@edx/paragon/icons';
|
||||
} from '@openedx/paragon/icons';
|
||||
|
||||
import { Formik } from 'formik';
|
||||
import PropTypes from 'prop-types';
|
||||
@@ -24,22 +24,25 @@ import React, {
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
import { RequestStatus } from '../../../data/constants';
|
||||
import ConnectionErrorAlert from '../../../generic/ConnectionErrorAlert';
|
||||
import FormSwitchGroup from '../../../generic/FormSwitchGroup';
|
||||
import Loading from '../../../generic/Loading';
|
||||
import { useModel } from '../../../generic/model-store';
|
||||
import PermissionDeniedAlert from '../../../generic/PermissionDeniedAlert';
|
||||
import { useIsMobile } from '../../../utils';
|
||||
import { getLoadingStatus, getSavingStatus, getResetStatus } from '../../data/selectors';
|
||||
import { updateSavingStatus, updateResetStatus } from '../../data/slice';
|
||||
import { RequestStatus } from 'CourseAuthoring/data/constants';
|
||||
import ConnectionErrorAlert from 'CourseAuthoring/generic/ConnectionErrorAlert';
|
||||
import FormSwitchGroup from 'CourseAuthoring/generic/FormSwitchGroup';
|
||||
import Loading from 'CourseAuthoring/generic/Loading';
|
||||
import { useModel } from 'CourseAuthoring/generic/model-store';
|
||||
import PermissionDeniedAlert from 'CourseAuthoring/generic/PermissionDeniedAlert';
|
||||
import { useIsMobile } from 'CourseAuthoring/utils';
|
||||
import { getLoadingStatus, getSavingStatus, getResetStatus } from 'CourseAuthoring/pages-and-resources/data/selectors';
|
||||
import { updateSavingStatus, updateResetStatus } from 'CourseAuthoring/pages-and-resources/data/slice';
|
||||
import AppConfigFormDivider from 'CourseAuthoring/pages-and-resources/discussions/app-config-form/apps/shared/AppConfigFormDivider';
|
||||
import { PagesAndResourcesContext } from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
|
||||
|
||||
import { updateXpertSettings, resetXpertSettings, removeXpertSettings } from '../data/thunks';
|
||||
import AppConfigFormDivider from '../../discussions/app-config-form/apps/shared/AppConfigFormDivider';
|
||||
import { PagesAndResourcesContext } from '../../PagesAndResourcesProvider';
|
||||
import messages from './messages';
|
||||
import appInfo from '../appInfo';
|
||||
import ResetIcon from './ResetIcon';
|
||||
|
||||
import './SettingsModal.scss';
|
||||
|
||||
const AppSettingsForm = ({
|
||||
formikProps, children, showForm,
|
||||
}) => children && (
|
||||
@@ -1,3 +1,6 @@
|
||||
@import "~@edx/brand/paragon/variables";
|
||||
@import "~@openedx/paragon/scss/core/utilities-only";
|
||||
|
||||
.summary-radio {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -14,8 +14,6 @@ import PermissionDeniedAlert from './generic/PermissionDeniedAlert';
|
||||
import { getCourseAppsApiStatus } from './pages-and-resources/data/selectors';
|
||||
import { RequestStatus } from './data/constants';
|
||||
import Loading from './generic/Loading';
|
||||
import { fetchUserPermissionsQuery, fetchUserPermissionsEnabledFlag } from './generic/data/thunks';
|
||||
import { getUserPermissions } from './generic/data/selectors';
|
||||
|
||||
const AppHeader = ({
|
||||
courseNumber, courseOrg, courseTitle, courseId,
|
||||
@@ -42,14 +40,9 @@ AppHeader.defaultProps = {
|
||||
|
||||
const CourseAuthoringPage = ({ courseId, children }) => {
|
||||
const dispatch = useDispatch();
|
||||
const userPermissions = useSelector(getUserPermissions);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchCourseDetail(courseId));
|
||||
dispatch(fetchUserPermissionsEnabledFlag());
|
||||
if (!userPermissions) {
|
||||
dispatch(fetchUserPermissionsQuery(courseId));
|
||||
}
|
||||
}, [courseId]);
|
||||
|
||||
const courseDetail = useModel('courseDetails', courseId);
|
||||
|
||||
@@ -20,6 +20,7 @@ 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:
|
||||
@@ -73,6 +74,7 @@ const CourseAuthoringRoutes = () => {
|
||||
/>
|
||||
{DECODED_ROUTES.COURSE_UNIT.map((path) => (
|
||||
<Route
|
||||
key={path}
|
||||
path={path}
|
||||
element={<PageWrap><CourseUnit courseId={courseId} /></PageWrap>}
|
||||
/>
|
||||
@@ -109,6 +111,10 @@ const CourseAuthoringRoutes = () => {
|
||||
path="export"
|
||||
element={<PageWrap><CourseExportPage courseId={courseId} /></PageWrap>}
|
||||
/>
|
||||
<Route
|
||||
path="checklists"
|
||||
element={<PageWrap><CourseChecklist courseId={courseId} /></PageWrap>}
|
||||
/>
|
||||
</Routes>
|
||||
</CourseAuthoringPage>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { injectIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import { Hyperlink, MailtoLink, Stack } from '@openedx/paragon';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
const AccessibilityBody = ({
|
||||
communityAccessibilityLink,
|
||||
email,
|
||||
}) => (
|
||||
<div className="mt-5">
|
||||
<header>
|
||||
<h2 className="mb-4 pb-1">
|
||||
<FormattedMessage {...messages.a11yBodyPageHeader} />
|
||||
</h2>
|
||||
</header>
|
||||
<Stack gap={2.5}>
|
||||
<div className="small">
|
||||
<FormattedMessage
|
||||
{...messages.a11yBodyIntroGraph}
|
||||
values={{
|
||||
communityAccessibilityLink: (
|
||||
<Hyperlink
|
||||
destination={communityAccessibilityLink}
|
||||
data-testid="accessibility-page-link"
|
||||
>
|
||||
Website Accessibility Policy
|
||||
</Hyperlink>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="small">
|
||||
<FormattedMessage {...messages.a11yBodyStepsHeader} />
|
||||
</div>
|
||||
<ol className="small m-0">
|
||||
<li>
|
||||
<FormattedMessage
|
||||
{...messages.a11yBodyEmailHeading}
|
||||
values={{
|
||||
emailElement: (
|
||||
<MailtoLink
|
||||
to={email}
|
||||
data-testid="email-element"
|
||||
>
|
||||
{email}
|
||||
</MailtoLink>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<ul>
|
||||
<li>
|
||||
<FormattedMessage {...messages.a11yBodyNameEmail} />
|
||||
</li>
|
||||
<li>
|
||||
<FormattedMessage {...messages.a11yBodyInstitution} />
|
||||
</li>
|
||||
<li>
|
||||
<FormattedMessage {...messages.a11yBodyBarrier} />
|
||||
</li>
|
||||
<li>
|
||||
<FormattedMessage {...messages.a11yBodyTimeConstraints} />
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<FormattedMessage {...messages.a11yBodyReceipt} />
|
||||
</li>
|
||||
<li>
|
||||
<FormattedMessage {...messages.a11yBodyExtraInfo} />
|
||||
</li>
|
||||
</ol>
|
||||
<div className="small">
|
||||
<FormattedMessage
|
||||
{...messages.a11yBodyA11yFeedback}
|
||||
values={{
|
||||
emailElement: (
|
||||
<MailtoLink
|
||||
to={email}
|
||||
data-testid="email-element"
|
||||
>
|
||||
{email}
|
||||
</MailtoLink>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
3
src/accessibility-page/AccessibilityBody/index.js
Normal file
3
src/accessibility-page/AccessibilityBody/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import AccessibilityBody from './AccessibilityBody';
|
||||
|
||||
export default AccessibilityBody;
|
||||
111
src/accessibility-page/AccessibilityBody/messages.js
Normal file
111
src/accessibility-page/AccessibilityBody/messages.js
Normal file
@@ -0,0 +1,111 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
a11yBodyPolicyLink: {
|
||||
id: 'a11yBodyPolicyLink',
|
||||
defaultMessage: 'Website Accessibility Policy',
|
||||
description: 'Title for link to full accessibility policy.',
|
||||
},
|
||||
a11yBodyPageHeader: {
|
||||
id: 'a11yBodyPageHeader',
|
||||
defaultMessage: 'Individualized Accessibility Process for Course Creators',
|
||||
description: 'Heading for studio\'s accessibility policy page.',
|
||||
},
|
||||
a11yBodyIntroGraph: {
|
||||
id: 'a11yBodyIntroGraph',
|
||||
defaultMessage: `At edX, we seek to understand and respect the unique needs and perspectives of the edX global community.
|
||||
We value every course team and are committed to expanding access to all, including course team creators and authors with
|
||||
disabilities. To that end, we have adopted a {communityAccessibilityLink} and this process to allow course team creators
|
||||
and authors to request assistance if they are unable to develop and post content on our platform via Studio because of their
|
||||
disabilities.`,
|
||||
description: 'Introductory paragraph outlining why we care about accessibility, and what we\'re doing about it.',
|
||||
},
|
||||
a11yBodyStepsHeader: {
|
||||
id: 'a11yBodyStepsHeader',
|
||||
defaultMessage: 'Course team creators and authors needing such assistance should take the following steps:',
|
||||
description: 'Heading for list of steps authors can take for accessibility requests.',
|
||||
},
|
||||
a11yBodyEdxResponse: {
|
||||
id: 'a11yBodyEdxResponse',
|
||||
defaultMessage: `'We will communicate with you about your preferences and needs in determining the appropriate solution, although
|
||||
the ultimate decision will be ours, provided that the solution is effective and timely. The factors we will consider in choosing
|
||||
an accessibility solution are: effectiveness; timeliness (relative to your deadlines); ease of implementation; and ease of use for
|
||||
you. We will notify you of the decision and explain the basis for our decision within 10 business days of discussing with you.`,
|
||||
description: 'Paragraph outlining how we will select an accessibility solution.',
|
||||
},
|
||||
a11yBodyEdxFollowUp: {
|
||||
id: 'a11yBodyEdxFollowUp',
|
||||
defaultMessage: `Thereafter, we will communicate with you on a weekly basis regarding our evaluation, decision, and progress in
|
||||
implementing the accessibility solution. We will notify you when implementation of your accessibility solution is complete and
|
||||
will follow-up with you as may be necessary to see if the solution was effective.`,
|
||||
description: 'Paragraph outlining how we will follow-up with you during and after implementing an accessibility solution.',
|
||||
},
|
||||
a11yBodyOngoingSupport: {
|
||||
id: 'a11yBodyOngoingSupport',
|
||||
defaultMessage: 'EdX will provide ongoing technical support as needed and will address any additional issues that arise after the initial course creation.',
|
||||
description: 'A statement of ongoing support.',
|
||||
},
|
||||
a11yBodyA11yFeedback: {
|
||||
id: 'a11yBodyA11yFeedback',
|
||||
defaultMessage: 'Please direct any questions or suggestions on how to improve the accessibility of Studio to {emailElement} or use the form below. We welcome your feedback.',
|
||||
description: 'Contact information heading for those with accessibility issues or suggestions.',
|
||||
},
|
||||
a11yBodyEmailHeading: {
|
||||
id: 'a11yBodyEmailHeading',
|
||||
defaultMessage: 'Send an email to {emailElement} with the following information:',
|
||||
description: 'Heading for list of information required when you email us.',
|
||||
},
|
||||
a11yBodyNameEmail: {
|
||||
id: 'a11yBodyNameEmail',
|
||||
defaultMessage: 'your name and email address;',
|
||||
description: 'Your contact information.',
|
||||
},
|
||||
a11yBodyInstitution: {
|
||||
id: 'a11yBodyInstitution',
|
||||
defaultMessage: 'the edX member institution that you are affiliated with;',
|
||||
description: 'edX affiliate information.',
|
||||
},
|
||||
a11yBodyBarrier: {
|
||||
id: 'a11yBodyBarrier',
|
||||
defaultMessage: 'a brief description of the challenge or barrier to access that you are experiencing; and',
|
||||
description: 'Accessibility problem information.',
|
||||
},
|
||||
a11yBodyTimeConstraints: {
|
||||
id: 'a11yBodyTimeConstraints',
|
||||
defaultMessage: 'how soon you need access and for how long (e.g., a planned course start date or in connection with a course-related deadline such as a final essay).',
|
||||
description: 'Time contstraint information.',
|
||||
},
|
||||
a11yBodyReceipt: {
|
||||
id: 'a11yBodyReceipt',
|
||||
defaultMessage: 'The edX Support Team will respond to confirm receipt and forward your request to the edX Partner Manager for your institution and the edX Website Accessibility Specialist.',
|
||||
description: 'Paragraph outlining what steps edX will take immediately.',
|
||||
},
|
||||
a11yBodyExtraInfo: {
|
||||
id: 'a11yBodyExtraInfo',
|
||||
defaultMessage: `With guidance from the Website Accessibility Specialist, edX will contact you to discuss your request and gather
|
||||
additional information from you about your preferences and needs, to determine if there's a workable solution that edX is able to support.`,
|
||||
description: 'Paragraph outlining how and when edX will reach out to you.',
|
||||
},
|
||||
a11yBodyFixesListHeader: {
|
||||
id: 'a11yBodyFixesListHeader',
|
||||
defaultMessage: 'EdX will assist you promptly and thoroughly so that you are able to create content on the CMS within your time constraints. Such efforts may include, but are not limited to:',
|
||||
description: 'Heading for list of ways we might be able to assist.',
|
||||
},
|
||||
a11yBodyThirdParty: {
|
||||
id: 'a11yBodyThirdParty',
|
||||
defaultMessage: 'Purchasing a third-party tool or software for use on an individual basis to assist your use of Studio;',
|
||||
description: 'Buy third-party software.',
|
||||
},
|
||||
a11yBodyContractor: {
|
||||
id: 'a11yBodyContractor',
|
||||
defaultMessage: 'Engaging a trained independent contractor to provide real-time visual, verbal and physical assistance; or',
|
||||
description: 'Hire a contractor.',
|
||||
},
|
||||
a11yBodyCodeFix: {
|
||||
id: 'a11yBodyCodeFix',
|
||||
defaultMessage: 'Developing new code to implement a technical fix.',
|
||||
description: 'Make a technical fix.',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
146
src/accessibility-page/AccessibilityForm/AccessibilityForm.jsx
Normal file
146
src/accessibility-page/AccessibilityForm/AccessibilityForm.jsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
injectIntl, FormattedMessage, intlShape, FormattedDate, FormattedTime,
|
||||
} from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
ActionRow, Alert, Form, Stack, StatefulButton,
|
||||
} from '@openedx/paragon';
|
||||
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import { STATEFUL_BUTTON_STATES } from '../../constants';
|
||||
import submitAccessibilityForm from '../data/thunks';
|
||||
import useAccessibility from './hooks';
|
||||
import messages from './messages';
|
||||
|
||||
const AccessibilityForm = ({
|
||||
accessibilityEmail,
|
||||
// injected
|
||||
intl,
|
||||
}) => {
|
||||
const {
|
||||
errors,
|
||||
values,
|
||||
isFormFilled,
|
||||
dispatch,
|
||||
handleBlur,
|
||||
handleChange,
|
||||
hasErrorField,
|
||||
savingStatus,
|
||||
} = useAccessibility({ name: '', email: '', message: '' }, intl);
|
||||
|
||||
const formFields = [
|
||||
{
|
||||
label: intl.formatMessage(messages.accessibilityPolicyFormEmailLabel),
|
||||
name: 'email',
|
||||
value: values.email,
|
||||
},
|
||||
{
|
||||
label: intl.formatMessage(messages.accessibilityPolicyFormNameLabel),
|
||||
name: 'name',
|
||||
value: values.name,
|
||||
},
|
||||
{
|
||||
label: intl.formatMessage(messages.accessibilityPolicyFormMessageLabel),
|
||||
name: 'message',
|
||||
value: values.message,
|
||||
},
|
||||
];
|
||||
|
||||
const createButtonState = {
|
||||
labels: {
|
||||
default: intl.formatMessage(messages.accessibilityPolicyFormSubmitLabel),
|
||||
pending: intl.formatMessage(messages.accessibilityPolicyFormSubmittingFeedbackLabel),
|
||||
},
|
||||
disabledStates: [STATEFUL_BUTTON_STATES.pending],
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
dispatch(submitAccessibilityForm(values));
|
||||
};
|
||||
|
||||
const start = new Date('Mon Jan 29 2018 13:00:00 GMT (UTC)');
|
||||
const end = new Date('Fri Feb 2 2018 21:00:00 GMT (UTC)');
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2 className="my-4">
|
||||
<FormattedMessage {...messages.accessibilityPolicyFormHeader} />
|
||||
</h2>
|
||||
{savingStatus === RequestStatus.SUCCESSFUL && (
|
||||
<Alert variant="success">
|
||||
<Stack gap={2}>
|
||||
<div className="mb-2">
|
||||
<FormattedMessage {...messages.accessibilityPolicyFormSuccess} />
|
||||
</div>
|
||||
<div>
|
||||
<FormattedMessage
|
||||
{...messages.accessibilityPolicyFormSuccessDetails}
|
||||
values={{
|
||||
day_start: (<FormattedDate value={start} weekday="long" />),
|
||||
time_start: (<FormattedTime value={start} timeZoneName="short" />),
|
||||
day_end: (<FormattedDate value={end} weekday="long" />),
|
||||
time_end: (<FormattedTime value={end} timeZoneName="short" />),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
{savingStatus === RequestStatus.FAILED && (
|
||||
<Alert variant="danger">
|
||||
<div data-testid="rate-limit-alert">
|
||||
<FormattedMessage
|
||||
{...messages.accessibilityPolicyFormErrorHighVolume}
|
||||
values={{
|
||||
emailLink: <a href={`mailto:${accessibilityEmail}`}>{accessibilityEmail}</a>,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
<Form>
|
||||
{formFields.map((field) => (
|
||||
<Form.Group size="sm" key={field.label}>
|
||||
<Form.Control
|
||||
value={field.value}
|
||||
name={field.name}
|
||||
isInvalid={hasErrorField(field.name)}
|
||||
type={field.name === 'email' ? 'email' : null}
|
||||
as={field.name === 'message' ? 'textarea' : 'input'}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
floatingLabel={field.label}
|
||||
/>
|
||||
{hasErrorField(field.name) && (
|
||||
<Form.Control.Feedback type="invalid" data-testid={`error-feedback-${field.name}`}>
|
||||
{errors[field.name]}
|
||||
</Form.Control.Feedback>
|
||||
)}
|
||||
</Form.Group>
|
||||
))}
|
||||
</Form>
|
||||
<ActionRow>
|
||||
<StatefulButton
|
||||
key="save-button"
|
||||
onClick={handleSubmit}
|
||||
disabled={!isFormFilled}
|
||||
state={
|
||||
savingStatus === RequestStatus.IN_PROGRESS
|
||||
? STATEFUL_BUTTON_STATES.pending
|
||||
: STATEFUL_BUTTON_STATES.default
|
||||
}
|
||||
{...createButtonState}
|
||||
/>
|
||||
</ActionRow>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
AccessibilityForm.propTypes = {
|
||||
accessibilityEmail: PropTypes.string.isRequired,
|
||||
// injected
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(AccessibilityForm);
|
||||
@@ -0,0 +1,164 @@
|
||||
import {
|
||||
render,
|
||||
act,
|
||||
screen,
|
||||
} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import initializeStore from '../../store';
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
|
||||
import AccessibilityForm from './index';
|
||||
import { getZendeskrUrl } from '../data/api';
|
||||
import messages from './messages';
|
||||
|
||||
let axiosMock;
|
||||
let store;
|
||||
|
||||
const defaultProps = {
|
||||
accessibilityEmail: 'accessibilityTest@test.com',
|
||||
};
|
||||
|
||||
const initialState = {
|
||||
accessibilityPage: {
|
||||
savingStatus: '',
|
||||
},
|
||||
};
|
||||
|
||||
const renderComponent = () => {
|
||||
render(
|
||||
<IntlProvider locale="en">
|
||||
<AppProvider store={store}>
|
||||
<AccessibilityForm {...defaultProps} />
|
||||
</AppProvider>
|
||||
</IntlProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
describe('<AccessibilityPolicyForm />', () => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: false,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore(initialState);
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
});
|
||||
|
||||
describe('renders', () => {
|
||||
beforeEach(() => {
|
||||
renderComponent();
|
||||
});
|
||||
|
||||
it('correct number of form fields', () => {
|
||||
const formSections = screen.getAllByRole('textbox');
|
||||
const formButton = screen.getByText(messages.accessibilityPolicyFormSubmitLabel.defaultMessage);
|
||||
expect(formSections).toHaveLength(3);
|
||||
expect(formButton).toBeVisible();
|
||||
});
|
||||
|
||||
it('hides StatusAlert on initial load', () => {
|
||||
expect(screen.queryAllByRole('alert')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('statusAlert', () => {
|
||||
let formSections;
|
||||
let submitButton;
|
||||
beforeEach(async () => {
|
||||
renderComponent();
|
||||
formSections = screen.getAllByRole('textbox');
|
||||
await act(async () => {
|
||||
userEvent.type(formSections[0], 'email@email.com');
|
||||
userEvent.type(formSections[1], 'test name');
|
||||
userEvent.type(formSections[2], 'feedback message');
|
||||
});
|
||||
submitButton = screen.getByText(messages.accessibilityPolicyFormSubmitLabel.defaultMessage);
|
||||
});
|
||||
|
||||
it('shows correct success message', async () => {
|
||||
axiosMock.onPost(getZendeskrUrl()).reply(200);
|
||||
await act(async () => {
|
||||
userEvent.click(submitButton);
|
||||
});
|
||||
const { savingStatus } = store.getState().accessibilityPage;
|
||||
expect(savingStatus).toEqual(RequestStatus.SUCCESSFUL);
|
||||
|
||||
expect(screen.getAllByRole('alert')).toHaveLength(1);
|
||||
|
||||
expect(screen.getByText(messages.accessibilityPolicyFormSuccess.defaultMessage)).toBeVisible();
|
||||
|
||||
formSections.forEach(input => {
|
||||
expect(input.value).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows correct rate limiting message', async () => {
|
||||
axiosMock.onPost(getZendeskrUrl()).reply(429);
|
||||
await act(async () => {
|
||||
userEvent.click(submitButton);
|
||||
});
|
||||
const { savingStatus } = store.getState().accessibilityPage;
|
||||
expect(savingStatus).toEqual(RequestStatus.FAILED);
|
||||
|
||||
expect(screen.getAllByRole('alert')).toHaveLength(1);
|
||||
|
||||
expect(screen.getByTestId('rate-limit-alert')).toBeVisible();
|
||||
|
||||
formSections.forEach(input => {
|
||||
expect(input.value).not.toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('input validation', () => {
|
||||
let formSections;
|
||||
let submitButton;
|
||||
beforeEach(async () => {
|
||||
renderComponent();
|
||||
formSections = screen.getAllByRole('textbox');
|
||||
await act(async () => {
|
||||
userEvent.type(formSections[0], 'email@email.com');
|
||||
userEvent.type(formSections[1], 'test name');
|
||||
userEvent.type(formSections[2], 'feedback message');
|
||||
});
|
||||
submitButton = screen.getByText(messages.accessibilityPolicyFormSubmitLabel.defaultMessage);
|
||||
});
|
||||
|
||||
it('adds validation checking on each input field', async () => {
|
||||
await act(async () => {
|
||||
userEvent.clear(formSections[0]);
|
||||
userEvent.clear(formSections[1]);
|
||||
userEvent.clear(formSections[2]);
|
||||
});
|
||||
const emailError = screen.getByTestId('error-feedback-email');
|
||||
expect(emailError).toBeVisible();
|
||||
|
||||
const fullNameError = screen.getByTestId('error-feedback-email');
|
||||
expect(fullNameError).toBeVisible();
|
||||
|
||||
const messageError = screen.getByTestId('error-feedback-message');
|
||||
expect(messageError).toBeVisible();
|
||||
});
|
||||
|
||||
it('sumbit button is disabled when trying to submit with all empty fields', async () => {
|
||||
await act(async () => {
|
||||
userEvent.clear(formSections[0]);
|
||||
userEvent.clear(formSections[1]);
|
||||
userEvent.clear(formSections[2]);
|
||||
userEvent.click(submitButton);
|
||||
});
|
||||
|
||||
expect(submitButton.closest('button')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
58
src/accessibility-page/AccessibilityForm/hooks.js
Normal file
58
src/accessibility-page/AccessibilityForm/hooks.js
Normal file
@@ -0,0 +1,58 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import messages from './messages';
|
||||
|
||||
const useAccessibility = (initialValues, intl) => {
|
||||
const dispatch = useDispatch();
|
||||
const savingStatus = useSelector(state => state.accessibilityPage.savingStatus);
|
||||
const [isFormFilled, setFormFilled] = useState(false);
|
||||
const validationSchema = Yup.object().shape({
|
||||
name: Yup.string().required(
|
||||
intl.formatMessage(messages.accessibilityPolicyFormValidName),
|
||||
),
|
||||
email: Yup.string()
|
||||
.email(intl.formatMessage(messages.accessibilityPolicyFormValidEmail))
|
||||
.required(intl.formatMessage(messages.accessibilityPolicyFormValidEmail)),
|
||||
message: Yup.string().required(
|
||||
intl.formatMessage(messages.accessibilityPolicyFormValidMessage),
|
||||
),
|
||||
});
|
||||
|
||||
const {
|
||||
values, errors, touched, handleChange, handleBlur, handleReset,
|
||||
} = useFormik({
|
||||
initialValues,
|
||||
enableReinitialize: true,
|
||||
validateOnBlur: false,
|
||||
validationSchema,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setFormFilled(Object.values(values).every((i) => i));
|
||||
}, [values]);
|
||||
|
||||
useEffect(() => {
|
||||
if (savingStatus === RequestStatus.SUCCESSFUL) {
|
||||
handleReset();
|
||||
}
|
||||
}, [savingStatus]);
|
||||
|
||||
const hasErrorField = (fieldName) => !!errors[fieldName] && !!touched[fieldName];
|
||||
|
||||
return {
|
||||
errors,
|
||||
values,
|
||||
isFormFilled,
|
||||
dispatch,
|
||||
handleBlur,
|
||||
handleChange,
|
||||
hasErrorField,
|
||||
savingStatus,
|
||||
};
|
||||
};
|
||||
|
||||
export default useAccessibility;
|
||||
3
src/accessibility-page/AccessibilityForm/index.js
Normal file
3
src/accessibility-page/AccessibilityForm/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import AccessibilityForm from './AccessibilityForm';
|
||||
|
||||
export default AccessibilityForm;
|
||||
76
src/accessibility-page/AccessibilityForm/messages.js
Normal file
76
src/accessibility-page/AccessibilityForm/messages.js
Normal file
@@ -0,0 +1,76 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
accessibilityPolicyFormEmailLabel: {
|
||||
id: 'accessibilityPolicyFormEmailLabel',
|
||||
defaultMessage: 'Email Address',
|
||||
description: 'Label for the email form field',
|
||||
},
|
||||
accessibilityPolicyFormErrorHighVolume: {
|
||||
id: 'accessibilityPolicyFormErrorHighVolume',
|
||||
defaultMessage: 'We are currently experiencing high volume. Try again later today or send an email message to {emailLink}.',
|
||||
description: 'Error message when site is experiencing high volume that will include an email link',
|
||||
},
|
||||
accessibilityPolicyFormErrorMissingFields: {
|
||||
id: 'accessibilityPolicyFormErrorMissingFields',
|
||||
defaultMessage: 'Make sure to fill in all fields.',
|
||||
description: 'Error message to instruct user to fill in all fields',
|
||||
},
|
||||
accessibilityPolicyFormHeader: {
|
||||
id: 'accessibilityPolicyFormHeader',
|
||||
defaultMessage: 'Studio Accessibility Feedback',
|
||||
description: 'The heading for the form',
|
||||
},
|
||||
accessibilityPolicyFormMessageLabel: {
|
||||
id: 'accessibilityPolicyFormMessageLabel',
|
||||
defaultMessage: 'Message',
|
||||
description: 'Label for the message form field',
|
||||
},
|
||||
accessibilityPolicyFormNameLabel: {
|
||||
id: 'accessibilityPolicyFormNameLabel',
|
||||
defaultMessage: 'Name',
|
||||
description: 'Label for the name form field',
|
||||
},
|
||||
accessibilityPolicyFormSubmitAria: {
|
||||
id: 'accessibilityPolicyFormSubmitAria',
|
||||
defaultMessage: 'Submit Accessibility Feedback Form',
|
||||
description: 'Detailed aria-label for the submit button',
|
||||
},
|
||||
accessibilityPolicyFormSubmitLabel: {
|
||||
id: 'accessibilityPolicyFormSubmitLabel',
|
||||
defaultMessage: 'Submit',
|
||||
description: 'General label for the submit button',
|
||||
},
|
||||
accessibilityPolicyFormSubmittingFeedbackLabel: {
|
||||
id: 'accessibilityPolicyFormSubmittingFeedbackLabel',
|
||||
defaultMessage: 'Submitting',
|
||||
description: 'Loading message while form feedback is being submitted',
|
||||
},
|
||||
accessibilityPolicyFormSuccess: {
|
||||
id: 'accessibilityPolicyFormSuccess',
|
||||
defaultMessage: 'Thank you for contacting edX!',
|
||||
description: 'Simple thank you message when form submission is successful',
|
||||
},
|
||||
accessibilityPolicyFormSuccessDetails: {
|
||||
id: 'accessibilityPolicyFormSuccessDetails',
|
||||
defaultMessage: 'Thank you for your feedback regarding the accessibility of Studio. We typically respond within one business day ({day_start} to {day_end}, {time_start} to {time_end}).',
|
||||
description: 'Detailed thank you message when form submission is successful',
|
||||
},
|
||||
accessibilityPolicyFormValidEmail: {
|
||||
id: 'accessibilityPolicyFormValidEmail',
|
||||
defaultMessage: 'Enter a valid email address.',
|
||||
description: 'Error message for when an invalid email is entered into the form',
|
||||
},
|
||||
accessibilityPolicyFormValidMessage: {
|
||||
id: 'accessibilityPolicyFormValidMessage',
|
||||
defaultMessage: 'Enter a message.',
|
||||
description: 'Error message an invalid message is entered into the form',
|
||||
},
|
||||
accessibilityPolicyFormValidName: {
|
||||
id: 'accessibilityPolicyFormValidName',
|
||||
defaultMessage: 'Enter a name.',
|
||||
description: 'Error message an invalid name is entered into the form',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
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);
|
||||
46
src/accessibility-page/AccessibilityPage.test.jsx
Normal file
46
src/accessibility-page/AccessibilityPage.test.jsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
} from '@testing-library/react';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import initializeStore from '../store';
|
||||
import AccessibilityPage from './index';
|
||||
|
||||
const initialState = {
|
||||
accessibilityPage: {
|
||||
status: {},
|
||||
},
|
||||
};
|
||||
let store;
|
||||
|
||||
const renderComponent = () => {
|
||||
render(
|
||||
<IntlProvider locale="en">
|
||||
<AppProvider store={store}>
|
||||
<AccessibilityPage />
|
||||
</AppProvider>
|
||||
</IntlProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
describe('<AccessibilityPolicyPage />', () => {
|
||||
describe('renders', () => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: false,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore(initialState);
|
||||
});
|
||||
it('contains the policy body', () => {
|
||||
renderComponent();
|
||||
expect(screen.getByText('Individualized Accessibility Process for Course Creators')).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
28
src/accessibility-page/data/api.js
Normal file
28
src/accessibility-page/data/api.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import { ensureConfig, getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
ensureConfig([
|
||||
'STUDIO_BASE_URL',
|
||||
], 'Course Apps API service');
|
||||
|
||||
export const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
|
||||
export const getZendeskrUrl = () => `${getApiBaseUrl()}/zendesk_proxy/v0`;
|
||||
|
||||
/**
|
||||
* Posts the form data to zendesk endpoint
|
||||
* @param {string} courseId
|
||||
* @returns {Promise<[{}]>}
|
||||
*/
|
||||
export async function postAccessibilityForm({ name, email, message }) {
|
||||
const data = {
|
||||
name,
|
||||
tags: ['studio_a11y'],
|
||||
email: {
|
||||
from: email,
|
||||
subject: 'Studio Accessibility Request',
|
||||
message,
|
||||
},
|
||||
};
|
||||
|
||||
await getAuthenticatedHttpClient().post(getZendeskrUrl(), data);
|
||||
}
|
||||
23
src/accessibility-page/data/slice.js
Normal file
23
src/accessibility-page/data/slice.js
Normal file
@@ -0,0 +1,23 @@
|
||||
/* eslint-disable no-param-reassign */
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
const slice = createSlice({
|
||||
name: 'accessibilityPage',
|
||||
initialState: {
|
||||
savingStatus: '',
|
||||
},
|
||||
reducers: {
|
||||
updateSavingStatus: (state, { payload }) => {
|
||||
state.savingStatus = payload.status;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
updateLoadingStatus,
|
||||
updateSavingStatus,
|
||||
} = slice.actions;
|
||||
|
||||
export const {
|
||||
reducer,
|
||||
} = slice;
|
||||
22
src/accessibility-page/data/thunks.js
Normal file
22
src/accessibility-page/data/thunks.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import { postAccessibilityForm } from './api';
|
||||
import { updateSavingStatus } from './slice';
|
||||
|
||||
function submitAccessibilityForm({ email, name, message }) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.IN_PROGRESS }));
|
||||
|
||||
try {
|
||||
await postAccessibilityForm({ email, name, message });
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
} catch (error) {
|
||||
if (error.response && error.response.status === 429) {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
} else {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default submitAccessibilityForm;
|
||||
3
src/accessibility-page/index.js
Normal file
3
src/accessibility-page/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import AccessibilityPage from './AccessibilityPage';
|
||||
|
||||
export default AccessibilityPage;
|
||||
10
src/accessibility-page/messages.js
Normal file
10
src/accessibility-page/messages.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
pageTitle: {
|
||||
id: 'course-authoring.import.page.title',
|
||||
defaultMessage: 'Studio Accessibility Policy| {siteName}',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -3,8 +3,8 @@ import PropTypes from 'prop-types';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import {
|
||||
Container, Button, Layout, StatefulButton, TransitionReplace,
|
||||
} from '@edx/paragon';
|
||||
import { CheckCircle, Info, Warning } from '@edx/paragon/icons';
|
||||
} from '@openedx/paragon';
|
||||
import { CheckCircle, Info, Warning } from '@openedx/paragon/icons';
|
||||
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import Placeholder from '@edx/frontend-lib-content-components';
|
||||
|
||||
@@ -25,9 +25,6 @@ import validateAdvancedSettingsData from './utils';
|
||||
import messages from './messages';
|
||||
import ModalError from './modal-error/ModalError';
|
||||
import getPageHeadTitle from '../generic/utils';
|
||||
import { useUserPermissions } from '../generic/hooks';
|
||||
import { getUserPermissionsEnabled } from '../generic/data/selectors';
|
||||
import PermissionDeniedAlert from '../generic/PermissionDeniedAlert';
|
||||
|
||||
const AdvancedSettings = ({ intl, courseId }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -44,13 +41,6 @@ const AdvancedSettings = ({ intl, courseId }) => {
|
||||
const courseDetails = useModel('courseDetails', courseId);
|
||||
document.title = getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.headingTitle));
|
||||
|
||||
const { checkPermission } = useUserPermissions();
|
||||
const userPermissionsEnabled = useSelector(getUserPermissionsEnabled);
|
||||
const viewOnly = checkPermission('view_course_settings');
|
||||
const showPermissionDeniedAlert = userPermissionsEnabled && (
|
||||
!checkPermission('manage_advanced_settings') && !checkPermission('view_course_settings')
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchCourseAppSettings(courseId));
|
||||
dispatch(fetchProctoringExamErrors(courseId));
|
||||
@@ -93,11 +83,6 @@ const AdvancedSettings = ({ intl, courseId }) => {
|
||||
// eslint-disable-next-line react/jsx-no-useless-fragment
|
||||
return <></>;
|
||||
}
|
||||
if (showPermissionDeniedAlert) {
|
||||
return (
|
||||
<PermissionDeniedAlert />
|
||||
);
|
||||
}
|
||||
if (loadingSettingsStatus === RequestStatus.DENIED) {
|
||||
return (
|
||||
<div className="row justify-content-center m-6">
|
||||
@@ -230,7 +215,6 @@ const AdvancedSettings = ({ intl, courseId }) => {
|
||||
handleBlur={handleSettingBlur}
|
||||
isEditableState={isEditableState}
|
||||
setIsEditableState={setIsEditableState}
|
||||
disableForm={viewOnly}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -3,11 +3,7 @@ import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import {
|
||||
render,
|
||||
fireEvent,
|
||||
waitFor,
|
||||
} from '@testing-library/react';
|
||||
import { render, fireEvent, waitFor } from '@testing-library/react';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
|
||||
import initializeStore from '../store';
|
||||
@@ -17,15 +13,11 @@ import { getCourseAdvancedSettingsApiUrl } from './data/api';
|
||||
import { updateCourseAppSetting } from './data/thunks';
|
||||
import AdvancedSettings from './AdvancedSettings';
|
||||
import messages from './messages';
|
||||
import { getUserPermissionsUrl, getUserPermissionsEnabledFlagUrl } from '../generic/data/api';
|
||||
import { fetchUserPermissionsQuery, fetchUserPermissionsEnabledFlag } from '../generic/data/thunks';
|
||||
|
||||
let axiosMock;
|
||||
let store;
|
||||
const mockPathname = '/foo-bar';
|
||||
const courseId = '123';
|
||||
const userId = 3;
|
||||
const userPermissionsData = { permissions: ['view_course_settings', 'manage_advanced_settings'] };
|
||||
|
||||
// Mock the TextareaAutosize component
|
||||
jest.mock('react-textarea-autosize', () => jest.fn((props) => (
|
||||
@@ -51,23 +43,11 @@ const RootWrapper = () => (
|
||||
</AppProvider>
|
||||
);
|
||||
|
||||
const permissionsMockStore = async (permissions) => {
|
||||
axiosMock.onGet(getUserPermissionsUrl(courseId, userId)).reply(200, permissions);
|
||||
axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: true });
|
||||
await executeThunk(fetchUserPermissionsQuery(courseId), store.dispatch);
|
||||
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
|
||||
};
|
||||
|
||||
const permissionDisabledMockStore = async () => {
|
||||
axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: false });
|
||||
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
|
||||
};
|
||||
|
||||
describe('<AdvancedSettings />', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId,
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
@@ -78,9 +58,7 @@ describe('<AdvancedSettings />', () => {
|
||||
axiosMock
|
||||
.onGet(`${getCourseAdvancedSettingsApiUrl(courseId)}?fetch_all=0`)
|
||||
.reply(200, advancedSettingsMock);
|
||||
permissionsMockStore(userPermissionsData);
|
||||
});
|
||||
|
||||
it('should render without errors', async () => {
|
||||
const { getByText } = render(<RootWrapper />);
|
||||
await waitFor(() => {
|
||||
@@ -183,29 +161,4 @@ describe('<AdvancedSettings />', () => {
|
||||
await executeThunk(updateCourseAppSetting(courseId, [3, 2, 1]), store.dispatch);
|
||||
expect(getByText('Your policy changes have been saved.')).toBeInTheDocument();
|
||||
});
|
||||
it('should shows the PermissionDeniedAlert when there are not the right user permissions', async () => {
|
||||
const permissionsData = { permissions: ['view'] };
|
||||
await permissionsMockStore(permissionsData);
|
||||
|
||||
const { queryByText } = render(<RootWrapper />);
|
||||
await waitFor(() => {
|
||||
const permissionDeniedAlert = queryByText('You are not authorized to view this page. If you feel you should have access, please reach out to your course team admin to be given access.');
|
||||
expect(permissionDeniedAlert).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
it('should not show the PermissionDeniedAlert when the User Permissions Flag is not enabled', async () => {
|
||||
await permissionDisabledMockStore();
|
||||
|
||||
const { queryByText } = render(<RootWrapper />);
|
||||
const permissionDeniedAlert = queryByText('You are not authorized to view this page. If you feel you should have access, please reach out to your course team admin to be given access.');
|
||||
expect(permissionDeniedAlert).not.toBeInTheDocument();
|
||||
});
|
||||
it('should be view only if the permission is set for viewOnly', async () => {
|
||||
const permissions = { permissions: ['view_course_settings'] };
|
||||
await permissionsMockStore(permissions);
|
||||
const { getByLabelText } = render(<RootWrapper />);
|
||||
await waitFor(() => {
|
||||
expect(getByLabelText('Advanced Module List')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { ActionRow, AlertModal, Button } from '@edx/paragon';
|
||||
import { ActionRow, AlertModal, Button } from '@openedx/paragon';
|
||||
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import ModalErrorListItem from './ModalErrorListItem';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Alert, Icon } from '@edx/paragon';
|
||||
import { Error } from '@edx/paragon/icons';
|
||||
import { Alert, Icon } from '@openedx/paragon';
|
||||
import { Error } from '@openedx/paragon/icons';
|
||||
import { capitalize } from 'lodash';
|
||||
|
||||
import { transformKeysToCamelCase } from '../../utils';
|
||||
|
||||
@@ -7,8 +7,8 @@ import {
|
||||
IconButton,
|
||||
ModalPopup,
|
||||
useToggle,
|
||||
} from '@edx/paragon';
|
||||
import { InfoOutline, Warning } from '@edx/paragon/icons';
|
||||
} from '@openedx/paragon';
|
||||
import { InfoOutline, Warning } from '@openedx/paragon/icons';
|
||||
import PropTypes from 'prop-types';
|
||||
import { capitalize } from 'lodash';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
@@ -27,7 +27,6 @@ const SettingCard = ({
|
||||
setIsEditableState,
|
||||
// injected
|
||||
intl,
|
||||
disableForm,
|
||||
}) => {
|
||||
const { deprecated, help, displayName } = settingData;
|
||||
const initialValue = JSON.stringify(settingData.value, null, 4);
|
||||
@@ -101,7 +100,6 @@ const SettingCard = ({
|
||||
onChange={handleSettingChange}
|
||||
aria-label={displayName}
|
||||
onBlur={handleCardBlur}
|
||||
disabled={disableForm}
|
||||
/>
|
||||
</Form.Group>
|
||||
</Card.Section>
|
||||
@@ -137,7 +135,6 @@ SettingCard.propTypes = {
|
||||
saveSettingsPrompt: PropTypes.bool.isRequired,
|
||||
isEditableState: PropTypes.bool.isRequired,
|
||||
setIsEditableState: PropTypes.func.isRequired,
|
||||
disableForm: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(SettingCard);
|
||||
|
||||
@@ -26,6 +26,10 @@ export const NOTIFICATION_MESSAGES = {
|
||||
deleting: 'Deleting',
|
||||
copying: 'Copying',
|
||||
pasting: 'Pasting',
|
||||
discardChanges: 'Discarding changes',
|
||||
publishing: 'Publishing',
|
||||
hidingFromStudents: 'Hiding from students',
|
||||
makingVisibleToStudents: 'Making visible to students',
|
||||
empty: '',
|
||||
};
|
||||
|
||||
|
||||
39
src/content-tags-drawer/ContentTagsCollapsible.d.ts
vendored
Normal file
39
src/content-tags-drawer/ContentTagsCollapsible.d.ts
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
import type {} from 'react-select/base';
|
||||
// This import is necessary for module augmentation.
|
||||
// It allows us to extend the 'Props' interface in the 'react-select/base' module
|
||||
// and add our custom property 'myCustomProp' to it.
|
||||
|
||||
export interface TagTreeEntry {
|
||||
explicit: boolean;
|
||||
children: Record<string, TagTreeEntry>;
|
||||
canChangeObjecttag: boolean;
|
||||
canDeleteObjecttag: boolean;
|
||||
}
|
||||
|
||||
export interface TaxonomySelectProps {
|
||||
taxonomyId: number;
|
||||
searchTerm: string;
|
||||
appliedContentTagsTree: Record<string, TagTreeEntry>;
|
||||
stagedContentTagsTree: Record<string, TagTreeEntry>;
|
||||
checkedTags: string[];
|
||||
handleCommitStagedTags: () => void;
|
||||
handleCancelStagedTags: () => void;
|
||||
handleSelectableBoxChange: React.ChangeEventHandler;
|
||||
}
|
||||
|
||||
// Unfortunately the only way to specify the custom props we pass into React Select
|
||||
// is with this global type augmentation.
|
||||
// https://react-select.com/typescript#custom-select-props
|
||||
// If in the future other parts of this MFE need to use React Select for different things,
|
||||
// we should change to using a 'react context' to share this data within <ContentTagsCollapsible>,
|
||||
// rather than using the custom <Select> Props (selectProps).
|
||||
declare module 'react-select/base' {
|
||||
export interface Props<
|
||||
Option,
|
||||
IsMulti extends boolean,
|
||||
Group extends GroupBase<Option>
|
||||
> extends TaxonomySelectProps {
|
||||
}
|
||||
}
|
||||
|
||||
export default ContentTagsCollapsible;
|
||||
@@ -1,20 +1,20 @@
|
||||
// @ts-check
|
||||
// disable prop-types since we're using TypeScript to define the prop types,
|
||||
// but the linter can't detect that in a .jsx file.
|
||||
/* eslint-disable react/prop-types */
|
||||
import React from 'react';
|
||||
import Select, { components } from 'react-select';
|
||||
import {
|
||||
Badge,
|
||||
Collapsible,
|
||||
SelectableBox,
|
||||
Button,
|
||||
ModalPopup,
|
||||
useToggle,
|
||||
SearchField,
|
||||
} from '@edx/paragon';
|
||||
import PropTypes from 'prop-types';
|
||||
Spinner,
|
||||
} from '@openedx/paragon';
|
||||
import classNames from 'classnames';
|
||||
import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import { SelectableBox } from '@edx/frontend-lib-content-components';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { debounce } from 'lodash';
|
||||
import messages from './messages';
|
||||
import './ContentTagsCollapsible.scss';
|
||||
|
||||
import ContentTagsDropDownSelector from './ContentTagsDropDownSelector';
|
||||
|
||||
@@ -22,9 +22,116 @@ import ContentTagsTree from './ContentTagsTree';
|
||||
|
||||
import useContentTagsCollapsibleHelper from './ContentTagsCollapsibleHelper';
|
||||
|
||||
/** @typedef {import("./ContentTagsCollapsible").TagTreeEntry} TagTreeEntry */
|
||||
/** @typedef {import("./ContentTagsCollapsible").TaxonomySelectProps} TaxonomySelectProps */
|
||||
/** @typedef {import("../taxonomy/data/types.mjs").TaxonomyData} TaxonomyData */
|
||||
/** @typedef {import("./data/types.mjs").Tag} ContentTagData */
|
||||
|
||||
/**
|
||||
* Custom Menu component for our Select box
|
||||
* @param {import("react-select").MenuProps&{selectProps: TaxonomySelectProps}} props
|
||||
*/
|
||||
const CustomMenu = (props) => {
|
||||
const {
|
||||
handleSelectableBoxChange,
|
||||
checkedTags,
|
||||
taxonomyId,
|
||||
appliedContentTagsTree,
|
||||
stagedContentTagsTree,
|
||||
handleCommitStagedTags,
|
||||
handleCancelStagedTags,
|
||||
searchTerm,
|
||||
value,
|
||||
} = props.selectProps;
|
||||
const intl = useIntl();
|
||||
return (
|
||||
<components.Menu {...props}>
|
||||
<div className="bg-white p-3 shadow">
|
||||
|
||||
<SelectableBox.Set
|
||||
type="checkbox"
|
||||
name="tags"
|
||||
columns={1}
|
||||
ariaLabel={intl.formatMessage(messages.taxonomyTagsAriaLabel)}
|
||||
className="taxonomy-tags-selectable-box-set"
|
||||
onChange={handleSelectableBoxChange}
|
||||
value={checkedTags}
|
||||
>
|
||||
<ContentTagsDropDownSelector
|
||||
key={`selector-${taxonomyId}`}
|
||||
taxonomyId={taxonomyId}
|
||||
level={0}
|
||||
appliedContentTagsTree={appliedContentTagsTree}
|
||||
stagedContentTagsTree={stagedContentTagsTree}
|
||||
searchTerm={searchTerm}
|
||||
/>
|
||||
</SelectableBox.Set>
|
||||
<hr className="mt-0 mb-0" />
|
||||
<div className="d-flex flex-row justify-content-end">
|
||||
<div className="d-inline">
|
||||
<Button
|
||||
variant="tertiary"
|
||||
className="cancel-add-tags-button"
|
||||
onClick={handleCancelStagedTags}
|
||||
>
|
||||
{ intl.formatMessage(messages.collapsibleCancelStagedTagsButtonText) }
|
||||
</Button>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
className="text-info-500 add-tags-button"
|
||||
disabled={!(value && value.length)}
|
||||
onClick={handleCommitStagedTags}
|
||||
>
|
||||
{ intl.formatMessage(messages.collapsibleAddStagedTagsButtonText) }
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</components.Menu>
|
||||
);
|
||||
};
|
||||
|
||||
const CustomLoadingIndicator = () => {
|
||||
const intl = useIntl();
|
||||
return (
|
||||
<Spinner
|
||||
animation="border"
|
||||
size="xl"
|
||||
screenReaderText={intl.formatMessage(messages.loadingMessage)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Custom IndicatorsContainer component for our Select box
|
||||
* @param {import("react-select").IndicatorsContainerProps&{selectProps: TaxonomySelectProps}} props
|
||||
*/
|
||||
const CustomIndicatorsContainer = (props) => {
|
||||
const {
|
||||
value,
|
||||
handleCommitStagedTags,
|
||||
} = props.selectProps;
|
||||
const intl = useIntl();
|
||||
return (
|
||||
<components.IndicatorsContainer {...props}>
|
||||
{
|
||||
(value && value.length && (
|
||||
<Button
|
||||
variant="dark"
|
||||
size="sm"
|
||||
className="mt-2 mb-2 rounded-0"
|
||||
onClick={handleCommitStagedTags}
|
||||
onMouseDown={(e) => { e.stopPropagation(); e.preventDefault(); }}
|
||||
>
|
||||
{ intl.formatMessage(messages.collapsibleInlineAddStagedTagsButtonText) }
|
||||
</Button>
|
||||
)) || null
|
||||
}
|
||||
{props.children}
|
||||
</components.IndicatorsContainer>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Collapsible component that holds a Taxonomy along with Tags that belong to it.
|
||||
* This includes both applied tags and tags that are available to select
|
||||
@@ -98,99 +205,137 @@ import useContentTagsCollapsibleHelper from './ContentTagsCollapsibleHelper';
|
||||
*
|
||||
* @param {Object} props - The component props.
|
||||
* @param {string} props.contentId - Id of the content object
|
||||
* @param {{value: string, label: string}[]} props.stagedContentTags
|
||||
* - Array of staged tags represented as objects with value/label
|
||||
* @param {(taxonomyId: number, tag: {value: string, label: string}) => void} props.addStagedContentTag
|
||||
* - Callback function to add a staged tag for a taxonomy
|
||||
* @param {(taxonomyId: number, tagValue: string) => void} props.removeStagedContentTag
|
||||
* - Callback function to remove a staged tag from a taxonomy
|
||||
* @param {Function} props.setStagedTags - Callback function to set staged tags for a taxonomy to provided tags list
|
||||
* @param {TaxonomyData & {contentTags: ContentTagData[]}} props.taxonomyAndTagsData - Taxonomy metadata & applied tags
|
||||
*/
|
||||
const ContentTagsCollapsible = ({ contentId, taxonomyAndTagsData }) => {
|
||||
const ContentTagsCollapsible = ({
|
||||
contentId, taxonomyAndTagsData, stagedContentTags, addStagedContentTag, removeStagedContentTag, setStagedTags,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const { id, name, canTagObject } = taxonomyAndTagsData;
|
||||
const { id: taxonomyId, name, canTagObject } = taxonomyAndTagsData;
|
||||
const selectRef = React.useRef(/** @type {HTMLSelectElement | null} */(null));
|
||||
|
||||
const {
|
||||
tagChangeHandler, tagsTree, contentTagsCount, checkedTags,
|
||||
} = useContentTagsCollapsibleHelper(contentId, taxonomyAndTagsData);
|
||||
|
||||
const [isOpen, open, close] = useToggle(false);
|
||||
const [addTagsButtonRef, setAddTagsButtonRef] = React.useState(null);
|
||||
tagChangeHandler,
|
||||
removeAppliedTagHandler,
|
||||
appliedContentTagsTree,
|
||||
stagedContentTagsTree,
|
||||
contentTagsCount,
|
||||
checkedTags,
|
||||
commitStagedTags,
|
||||
updateTags,
|
||||
} = useContentTagsCollapsibleHelper(
|
||||
contentId,
|
||||
taxonomyAndTagsData,
|
||||
addStagedContentTag,
|
||||
removeStagedContentTag,
|
||||
stagedContentTags,
|
||||
);
|
||||
|
||||
const [searchTerm, setSearchTerm] = React.useState('');
|
||||
|
||||
const handleSelectableBoxChange = React.useCallback((e) => {
|
||||
tagChangeHandler(e.target.value, e.target.checked);
|
||||
}, []);
|
||||
}, [tagChangeHandler]);
|
||||
|
||||
const handleSearch = debounce((term) => {
|
||||
setSearchTerm(term.trim());
|
||||
}, 500); // Perform search after 500ms
|
||||
|
||||
const handleSearchChange = React.useCallback((value) => {
|
||||
if (value === '') {
|
||||
// No need to debounce when search term cleared. Clear debounce function
|
||||
const handleSearchChange = React.useCallback((value, { action }) => {
|
||||
if (action === 'input-blur') {
|
||||
// Cancel/clear search if focused away from select input
|
||||
handleSearch.cancel();
|
||||
setSearchTerm('');
|
||||
} else {
|
||||
handleSearch(value);
|
||||
} else if (action === 'input-change') {
|
||||
if (value === '') {
|
||||
// No need to debounce when search term cleared. Clear debounce function
|
||||
handleSearch.cancel();
|
||||
setSearchTerm('');
|
||||
} else {
|
||||
handleSearch(value);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const modalPopupOnCloseHandler = React.useCallback((event) => {
|
||||
close(event);
|
||||
// Clear search term
|
||||
// onChange handler for react-select component, currently only called when
|
||||
// staged tags in the react-select input are removed or fully cleared.
|
||||
// The remaining staged tags are passed in as the parameter, so we set the state
|
||||
// to the passed in tags
|
||||
const handleStagedTagsMenuChange = React.useCallback((stagedTags) => {
|
||||
// Get tags that were unstaged to remove them from checkbox selector
|
||||
const unstagedTags = stagedContentTags.filter(
|
||||
t1 => !stagedTags.some(t2 => t1.value === t2.value),
|
||||
);
|
||||
|
||||
// Call the `tagChangeHandler` with the unstaged tags to unselect them from the selectbox
|
||||
// and update the staged content tags tree. Since the `handleStagedTagsMenuChange` function is={}
|
||||
// only called when a change occurs in the react-select menu component we know that tags can only be
|
||||
// removed from there, hence the tagChangeHandler is always called with `checked=false`.
|
||||
unstagedTags.forEach(unstagedTag => tagChangeHandler(unstagedTag.value, false));
|
||||
setStagedTags(taxonomyId, stagedTags);
|
||||
}, [taxonomyId, setStagedTags, stagedContentTags, tagChangeHandler]);
|
||||
|
||||
const handleCommitStagedTags = React.useCallback(() => {
|
||||
commitStagedTags();
|
||||
handleStagedTagsMenuChange([]);
|
||||
selectRef.current?.blur();
|
||||
setSearchTerm('');
|
||||
}, []);
|
||||
}, [commitStagedTags, handleStagedTagsMenuChange, selectRef, setSearchTerm]);
|
||||
|
||||
const handleCancelStagedTags = React.useCallback(() => {
|
||||
handleStagedTagsMenuChange([]);
|
||||
selectRef.current?.blur();
|
||||
setSearchTerm('');
|
||||
}, [handleStagedTagsMenuChange, selectRef, setSearchTerm]);
|
||||
|
||||
return (
|
||||
<div className="d-flex">
|
||||
<Collapsible title={name} styling="card-lg" className="taxonomy-tags-collapsible">
|
||||
<div key={id}>
|
||||
<ContentTagsTree tagsTree={tagsTree} removeTagHandler={tagChangeHandler} />
|
||||
<div key={taxonomyId}>
|
||||
<ContentTagsTree tagsTree={appliedContentTagsTree} removeTagHandler={removeAppliedTagHandler} />
|
||||
</div>
|
||||
|
||||
<div className="d-flex taxonomy-tags-selector-menu">
|
||||
|
||||
{canTagObject && (
|
||||
<Button
|
||||
ref={setAddTagsButtonRef}
|
||||
variant="outline-primary"
|
||||
onClick={open}
|
||||
>
|
||||
<FormattedMessage {...messages.addTagsButtonText} />
|
||||
</Button>
|
||||
<Select
|
||||
ref={/** @type {React.RefObject} */(selectRef)}
|
||||
isMulti
|
||||
isLoading={updateTags.isLoading}
|
||||
isDisabled={updateTags.isLoading}
|
||||
name="tags-select"
|
||||
placeholder={intl.formatMessage(messages.collapsibleAddTagsPlaceholderText)}
|
||||
isSearchable
|
||||
className="d-flex flex-column flex-fill"
|
||||
classNamePrefix="react-select-add-tags"
|
||||
onInputChange={handleSearchChange}
|
||||
onChange={handleStagedTagsMenuChange}
|
||||
components={{
|
||||
Menu: CustomMenu,
|
||||
LoadingIndicator: CustomLoadingIndicator,
|
||||
IndicatorsContainer: CustomIndicatorsContainer,
|
||||
}}
|
||||
closeMenuOnSelect={false}
|
||||
blurInputOnSelect={false}
|
||||
handleSelectableBoxChange={handleSelectableBoxChange}
|
||||
checkedTags={checkedTags}
|
||||
taxonomyId={taxonomyId}
|
||||
appliedContentTagsTree={appliedContentTagsTree}
|
||||
stagedContentTagsTree={stagedContentTagsTree}
|
||||
handleCommitStagedTags={handleCommitStagedTags}
|
||||
handleCancelStagedTags={handleCancelStagedTags}
|
||||
searchTerm={searchTerm}
|
||||
value={stagedContentTags}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<ModalPopup
|
||||
hasArrow
|
||||
placement="bottom"
|
||||
positionRef={addTagsButtonRef}
|
||||
isOpen={isOpen}
|
||||
onClose={modalPopupOnCloseHandler}
|
||||
>
|
||||
<div className="bg-white p-3 shadow">
|
||||
|
||||
<SelectableBox.Set
|
||||
type="checkbox"
|
||||
name="tags"
|
||||
columns={1}
|
||||
ariaLabel={intl.formatMessage(messages.taxonomyTagsAriaLabel)}
|
||||
className="taxonomy-tags-selectable-box-set"
|
||||
onChange={handleSelectableBoxChange}
|
||||
value={checkedTags}
|
||||
>
|
||||
<SearchField
|
||||
onSubmit={() => {}}
|
||||
onChange={handleSearchChange}
|
||||
className="mb-2"
|
||||
/>
|
||||
|
||||
<ContentTagsDropDownSelector
|
||||
key={`selector-${id}`}
|
||||
taxonomyId={id}
|
||||
level={0}
|
||||
tagsTree={tagsTree}
|
||||
searchTerm={searchTerm}
|
||||
/>
|
||||
</SelectableBox.Set>
|
||||
</div>
|
||||
</ModalPopup>
|
||||
|
||||
</Collapsible>
|
||||
<div className="d-flex">
|
||||
<Badge
|
||||
@@ -207,17 +352,4 @@ const ContentTagsCollapsible = ({ contentId, taxonomyAndTagsData }) => {
|
||||
);
|
||||
};
|
||||
|
||||
ContentTagsCollapsible.propTypes = {
|
||||
contentId: PropTypes.string.isRequired,
|
||||
taxonomyAndTagsData: PropTypes.shape({
|
||||
id: PropTypes.number,
|
||||
name: PropTypes.string,
|
||||
contentTags: PropTypes.arrayOf(PropTypes.shape({
|
||||
value: PropTypes.string,
|
||||
lineage: PropTypes.arrayOf(PropTypes.string),
|
||||
})),
|
||||
canTagObject: PropTypes.bool.isRequired,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default ContentTagsCollapsible;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user