Compare commits
7 Commits
abdullahwa
...
CourseRole
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2bfb1fb7b | ||
|
|
c754a5e519 | ||
|
|
1e9146a5b9 | ||
|
|
a518fada29 | ||
|
|
69d9ea318e | ||
|
|
e74e1ff5aa | ||
|
|
1137dae97a |
2
.env
2
.env
@@ -32,7 +32,6 @@ 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=''
|
||||
@@ -41,4 +40,3 @@ HOTJAR_VERSION=6
|
||||
HOTJAR_DEBUG=false
|
||||
INVITE_STUDENTS_EMAIL_TO=''
|
||||
AI_TRANSLATIONS_BASE_URL=''
|
||||
ENABLE_CHECKLIST_QUALITY=''
|
||||
|
||||
@@ -34,7 +34,6 @@ 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=''
|
||||
@@ -43,4 +42,3 @@ HOTJAR_VERSION=6
|
||||
HOTJAR_DEBUG=true
|
||||
INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
|
||||
AI_TRANSLATIONS_BASE_URL='http://localhost:18760'
|
||||
ENABLE_CHECKLIST_QUALITY=true
|
||||
|
||||
@@ -30,9 +30,7 @@ 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_CHECKLIST_QUALITY=true
|
||||
|
||||
19
.eslintrc.js
19
.eslintrc.js
@@ -1,6 +1,5 @@
|
||||
const path = require('path');
|
||||
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||
const { createConfig } = require('@openedx/frontend-build');
|
||||
const { createConfig } = require('@edx/frontend-build');
|
||||
|
||||
module.exports = createConfig(
|
||||
'eslint',
|
||||
@@ -14,21 +13,5 @@ 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
27
.github/pull_request_template.md
vendored
@@ -1,27 +0,0 @@
|
||||
## 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,6 +23,3 @@ temp/babel-plugin-react-intl
|
||||
|
||||
# Local environment overrides
|
||||
.env.private
|
||||
|
||||
# Messages .json files fetched by atlas
|
||||
src/i18n/messages/
|
||||
|
||||
9
.tx/config
Normal file
9
.tx/config
Normal file
@@ -0,0 +1,9 @@
|
||||
[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
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
# The following users are the maintainers of all frontend-app-course-authoring files
|
||||
* @openedx/2u-tnl
|
||||
22
Makefile
22
Makefile
@@ -1,3 +1,7 @@
|
||||
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
|
||||
@@ -29,6 +33,23 @@ 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
|
||||
@@ -42,6 +63,7 @@ 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:
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
# 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'
|
||||
@@ -0,0 +1,21 @@
|
||||
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.
|
||||
@@ -0,0 +1,66 @@
|
||||
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``.
|
||||
@@ -0,0 +1,62 @@
|
||||
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('@openedx/frontend-build');
|
||||
const { createConfig } = require('@edx/frontend-build');
|
||||
|
||||
module.exports = createConfig('jest', {
|
||||
setupFilesAfterEnv: [
|
||||
@@ -11,7 +11,6 @@ module.exports = createConfig('jest', {
|
||||
],
|
||||
moduleNameMapper: {
|
||||
'^lodash-es$': 'lodash',
|
||||
'^CourseAuthoring/(.*)$': '<rootDir>/src/$1',
|
||||
},
|
||||
modulePathIgnorePatterns: [
|
||||
'/src/pages-and-resources/utils.test.jsx',
|
||||
|
||||
18265
package-lock.json
generated
18265
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
37
package.json
37
package.json
@@ -36,35 +36,21 @@
|
||||
"url": "https://github.com/openedx/frontend-app-course-authoring/issues"
|
||||
},
|
||||
"dependencies": {
|
||||
"@datadog/browser-rum": "^5.13.0",
|
||||
"@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": "^2.0.0",
|
||||
"@edx/frontend-component-footer": "^13.0.2",
|
||||
"@edx/frontend-component-header": "^5.0.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-enterprise-hotjar": "^2.0.0",
|
||||
"@edx/frontend-lib-content-components": "^2.1.4",
|
||||
"@edx/frontend-platform": "7.0.1",
|
||||
"@edx/frontend-lib-content-components": "^1.178.2",
|
||||
"@edx/frontend-platform": "5.6.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",
|
||||
@@ -85,7 +71,6 @@
|
||||
"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",
|
||||
@@ -96,20 +81,18 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@edx/browserslist-config": "1.2.0",
|
||||
"@edx/react-unit-test-utils": "^2.0.0",
|
||||
"@edx/frontend-build": "13.0.5",
|
||||
"@edx/react-unit-test-utils": "^1.7.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",
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
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;
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
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;
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"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,19 +0,0 @@
|
||||
{
|
||||
"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,23 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"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,102 +0,0 @@
|
||||
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,20 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
/* 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;
|
||||
};
|
||||
@@ -1,39 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
Xpert Unit Summaries Configuration Plugin
|
||||
=========================================
|
||||
|
||||
Install this using ``npm install plugins/course-apps/xpert_unit_summary/ --no-save``.
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@ 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,
|
||||
@@ -40,9 +42,14 @@ 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,7 +20,6 @@ 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:
|
||||
@@ -74,7 +73,6 @@ const CourseAuthoringRoutes = () => {
|
||||
/>
|
||||
{DECODED_ROUTES.COURSE_UNIT.map((path) => (
|
||||
<Route
|
||||
key={path}
|
||||
path={path}
|
||||
element={<PageWrap><CourseUnit courseId={courseId} /></PageWrap>}
|
||||
/>
|
||||
@@ -111,10 +109,6 @@ const CourseAuthoringRoutes = () => {
|
||||
path="export"
|
||||
element={<PageWrap><CourseExportPage courseId={courseId} /></PageWrap>}
|
||||
/>
|
||||
<Route
|
||||
path="checklists"
|
||||
element={<PageWrap><CourseChecklist courseId={courseId} /></PageWrap>}
|
||||
/>
|
||||
</Routes>
|
||||
</CourseAuthoringPage>
|
||||
);
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
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);
|
||||
@@ -1,46 +0,0 @@
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
} from '@testing-library/react';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import initializeStore from '../../store';
|
||||
|
||||
import AccessibilityBody from './index';
|
||||
|
||||
let store;
|
||||
|
||||
const renderComponent = () => {
|
||||
render(
|
||||
<IntlProvider locale="en">
|
||||
<AppProvider store={store}>
|
||||
<AccessibilityBody
|
||||
communityAccessibilityLink="http://example.com"
|
||||
email="example@example.com"
|
||||
/>
|
||||
</AppProvider>
|
||||
</IntlProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
describe('<AccessibilityBody />', () => {
|
||||
describe('renders', () => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: false,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore({});
|
||||
});
|
||||
it('contains links', () => {
|
||||
renderComponent();
|
||||
expect(screen.getAllByTestId('email-element')).toHaveLength(2);
|
||||
expect(screen.getAllByTestId('accessibility-page-link')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,3 +0,0 @@
|
||||
import AccessibilityBody from './AccessibilityBody';
|
||||
|
||||
export default AccessibilityBody;
|
||||
@@ -1,111 +0,0 @@
|
||||
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;
|
||||
@@ -1,146 +0,0 @@
|
||||
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);
|
||||
@@ -1,164 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,58 +0,0 @@
|
||||
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;
|
||||
@@ -1,3 +0,0 @@
|
||||
import AccessibilityForm from './AccessibilityForm';
|
||||
|
||||
export default AccessibilityForm;
|
||||
@@ -1,76 +0,0 @@
|
||||
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;
|
||||
@@ -1,42 +0,0 @@
|
||||
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);
|
||||
@@ -1,46 +0,0 @@
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
} from '@testing-library/react';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import initializeStore from '../store';
|
||||
import 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,28 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
/* eslint-disable no-param-reassign */
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
const slice = createSlice({
|
||||
name: 'accessibilityPage',
|
||||
initialState: {
|
||||
savingStatus: '',
|
||||
},
|
||||
reducers: {
|
||||
updateSavingStatus: (state, { payload }) => {
|
||||
state.savingStatus = payload.status;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
updateLoadingStatus,
|
||||
updateSavingStatus,
|
||||
} = slice.actions;
|
||||
|
||||
export const {
|
||||
reducer,
|
||||
} = slice;
|
||||
@@ -1,22 +0,0 @@
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import { postAccessibilityForm } from './api';
|
||||
import { updateSavingStatus } from './slice';
|
||||
|
||||
function submitAccessibilityForm({ email, name, message }) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.IN_PROGRESS }));
|
||||
|
||||
try {
|
||||
await postAccessibilityForm({ email, name, message });
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
} catch (error) {
|
||||
if (error.response && error.response.status === 429) {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
} else {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default submitAccessibilityForm;
|
||||
@@ -1,3 +0,0 @@
|
||||
import AccessibilityPage from './AccessibilityPage';
|
||||
|
||||
export default AccessibilityPage;
|
||||
@@ -1,10 +0,0 @@
|
||||
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 '@openedx/paragon';
|
||||
import { CheckCircle, Info, Warning } from '@openedx/paragon/icons';
|
||||
} from '@edx/paragon';
|
||||
import { CheckCircle, Info, Warning } from '@edx/paragon/icons';
|
||||
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import Placeholder from '@edx/frontend-lib-content-components';
|
||||
|
||||
@@ -25,6 +25,9 @@ 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();
|
||||
@@ -41,6 +44,13 @@ 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));
|
||||
@@ -83,6 +93,11 @@ 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">
|
||||
@@ -215,6 +230,7 @@ const AdvancedSettings = ({ intl, courseId }) => {
|
||||
handleBlur={handleSettingBlur}
|
||||
isEditableState={isEditableState}
|
||||
setIsEditableState={setIsEditableState}
|
||||
disableForm={viewOnly}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -3,7 +3,11 @@ 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';
|
||||
@@ -13,11 +17,15 @@ 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) => (
|
||||
@@ -43,11 +51,23 @@ 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: 3,
|
||||
userId,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
@@ -58,7 +78,9 @@ 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(() => {
|
||||
@@ -161,4 +183,29 @@ 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 '@openedx/paragon';
|
||||
import { ActionRow, AlertModal, Button } from '@edx/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 '@openedx/paragon';
|
||||
import { Error } from '@openedx/paragon/icons';
|
||||
import { Alert, Icon } from '@edx/paragon';
|
||||
import { Error } from '@edx/paragon/icons';
|
||||
import { capitalize } from 'lodash';
|
||||
|
||||
import { transformKeysToCamelCase } from '../../utils';
|
||||
|
||||
@@ -7,8 +7,8 @@ import {
|
||||
IconButton,
|
||||
ModalPopup,
|
||||
useToggle,
|
||||
} from '@openedx/paragon';
|
||||
import { InfoOutline, Warning } from '@openedx/paragon/icons';
|
||||
} from '@edx/paragon';
|
||||
import { InfoOutline, Warning } from '@edx/paragon/icons';
|
||||
import PropTypes from 'prop-types';
|
||||
import { capitalize } from 'lodash';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
@@ -27,6 +27,7 @@ const SettingCard = ({
|
||||
setIsEditableState,
|
||||
// injected
|
||||
intl,
|
||||
disableForm,
|
||||
}) => {
|
||||
const { deprecated, help, displayName } = settingData;
|
||||
const initialValue = JSON.stringify(settingData.value, null, 4);
|
||||
@@ -100,6 +101,7 @@ const SettingCard = ({
|
||||
onChange={handleSettingChange}
|
||||
aria-label={displayName}
|
||||
onBlur={handleCardBlur}
|
||||
disabled={disableForm}
|
||||
/>
|
||||
</Form.Group>
|
||||
</Card.Section>
|
||||
@@ -135,6 +137,7 @@ SettingCard.propTypes = {
|
||||
saveSettingsPrompt: PropTypes.bool.isRequired,
|
||||
isEditableState: PropTypes.bool.isRequired,
|
||||
setIsEditableState: PropTypes.func.isRequired,
|
||||
disableForm: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(SettingCard);
|
||||
|
||||
@@ -26,10 +26,6 @@ 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: '',
|
||||
};
|
||||
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import { Ref } from 'react';
|
||||
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[];
|
||||
selectCancelRef: Ref,
|
||||
selectAddRef: Ref,
|
||||
selectInlineAddRef: Ref,
|
||||
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,
|
||||
Spinner,
|
||||
} from '@openedx/paragon';
|
||||
ModalPopup,
|
||||
useToggle,
|
||||
SearchField,
|
||||
} from '@edx/paragon';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { SelectableBox } from '@edx/frontend-lib-content-components';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import { debounce } from 'lodash';
|
||||
import messages from './messages';
|
||||
import './ContentTagsCollapsible.scss';
|
||||
|
||||
import ContentTagsDropDownSelector from './ContentTagsDropDownSelector';
|
||||
|
||||
@@ -22,134 +22,9 @@ 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,
|
||||
selectCancelRef,
|
||||
selectAddRef,
|
||||
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}
|
||||
tabIndex="-1"
|
||||
>
|
||||
<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
|
||||
tabIndex="0"
|
||||
ref={selectCancelRef}
|
||||
variant="tertiary"
|
||||
className="cancel-add-tags-button"
|
||||
onClick={handleCancelStagedTags}
|
||||
>
|
||||
{ intl.formatMessage(messages.collapsibleCancelStagedTagsButtonText) }
|
||||
</Button>
|
||||
<Button
|
||||
tabIndex="0"
|
||||
ref={selectAddRef}
|
||||
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 disableActionKeys = (e) => {
|
||||
const arrowKeys = ['ArrowUp', 'ArrowDown', 'ArrowRight', 'ArrowLeft', 'Backspace'];
|
||||
if (arrowKeys.includes(e.code)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
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,
|
||||
selectInlineAddRef,
|
||||
} = props.selectProps;
|
||||
const intl = useIntl();
|
||||
return (
|
||||
<components.IndicatorsContainer {...props}>
|
||||
{
|
||||
(value && value.length && (
|
||||
<Button
|
||||
variant="dark"
|
||||
size="sm"
|
||||
className="mt-2 mb-2 rounded-0 inline-add-button"
|
||||
onClick={handleCommitStagedTags}
|
||||
onMouseDown={(e) => { e.stopPropagation(); e.preventDefault(); }}
|
||||
ref={selectInlineAddRef}
|
||||
tabIndex="0"
|
||||
onKeyDown={disableActionKeys} // To prevent navigating staged tags when button focused
|
||||
>
|
||||
{ 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
|
||||
@@ -223,200 +98,99 @@ const CustomIndicatorsContainer = (props) => {
|
||||
*
|
||||
* @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, stagedContentTags, addStagedContentTag, removeStagedContentTag, setStagedTags,
|
||||
}) => {
|
||||
const ContentTagsCollapsible = ({ contentId, taxonomyAndTagsData }) => {
|
||||
const intl = useIntl();
|
||||
const { id: taxonomyId, name, canTagObject } = taxonomyAndTagsData;
|
||||
const selectCancelRef = React.useRef(/** @type {HTMLSelectElement | null} */(null));
|
||||
const selectAddRef = React.useRef(/** @type {HTMLSelectElement | null} */(null));
|
||||
const selectInlineAddRef = React.useRef(/** @type {HTMLSelectElement | null} */(null));
|
||||
const selectRef = React.useRef(/** @type {HTMLSelectElement | null} */(null));
|
||||
|
||||
const [selectMenuIsOpen, setSelectMenuIsOpen] = React.useState(false);
|
||||
const { id, name, canTagObject } = taxonomyAndTagsData;
|
||||
|
||||
const {
|
||||
tagChangeHandler,
|
||||
removeAppliedTagHandler,
|
||||
appliedContentTagsTree,
|
||||
stagedContentTagsTree,
|
||||
contentTagsCount,
|
||||
checkedTags,
|
||||
commitStagedTags,
|
||||
updateTags,
|
||||
} = useContentTagsCollapsibleHelper(
|
||||
contentId,
|
||||
taxonomyAndTagsData,
|
||||
addStagedContentTag,
|
||||
removeStagedContentTag,
|
||||
stagedContentTags,
|
||||
);
|
||||
tagChangeHandler, tagsTree, contentTagsCount, checkedTags,
|
||||
} = useContentTagsCollapsibleHelper(contentId, taxonomyAndTagsData);
|
||||
|
||||
const [isOpen, open, close] = useToggle(false);
|
||||
const [addTagsButtonRef, setAddTagsButtonRef] = React.useState(null);
|
||||
|
||||
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, { action }) => {
|
||||
if (action === 'input-blur') {
|
||||
if (!selectMenuIsOpen) {
|
||||
// Cancel/clear search if focused away from select input and menu closed
|
||||
handleSearch.cancel();
|
||||
setSearchTerm('');
|
||||
}
|
||||
} 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 handleSearchChange = React.useCallback((value) => {
|
||||
if (value === '') {
|
||||
// No need to debounce when search term cleared. Clear debounce function
|
||||
handleSearch.cancel();
|
||||
setSearchTerm('');
|
||||
} else {
|
||||
handleSearch(value);
|
||||
}
|
||||
}, [selectMenuIsOpen, setSearchTerm, handleSearch]);
|
||||
}, []);
|
||||
|
||||
// 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();
|
||||
const modalPopupOnCloseHandler = React.useCallback((event) => {
|
||||
close(event);
|
||||
// Clear search term
|
||||
setSearchTerm('');
|
||||
setSelectMenuIsOpen(false);
|
||||
}, [commitStagedTags, handleStagedTagsMenuChange, selectRef, setSearchTerm]);
|
||||
|
||||
const handleCancelStagedTags = React.useCallback(() => {
|
||||
handleStagedTagsMenuChange([]);
|
||||
selectRef.current?.blur();
|
||||
setSearchTerm('');
|
||||
setSelectMenuIsOpen(false);
|
||||
}, [handleStagedTagsMenuChange, selectRef, setSearchTerm]);
|
||||
|
||||
const handleSelectOnKeyDown = (event) => {
|
||||
const focusedElement = event.target;
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
setSelectMenuIsOpen(false);
|
||||
} else if (event.key === 'Tab') {
|
||||
// Keep the menu open when navigating inside the select menu
|
||||
setSelectMenuIsOpen(true);
|
||||
|
||||
// Determine when to close the menu when navigating with keyboard
|
||||
if (!event.shiftKey) { // Navigating forwards
|
||||
if (focusedElement === selectAddRef.current) {
|
||||
setSelectMenuIsOpen(false);
|
||||
} else if (focusedElement === selectCancelRef.current && selectAddRef.current?.disabled) {
|
||||
setSelectMenuIsOpen(false);
|
||||
}
|
||||
// Navigating backwards
|
||||
// @ts-ignore inputRef actually exists under the current selectRef
|
||||
} else if (event.shiftKey && focusedElement === selectRef.current?.inputRef) {
|
||||
setSelectMenuIsOpen(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Open the select menu and make sure the search term is cleared when focused
|
||||
const onSelectMenuFocus = React.useCallback(() => {
|
||||
setSelectMenuIsOpen(true);
|
||||
setSearchTerm('');
|
||||
}, [setSelectMenuIsOpen, setSearchTerm]);
|
||||
|
||||
// Handles logic to close the select menu when clicking outside
|
||||
const handleOnBlur = React.useCallback((event) => {
|
||||
// Check if a target we are focusing to is an element in our select menu, if not close it
|
||||
const menuClasses = ['dropdown-selector', 'inline-add-button', 'cancel-add-tags-button'];
|
||||
if (!event.relatedTarget || !menuClasses.some(cls => event.relatedTarget.className?.includes(cls))) {
|
||||
setSelectMenuIsOpen(false);
|
||||
}
|
||||
}, [setSelectMenuIsOpen]);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="d-flex">
|
||||
<Collapsible title={name} styling="card-lg" className="taxonomy-tags-collapsible">
|
||||
<div key={taxonomyId}>
|
||||
<ContentTagsTree tagsTree={appliedContentTagsTree} removeTagHandler={removeAppliedTagHandler} />
|
||||
<div key={id}>
|
||||
<ContentTagsTree tagsTree={tagsTree} removeTagHandler={tagChangeHandler} />
|
||||
</div>
|
||||
|
||||
<div className="d-flex taxonomy-tags-selector-menu">
|
||||
|
||||
{canTagObject && (
|
||||
<Select
|
||||
onBlur={handleOnBlur}
|
||||
styles={{
|
||||
// Overriding 'x' button styles for staged tags when navigating by keyboard
|
||||
multiValueRemove: (base, state) => ({
|
||||
...base,
|
||||
background: state.isFocused ? 'black' : base.background,
|
||||
color: state.isFocused ? 'white' : base.color,
|
||||
}),
|
||||
}}
|
||||
menuIsOpen={selectMenuIsOpen}
|
||||
onFocus={onSelectMenuFocus}
|
||||
onKeyDown={handleSelectOnKeyDown}
|
||||
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}
|
||||
selectCancelRef={selectCancelRef}
|
||||
selectAddRef={selectAddRef}
|
||||
selectInlineAddRef={selectInlineAddRef}
|
||||
value={stagedContentTags}
|
||||
/>
|
||||
<Button
|
||||
ref={setAddTagsButtonRef}
|
||||
variant="outline-primary"
|
||||
onClick={open}
|
||||
>
|
||||
<FormattedMessage {...messages.addTagsButtonText} />
|
||||
</Button>
|
||||
)}
|
||||
</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
|
||||
@@ -433,4 +207,17 @@ const ContentTagsCollapsible = ({
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
@@ -27,33 +27,3 @@
|
||||
.pgn__modal-popup__arrow {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.add-tags-button:not([disabled]):hover {
|
||||
background-color: transparent;
|
||||
color: $info-900 !important;
|
||||
}
|
||||
|
||||
.cancel-add-tags-button:hover {
|
||||
background-color: transparent;
|
||||
color: $gray-300 !important;
|
||||
}
|
||||
|
||||
.react-select-add-tags__control {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
.react-select-add-tags__control--is-focused {
|
||||
border-color: black !important;
|
||||
box-shadow: 0 0 0 1px black !important;
|
||||
}
|
||||
|
||||
.react-select-add-tags__multi-value__remove {
|
||||
padding-right: 7px !important;
|
||||
padding-left: 7px !important;
|
||||
border-radius: 0 3px 3px 0;
|
||||
|
||||
&:hover {
|
||||
background-color: black !important;
|
||||
color: white !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,89 +9,22 @@ import userEvent from '@testing-library/user-event';
|
||||
|
||||
import ContentTagsCollapsible from './ContentTagsCollapsible';
|
||||
import messages from './messages';
|
||||
|
||||
const taxonomyMockData = {
|
||||
hasMorePages: false,
|
||||
canAddTag: false,
|
||||
tagPages: {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: [{
|
||||
value: 'Tag 1',
|
||||
externalId: null,
|
||||
childCount: 2,
|
||||
depth: 0,
|
||||
parentValue: null,
|
||||
id: 12345,
|
||||
subTagsUrl: null,
|
||||
canChangeTag: false,
|
||||
canDeleteTag: false,
|
||||
}, {
|
||||
value: 'Tag 2',
|
||||
externalId: null,
|
||||
childCount: 0,
|
||||
depth: 0,
|
||||
parentValue: null,
|
||||
id: 12346,
|
||||
subTagsUrl: null,
|
||||
canChangeTag: false,
|
||||
canDeleteTag: false,
|
||||
}, {
|
||||
value: 'Tag 3',
|
||||
externalId: null,
|
||||
childCount: 0,
|
||||
depth: 0,
|
||||
parentValue: null,
|
||||
id: 12347,
|
||||
subTagsUrl: null,
|
||||
canChangeTag: false,
|
||||
canDeleteTag: false,
|
||||
}],
|
||||
},
|
||||
};
|
||||
|
||||
const nestedTaxonomyMockData = {
|
||||
hasMorePages: false,
|
||||
canAddTag: false,
|
||||
tagPages: {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: [{
|
||||
value: 'Tag 1.1',
|
||||
externalId: null,
|
||||
childCount: 0,
|
||||
depth: 1,
|
||||
parentValue: 'Tag 1',
|
||||
id: 12354,
|
||||
subTagsUrl: null,
|
||||
canChangeTag: false,
|
||||
canDeleteTag: false,
|
||||
}, {
|
||||
value: 'Tag 1.2',
|
||||
externalId: null,
|
||||
childCount: 0,
|
||||
depth: 1,
|
||||
parentValue: 'Tag 1',
|
||||
id: 12355,
|
||||
subTagsUrl: null,
|
||||
canChangeTag: false,
|
||||
canDeleteTag: false,
|
||||
}],
|
||||
},
|
||||
};
|
||||
import { useTaxonomyTagsData } from './data/apiHooks';
|
||||
|
||||
jest.mock('./data/apiHooks', () => ({
|
||||
useContentTaxonomyTagsUpdater: jest.fn(() => ({
|
||||
isError: false,
|
||||
mutate: jest.fn(),
|
||||
})),
|
||||
useTaxonomyTagsData: jest.fn((_, parentTagValue) => {
|
||||
// To mock nested call of useTaxonomyData in subtags dropdown
|
||||
if (parentTagValue === 'Tag 1') {
|
||||
return nestedTaxonomyMockData;
|
||||
}
|
||||
return taxonomyMockData;
|
||||
}),
|
||||
useTaxonomyTagsData: jest.fn(() => ({
|
||||
hasMorePages: false,
|
||||
tagPages: {
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
canAddTag: false,
|
||||
data: [],
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
const data = {
|
||||
@@ -118,29 +51,11 @@ const data = {
|
||||
},
|
||||
],
|
||||
},
|
||||
stagedContentTags: [],
|
||||
addStagedContentTag: jest.fn(),
|
||||
removeStagedContentTag: jest.fn(),
|
||||
setStagedTags: jest.fn(),
|
||||
};
|
||||
|
||||
const ContentTagsCollapsibleComponent = ({
|
||||
contentId,
|
||||
taxonomyAndTagsData,
|
||||
stagedContentTags,
|
||||
addStagedContentTag,
|
||||
removeStagedContentTag,
|
||||
setStagedTags,
|
||||
}) => (
|
||||
const ContentTagsCollapsibleComponent = ({ contentId, taxonomyAndTagsData }) => (
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
<ContentTagsCollapsible
|
||||
contentId={contentId}
|
||||
taxonomyAndTagsData={taxonomyAndTagsData}
|
||||
stagedContentTags={stagedContentTags}
|
||||
addStagedContentTag={addStagedContentTag}
|
||||
removeStagedContentTag={removeStagedContentTag}
|
||||
setStagedTags={setStagedTags}
|
||||
/>
|
||||
<ContentTagsCollapsible contentId={contentId} taxonomyAndTagsData={taxonomyAndTagsData} />
|
||||
</IntlProvider>
|
||||
);
|
||||
|
||||
@@ -155,10 +70,6 @@ describe('<ContentTagsCollapsible />', () => {
|
||||
jest.useRealTimers(); // Restore real timers after the tests
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks(); // Reset all mock function call counts after each test case
|
||||
});
|
||||
|
||||
async function getComponent(updatedData) {
|
||||
const componentData = (!updatedData ? data : updatedData);
|
||||
|
||||
@@ -166,14 +77,52 @@ describe('<ContentTagsCollapsible />', () => {
|
||||
<ContentTagsCollapsibleComponent
|
||||
contentId={componentData.contentId}
|
||||
taxonomyAndTagsData={componentData.taxonomyAndTagsData}
|
||||
stagedContentTags={componentData.stagedContentTags}
|
||||
addStagedContentTag={componentData.addStagedContentTag}
|
||||
removeStagedContentTag={componentData.removeStagedContentTag}
|
||||
setStagedTags={componentData.setStagedTags}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
function setupTaxonomyMock() {
|
||||
useTaxonomyTagsData.mockReturnValue({
|
||||
hasMorePages: false,
|
||||
canAddTag: false,
|
||||
tagPages: {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: [{
|
||||
value: 'Tag 1',
|
||||
externalId: null,
|
||||
childCount: 0,
|
||||
depth: 0,
|
||||
parentValue: null,
|
||||
id: 12345,
|
||||
subTagsUrl: null,
|
||||
canChangeTag: false,
|
||||
canDeleteTag: false,
|
||||
}, {
|
||||
value: 'Tag 2',
|
||||
externalId: null,
|
||||
childCount: 0,
|
||||
depth: 0,
|
||||
parentValue: null,
|
||||
id: 12346,
|
||||
subTagsUrl: null,
|
||||
canChangeTag: false,
|
||||
canDeleteTag: false,
|
||||
}, {
|
||||
value: 'Tag 3',
|
||||
externalId: null,
|
||||
childCount: 0,
|
||||
depth: 0,
|
||||
parentValue: null,
|
||||
id: 12347,
|
||||
subTagsUrl: null,
|
||||
canChangeTag: false,
|
||||
canDeleteTag: false,
|
||||
}],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
it('should render taxonomy tags data along content tags number badge', async () => {
|
||||
const { container, getByText } = await getComponent();
|
||||
expect(getByText('Taxonomy 1')).toBeInTheDocument();
|
||||
@@ -181,153 +130,59 @@ describe('<ContentTagsCollapsible />', () => {
|
||||
expect(getByText('3')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call `addStagedContentTag` when tag checked in the dropdown', async () => {
|
||||
it('should render new tags as they are checked in the dropdown', async () => {
|
||||
setupTaxonomyMock();
|
||||
const { container, getByText, getAllByText } = await getComponent();
|
||||
|
||||
// Expand the Taxonomy to view applied tags and "Add a tag" button
|
||||
// Expand the Taxonomy to view applied tags and "Add tags" button
|
||||
const expandToggle = container.getElementsByClassName('collapsible-trigger')[0];
|
||||
fireEvent.click(expandToggle);
|
||||
|
||||
// Click on "Add tags" button to open dropdown to select new tags
|
||||
const addTagsButton = getByText(messages.addTagsButtonText.defaultMessage);
|
||||
fireEvent.click(addTagsButton);
|
||||
|
||||
// Wait for the dropdown selector for tags to open,
|
||||
// Tag 3 should only appear there
|
||||
expect(getByText('Tag 3')).toBeInTheDocument();
|
||||
expect(getAllByText('Tag 3').length === 1);
|
||||
|
||||
const tag3 = getByText('Tag 3');
|
||||
|
||||
fireEvent.click(tag3);
|
||||
|
||||
// After clicking on Tag 3, it should also appear in amongst
|
||||
// the tag bubbles in the tree
|
||||
expect(getAllByText('Tag 3').length === 2);
|
||||
});
|
||||
|
||||
it('should remove tag when they are unchecked in the dropdown', async () => {
|
||||
setupTaxonomyMock();
|
||||
const { container, getByText, getAllByText } = await getComponent();
|
||||
|
||||
// Expand the Taxonomy to view applied tags and "Add tags" button
|
||||
const expandToggle = container.getElementsByClassName('collapsible-trigger')[0];
|
||||
|
||||
fireEvent.click(expandToggle);
|
||||
|
||||
// Click on "Add a tag" button to open dropdown to select new tags
|
||||
const addTagsButton = getByText(messages.collapsibleAddTagsPlaceholderText.defaultMessage);
|
||||
// Use `mouseDown/mouseUp` instead of `click` since the react-select didn't respond to `click`
|
||||
fireEvent.mouseDown(addTagsButton);
|
||||
fireEvent.mouseUp(addTagsButton);
|
||||
// Check that Tag 2 appears in tag bubbles
|
||||
expect(getByText('Tag 2')).toBeInTheDocument();
|
||||
|
||||
// Click on "Add tags" button to open dropdown to select new tags
|
||||
const addTagsButton = getByText(messages.addTagsButtonText.defaultMessage);
|
||||
fireEvent.click(addTagsButton);
|
||||
|
||||
// Wait for the dropdown selector for tags to open,
|
||||
// Tag 3 should only appear there, (i.e. the dropdown is open, since Tag 3 is not applied)
|
||||
expect(getAllByText('Tag 3').length).toBe(1);
|
||||
expect(getByText('Tag 3')).toBeInTheDocument();
|
||||
|
||||
// Click to check Tag 3 and check the `addStagedContentTag` was called with the correct params
|
||||
const tag3 = getByText('Tag 3');
|
||||
fireEvent.click(tag3);
|
||||
// Get the Tag 2 checkbox and click on it
|
||||
const tag2 = getAllByText('Tag 2')[1];
|
||||
fireEvent.click(tag2);
|
||||
|
||||
const taxonomyId = 123;
|
||||
const addedStagedTag = {
|
||||
value: 'Tag%203',
|
||||
label: 'Tag 3',
|
||||
};
|
||||
expect(data.addStagedContentTag).toHaveBeenCalledTimes(1);
|
||||
expect(data.addStagedContentTag).toHaveBeenCalledWith(taxonomyId, addedStagedTag);
|
||||
});
|
||||
|
||||
it('should call `removeStagedContentTag` when tag staged tag unchecked in the dropdown', async () => {
|
||||
const { container, getByText, getAllByText } = await getComponent();
|
||||
|
||||
// Expand the Taxonomy to view applied tags and "Add a tag" button
|
||||
const expandToggle = container.getElementsByClassName('collapsible-trigger')[0];
|
||||
|
||||
fireEvent.click(expandToggle);
|
||||
|
||||
// Click on "Add a tag" button to open dropdown to select new tags
|
||||
const addTagsButton = getByText(messages.collapsibleAddTagsPlaceholderText.defaultMessage);
|
||||
// Use `mouseDown/mouseup` instead of `click` since the react-select didn't respond to `click`
|
||||
fireEvent.mouseDown(addTagsButton);
|
||||
fireEvent.mouseUp(addTagsButton);
|
||||
|
||||
// Wait for the dropdown selector for tags to open,
|
||||
// Tag 3 should only appear there, (i.e. the dropdown is open, since Tag 3 is not applied)
|
||||
expect(getAllByText('Tag 3').length).toBe(1);
|
||||
|
||||
// Click to check Tag 3
|
||||
const tag3 = getByText('Tag 3');
|
||||
fireEvent.click(tag3);
|
||||
|
||||
// Click to uncheck Tag 3 and check the `removeStagedContentTag` was called with the correct params
|
||||
fireEvent.click(tag3);
|
||||
const taxonomyId = 123;
|
||||
const tagValue = 'Tag%203';
|
||||
expect(data.removeStagedContentTag).toHaveBeenCalledTimes(1);
|
||||
expect(data.removeStagedContentTag).toHaveBeenCalledWith(taxonomyId, tagValue);
|
||||
});
|
||||
|
||||
it('should call `setStagedTags` to clear staged tags when clicking inline "Add" button', async () => {
|
||||
// Setup component to have staged tags
|
||||
const { container, getByText } = await getComponent({
|
||||
...data,
|
||||
stagedContentTags: [{
|
||||
value: 'Tag%203',
|
||||
label: 'Tag 3',
|
||||
}],
|
||||
});
|
||||
|
||||
// Expand the Taxonomy to view applied tags and staged tags
|
||||
const expandToggle = container.getElementsByClassName('collapsible-trigger')[0];
|
||||
|
||||
fireEvent.click(expandToggle);
|
||||
|
||||
// Click on inline "Add" button and check that the appropriate methods are called
|
||||
const inlineAdd = getByText(messages.collapsibleInlineAddStagedTagsButtonText.defaultMessage);
|
||||
fireEvent.click(inlineAdd);
|
||||
|
||||
// Check that `setStagedTags` called with empty tags list to clear staged tags
|
||||
const taxonomyId = 123;
|
||||
expect(data.setStagedTags).toHaveBeenCalledTimes(1);
|
||||
expect(data.setStagedTags).toHaveBeenCalledWith(taxonomyId, []);
|
||||
});
|
||||
|
||||
it('should call `setStagedTags` to clear staged tags when clicking "Add tags" button in dropdown', async () => {
|
||||
// Setup component to have staged tags
|
||||
const { container, getByText } = await getComponent({
|
||||
...data,
|
||||
stagedContentTags: [{
|
||||
value: 'Tag%203',
|
||||
label: 'Tag 3',
|
||||
}],
|
||||
});
|
||||
|
||||
// Expand the Taxonomy to view applied tags and staged tags
|
||||
const expandToggle = container.getElementsByClassName('collapsible-trigger')[0];
|
||||
|
||||
fireEvent.click(expandToggle);
|
||||
|
||||
// Click on dropdown with staged tags to expand it
|
||||
const selectTagsDropdown = container.getElementsByClassName('react-select-add-tags__control')[0];
|
||||
// Use `mouseDown` instead of `click` since the react-select didn't respond to `click`
|
||||
fireEvent.mouseDown(selectTagsDropdown);
|
||||
|
||||
// Click on "Add tags" button and check that the appropriate methods are called
|
||||
const dropdownAdd = getByText(messages.collapsibleAddStagedTagsButtonText.defaultMessage);
|
||||
fireEvent.click(dropdownAdd);
|
||||
|
||||
// Check that `setStagedTags` called with empty tags list to clear staged tags
|
||||
const taxonomyId = 123;
|
||||
expect(data.setStagedTags).toHaveBeenCalledTimes(1);
|
||||
expect(data.setStagedTags).toHaveBeenCalledWith(taxonomyId, []);
|
||||
});
|
||||
|
||||
it('should close dropdown and clear staged tags when clicking "Cancel" inside dropdown', async () => {
|
||||
// Setup component to have staged tags
|
||||
const { container, getByText } = await getComponent({
|
||||
...data,
|
||||
stagedContentTags: [{
|
||||
value: 'Tag%203',
|
||||
label: 'Tag 3',
|
||||
}],
|
||||
});
|
||||
|
||||
// Expand the Taxonomy to view applied tags and staged tags
|
||||
const expandToggle = container.getElementsByClassName('collapsible-trigger')[0];
|
||||
|
||||
fireEvent.click(expandToggle);
|
||||
|
||||
// Click on dropdown with staged tags to expand it
|
||||
const selectTagsDropdown = container.getElementsByClassName('react-select-add-tags__control')[0];
|
||||
// Use `mouseDown` instead of `click` since the react-select didn't respond to `click`
|
||||
fireEvent.mouseDown(selectTagsDropdown);
|
||||
|
||||
// Click on inline "Add" button and check that the appropriate methods are called
|
||||
const dropdownCancel = getByText(messages.collapsibleCancelStagedTagsButtonText.defaultMessage);
|
||||
fireEvent.click(dropdownCancel);
|
||||
|
||||
// Check that `setStagedTags` called with empty tags list to clear staged tags
|
||||
const taxonomyId = 123;
|
||||
expect(data.setStagedTags).toHaveBeenCalledTimes(1);
|
||||
expect(data.setStagedTags).toHaveBeenCalledWith(taxonomyId, []);
|
||||
|
||||
// Check that the dropdown is closed
|
||||
expect(dropdownCancel).not.toBeInTheDocument();
|
||||
// After clicking on Tag 2, it should be removed from
|
||||
// the tag bubbles in so only the one in the dropdown appears
|
||||
expect(getAllByText('Tag 2').length === 1);
|
||||
});
|
||||
|
||||
it('should handle search term change', async () => {
|
||||
@@ -335,17 +190,16 @@ describe('<ContentTagsCollapsible />', () => {
|
||||
container, getByText, getByRole, getByDisplayValue,
|
||||
} = await getComponent();
|
||||
|
||||
// Expand the Taxonomy to view applied tags and "Add a tag" button
|
||||
// Expand the Taxonomy to view applied tags and "Add tags" button
|
||||
const expandToggle = container.getElementsByClassName('collapsible-trigger')[0];
|
||||
fireEvent.click(expandToggle);
|
||||
|
||||
// Click on "Add a tag" button to open dropdown
|
||||
const addTagsButton = getByText(messages.collapsibleAddTagsPlaceholderText.defaultMessage);
|
||||
// Use `mouseDown` instead of `click` since the react-select didn't respond to click
|
||||
fireEvent.mouseDown(addTagsButton);
|
||||
// Click on "Add tags" button to open dropdown
|
||||
const addTagsButton = getByText(messages.addTagsButtonText.defaultMessage);
|
||||
fireEvent.click(addTagsButton);
|
||||
|
||||
// Get the search field
|
||||
const searchField = getByRole('combobox');
|
||||
const searchField = getByRole('searchbox');
|
||||
|
||||
const searchTerm = 'memo';
|
||||
|
||||
@@ -369,17 +223,17 @@ describe('<ContentTagsCollapsible />', () => {
|
||||
});
|
||||
|
||||
it('should close dropdown selector when clicking away', async () => {
|
||||
setupTaxonomyMock();
|
||||
const { container, getByText, queryByText } = await getComponent();
|
||||
|
||||
// Expand the Taxonomy to view applied tags and "Add a tag" button
|
||||
// Expand the Taxonomy to view applied tags and "Add tags" button
|
||||
const expandToggle = container.getElementsByClassName('collapsible-trigger')[0];
|
||||
|
||||
fireEvent.click(expandToggle);
|
||||
|
||||
// Click on "Add a tag" button to open dropdown
|
||||
const addTagsButton = getByText(messages.collapsibleAddTagsPlaceholderText.defaultMessage);
|
||||
// Use `mouseDown` instead of `click` since the react-select didn't respond to `click`
|
||||
fireEvent.mouseDown(addTagsButton);
|
||||
// Click on "Add tags" button to open dropdown
|
||||
const addTagsButton = getByText(messages.addTagsButtonText.defaultMessage);
|
||||
fireEvent.click(addTagsButton);
|
||||
|
||||
// Wait for the dropdown selector for tags to open, Tag 3 should appear
|
||||
// since it is not applied
|
||||
@@ -396,156 +250,6 @@ describe('<ContentTagsCollapsible />', () => {
|
||||
expect(queryByText('Tag 3')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should test keyboard navigation of add tags widget', async () => {
|
||||
const {
|
||||
container,
|
||||
getByText,
|
||||
queryByText,
|
||||
queryAllByText,
|
||||
} = await getComponent();
|
||||
|
||||
// Expand the Taxonomy to view applied tags and "Add a tag" button
|
||||
const expandToggle = container.getElementsByClassName('collapsible-trigger')[0];
|
||||
|
||||
fireEvent.click(expandToggle);
|
||||
|
||||
// Click on "Add a tag" button to open dropdown
|
||||
const addTagsButton = getByText(messages.collapsibleAddTagsPlaceholderText.defaultMessage);
|
||||
// Use `mouseDown` instead of `click` since the react-select didn't respond to `click`
|
||||
fireEvent.mouseDown(addTagsButton);
|
||||
|
||||
// Wait for the dropdown selector for tags to open, Tag 3 should appear
|
||||
// since it is not applied
|
||||
expect(queryByText('Tag 3')).toBeInTheDocument();
|
||||
|
||||
/*
|
||||
The dropdown data looks like the following:
|
||||
|
||||
│Tag 1
|
||||
│ │
|
||||
│ ├─ Tag 1.1
|
||||
│ │
|
||||
│ │
|
||||
│ └─ Tag 1.2
|
||||
│
|
||||
│Tag 2
|
||||
│
|
||||
│
|
||||
│Tag 3
|
||||
|
||||
*/
|
||||
|
||||
// Press tab to focus on first element in dropdown, Tag 1 should be focused
|
||||
userEvent.tab();
|
||||
const dropdownTag1Div = queryAllByText('Tag 1')[1].closest('.dropdown-selector-tag-actions');
|
||||
expect(dropdownTag1Div).toHaveFocus();
|
||||
|
||||
// Press right arrow to expand Tag 1, Tag 1.1 & Tag 1.2 should now be visible
|
||||
userEvent.keyboard('{arrowright}');
|
||||
expect(queryAllByText('Tag 1.1').length).toBe(2);
|
||||
expect(queryByText('Tag 1.2')).toBeInTheDocument();
|
||||
|
||||
// Press left arrow to collapse Tag 1, Tag 1.1 & Tag 1.2 should not be visible
|
||||
userEvent.keyboard('{arrowleft}');
|
||||
expect(queryAllByText('Tag 1.1').length).toBe(1);
|
||||
expect(queryByText('Tag 1.2')).not.toBeInTheDocument();
|
||||
|
||||
// Press enter key to expand Tag 1, Tag 1.1 & Tag 1.2 should now be visible
|
||||
userEvent.keyboard('{enter}');
|
||||
expect(queryAllByText('Tag 1.1').length).toBe(2);
|
||||
expect(queryByText('Tag 1.2')).toBeInTheDocument();
|
||||
|
||||
// Press down arrow to navigate to Tag 1.1, it should be focused
|
||||
userEvent.keyboard('{arrowdown}');
|
||||
const dropdownTag1pt1Div = queryAllByText('Tag 1.1')[1].closest('.dropdown-selector-tag-actions');
|
||||
expect(dropdownTag1pt1Div).toHaveFocus();
|
||||
|
||||
// Press down arrow again to navigate to Tag 1.2, it should be fouced
|
||||
userEvent.keyboard('{arrowdown}');
|
||||
const dropdownTag1pt2Div = queryAllByText('Tag 1.2')[0].closest('.dropdown-selector-tag-actions');
|
||||
expect(dropdownTag1pt2Div).toHaveFocus();
|
||||
|
||||
// Press down arrow again to navigate to Tag 2, it should be fouced
|
||||
userEvent.keyboard('{arrowdown}');
|
||||
const dropdownTag2Div = queryAllByText('Tag 2')[1].closest('.dropdown-selector-tag-actions');
|
||||
expect(dropdownTag2Div).toHaveFocus();
|
||||
|
||||
// Press up arrow to navigate back to Tag 1.2, it should be focused
|
||||
userEvent.keyboard('{arrowup}');
|
||||
expect(dropdownTag1pt2Div).toHaveFocus();
|
||||
|
||||
// Press up arrow to navigate back to Tag 1.1, it should be focused
|
||||
userEvent.keyboard('{arrowup}');
|
||||
expect(dropdownTag1pt1Div).toHaveFocus();
|
||||
|
||||
// Press up arrow again to navigate to Tag 1, it should be focused
|
||||
userEvent.keyboard('{arrowup}');
|
||||
expect(dropdownTag1Div).toHaveFocus();
|
||||
|
||||
// Press down arrow twice to navigate to Tag 1.2, it should be focsed
|
||||
userEvent.keyboard('{arrowdown}');
|
||||
userEvent.keyboard('{arrowdown}');
|
||||
expect(dropdownTag1pt2Div).toHaveFocus();
|
||||
|
||||
// Press space key to check Tag 1.2, it should be staged
|
||||
userEvent.keyboard('{space}');
|
||||
const taxonomyId = 123;
|
||||
const addedStagedTag = {
|
||||
value: 'Tag%201,Tag%201.2',
|
||||
label: 'Tag 1.2',
|
||||
};
|
||||
expect(data.addStagedContentTag).toHaveBeenCalledWith(taxonomyId, addedStagedTag);
|
||||
|
||||
// Press enter key again to uncheck Tag 1.2 (since it's a leaf), it should be unstaged
|
||||
userEvent.keyboard('{enter}');
|
||||
const tagValue = 'Tag%201,Tag%201.2';
|
||||
expect(data.removeStagedContentTag).toHaveBeenCalledWith(taxonomyId, tagValue);
|
||||
|
||||
// Press left arrow to navigate back to Tag 1, it should be focused
|
||||
userEvent.keyboard('{arrowleft}');
|
||||
expect(dropdownTag1Div).toHaveFocus();
|
||||
|
||||
// Press tab key it should jump to cancel button, it should be focused
|
||||
userEvent.tab();
|
||||
const dropdownCancel = getByText(messages.collapsibleCancelStagedTagsButtonText.defaultMessage);
|
||||
expect(dropdownCancel).toHaveFocus();
|
||||
|
||||
// Press tab again, it should exit and close the select menu, since there are not staged tags
|
||||
userEvent.tab();
|
||||
expect(queryByText('Tag 3')).not.toBeInTheDocument();
|
||||
|
||||
// Press shift tab, focus back on select menu input, it should open the menu
|
||||
userEvent.tab({ shift: true });
|
||||
expect(queryByText('Tag 3')).toBeInTheDocument();
|
||||
|
||||
// Press shift tab again, it should focus out and close the select menu
|
||||
userEvent.tab({ shift: true });
|
||||
expect(queryByText('Tag 3')).not.toBeInTheDocument();
|
||||
|
||||
// Press tab again, the select menu should open, then press escape, it should close
|
||||
userEvent.tab();
|
||||
expect(queryByText('Tag 3')).toBeInTheDocument();
|
||||
userEvent.keyboard('{escape}');
|
||||
expect(queryByText('Tag 3')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should remove applied tags when clicking on `x` of tag bubble', async () => {
|
||||
const { container, getByText } = await getComponent();
|
||||
|
||||
// Expand the Taxonomy to view applied tags
|
||||
const expandToggle = container.getElementsByClassName('collapsible-trigger')[0];
|
||||
|
||||
fireEvent.click(expandToggle);
|
||||
|
||||
// Click on 'x' of applied tag to remove it
|
||||
const appliedTag = getByText('Tag 2');
|
||||
const xButtonAppliedTag = appliedTag.nextSibling;
|
||||
xButtonAppliedTag.click();
|
||||
|
||||
// Check that the applied tag has been removed
|
||||
expect(appliedTag).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render taxonomy tags data without tags number badge', async () => {
|
||||
const updatedData = { ...data };
|
||||
updatedData.taxonomyAndTagsData = { ...updatedData.taxonomyAndTagsData };
|
||||
|
||||
@@ -1,89 +1,84 @@
|
||||
// @ts-check
|
||||
import React from 'react';
|
||||
import { useCheckboxSetValues } from '@openedx/paragon';
|
||||
import { useCheckboxSetValues } from '@edx/paragon';
|
||||
import { cloneDeep } from 'lodash';
|
||||
|
||||
import { useContentTaxonomyTagsUpdater } from './data/apiHooks';
|
||||
|
||||
/** @typedef {import("../taxonomy/data/types.mjs").TaxonomyData} TaxonomyData */
|
||||
/** @typedef {import("./data/types.mjs").Tag} ContentTagData */
|
||||
/** @typedef {import("./ContentTagsCollapsible").TagTreeEntry} TagTreeEntry */
|
||||
|
||||
/**
|
||||
* Util function that sorts the keys of a tree in alphabetical order.
|
||||
* Util function that consolidates two tag trees into one, sorting the keys in
|
||||
* alphabetical order.
|
||||
*
|
||||
* @param {object} tree - tree that needs it's keys sorted
|
||||
* @returns {object} sorted tree
|
||||
* @param {object} tree1 - first tag tree
|
||||
* @param {object} tree2 - second tag tree
|
||||
* @returns {object} merged tree containing both tree1 and tree2
|
||||
*/
|
||||
const sortKeysAlphabetically = (tree) => {
|
||||
const sortedObj = {};
|
||||
Object.keys(tree)
|
||||
.sort()
|
||||
.forEach((key) => {
|
||||
sortedObj[key] = tree[key];
|
||||
if (tree[key] && typeof tree[key] === 'object') {
|
||||
sortedObj[key].children = sortKeysAlphabetically(tree[key].children);
|
||||
}
|
||||
});
|
||||
return sortedObj;
|
||||
};
|
||||
const mergeTrees = (tree1, tree2) => {
|
||||
const mergedTree = cloneDeep(tree1);
|
||||
|
||||
/**
|
||||
* Util function that returns the leafs of a tree. Mainly used to extract the explicit
|
||||
* tags selected in the staged tags tree
|
||||
*
|
||||
* @param {object} tree - tree to extract the leaf tags from
|
||||
* @returns {Array<string>} array of leaf (explicit) tags of provided tree
|
||||
*/
|
||||
const getLeafTags = (tree) => {
|
||||
const leafKeys = [];
|
||||
const sortKeysAlphabetically = (obj) => {
|
||||
const sortedObj = {};
|
||||
Object.keys(obj)
|
||||
.sort()
|
||||
.forEach((key) => {
|
||||
sortedObj[key] = obj[key];
|
||||
if (obj[key] && typeof obj[key] === 'object') {
|
||||
sortedObj[key].children = sortKeysAlphabetically(obj[key].children);
|
||||
}
|
||||
});
|
||||
return sortedObj;
|
||||
};
|
||||
|
||||
function traverse(node) {
|
||||
Object.keys(node).forEach(key => {
|
||||
const child = node[key];
|
||||
if (Object.keys(child.children).length === 0) {
|
||||
leafKeys.push(key);
|
||||
const mergeRecursively = (destination, source) => {
|
||||
Object.entries(source).forEach(([key, sourceValue]) => {
|
||||
const destinationValue = destination[key];
|
||||
|
||||
if (destinationValue && sourceValue && typeof destinationValue === 'object' && typeof sourceValue === 'object') {
|
||||
mergeRecursively(destinationValue, sourceValue);
|
||||
} else {
|
||||
traverse(child.children);
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
destination[key] = cloneDeep(sourceValue);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
traverse(tree);
|
||||
return leafKeys;
|
||||
mergeRecursively(mergedTree, tree2);
|
||||
return sortKeysAlphabetically(mergedTree);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles all the underlying logic for the ContentTagsCollapsible component
|
||||
* @param {string} contentId The ID of the content we're tagging (e.g. usage key)
|
||||
* @param {TaxonomyData & {contentTags: ContentTagData[]}} taxonomyAndTagsData
|
||||
* @param {(taxonomyId: number, tag: {value: string, label: string}) => void} addStagedContentTag
|
||||
* @param {(taxonomyId: number, tagValue: string) => void} removeStagedContentTag
|
||||
* @param {{value: string, label: string}[]} stagedContentTags
|
||||
* @returns {{
|
||||
* tagChangeHandler: (tagSelectableBoxValue: string, checked: boolean) => void,
|
||||
* removeAppliedTagHandler: (tagSelectableBoxValue: string) => void,
|
||||
* appliedContentTagsTree: Record<string, TagTreeEntry>,
|
||||
* stagedContentTagsTree: Record<string, TagTreeEntry>,
|
||||
* contentTagsCount: number,
|
||||
* checkedTags: any,
|
||||
* commitStagedTags: () => void,
|
||||
* updateTags: import('@tanstack/react-query').UseMutationResult<any, unknown, { tags: string[]; }, unknown>
|
||||
* }}
|
||||
* Util function that removes the tag along with its ancestors if it was
|
||||
* the only explicit child tag.
|
||||
*
|
||||
* @param {object} tree - tag tree to remove the tag from
|
||||
* @param {string[]} tagsToRemove - full lineage of tag to remove.
|
||||
* eg: ['grand parent', 'parent', 'tag']
|
||||
*/
|
||||
const useContentTagsCollapsibleHelper = (
|
||||
contentId,
|
||||
taxonomyAndTagsData,
|
||||
addStagedContentTag,
|
||||
removeStagedContentTag,
|
||||
stagedContentTags,
|
||||
) => {
|
||||
const removeTags = (tree, tagsToRemove) => {
|
||||
if (!tree || !tagsToRemove.length) {
|
||||
return;
|
||||
}
|
||||
const key = tagsToRemove[0];
|
||||
if (tree[key]) {
|
||||
removeTags(tree[key].children, tagsToRemove.slice(1));
|
||||
|
||||
if (Object.keys(tree[key].children).length === 0 && (tree[key].explicit === false || tagsToRemove.length === 1)) {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
delete tree[key];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
* Handles all the underlying logic for the ContentTagsCollapsible component
|
||||
*/
|
||||
const useContentTagsCollapsibleHelper = (contentId, taxonomyAndTagsData) => {
|
||||
const {
|
||||
id, contentTags, canTagObject,
|
||||
} = taxonomyAndTagsData;
|
||||
// State to determine whether an applied tag was removed so we make a call
|
||||
// State to determine whether the tags are being updating so we can make a call
|
||||
// to the update endpoint to the reflect those changes
|
||||
const [removingAppliedTag, setRemoveAppliedTag] = React.useState(false);
|
||||
const [updatingTags, setUpdatingTags] = React.useState(false);
|
||||
const updateTags = useContentTaxonomyTagsUpdater(contentId, id);
|
||||
|
||||
// Keeps track of the content objects tags count (both implicit and explicit)
|
||||
@@ -91,55 +86,32 @@ const useContentTagsCollapsibleHelper = (
|
||||
|
||||
// Keeps track of the tree structure for tags that are add by selecting/unselecting
|
||||
// tags in the dropdowns.
|
||||
const [stagedContentTagsTree, setStagedContentTagsTree] = React.useState({});
|
||||
const [addedContentTags, setAddedContentTags] = React.useState({});
|
||||
|
||||
// To handle checking/unchecking tags in the SelectableBox
|
||||
const [checkedTags, { add, remove }] = useCheckboxSetValues();
|
||||
const [checkedTags, { add, remove, clear }] = useCheckboxSetValues();
|
||||
|
||||
// State to keep track of the staged tags (and along with ancestors) that should be removed
|
||||
const [stagedTagsToRemove, setStagedTagsToRemove] = React.useState(/** @type string[] */([]));
|
||||
|
||||
// Handles making requests to the backend when applied tags are removed
|
||||
// Handles making requests to the update endpoint whenever the checked tags change
|
||||
React.useEffect(() => {
|
||||
// We have this check because this hook is fired when the component first loads
|
||||
// and reloads (on refocus). We only want to make a request to the update endpoint when
|
||||
// the user removes an applied tag
|
||||
if (removingAppliedTag) {
|
||||
setRemoveAppliedTag(false);
|
||||
|
||||
// Filter out staged tags from the checktags so they do not get committed
|
||||
// the user is updating the tags.
|
||||
if (updatingTags) {
|
||||
setUpdatingTags(false);
|
||||
const tags = checkedTags.map(t => decodeURIComponent(t.split(',').slice(-1)));
|
||||
const staged = stagedContentTags.map(t => t.label);
|
||||
const remainingAppliedTags = tags.filter(t => !staged.includes(t));
|
||||
|
||||
updateTags.mutate({ tags: remainingAppliedTags });
|
||||
updateTags.mutate({ tags });
|
||||
}
|
||||
}, [contentId, id, canTagObject, checkedTags, stagedContentTags]);
|
||||
|
||||
// Handles the removal of staged content tags based on what was removed
|
||||
// from the staged tags tree. We are doing it in a useEffect since the removeTag
|
||||
// method is being called inside a setState of the parent component, which
|
||||
// was causing warnings
|
||||
React.useEffect(() => {
|
||||
stagedTagsToRemove.forEach(tag => removeStagedContentTag(id, tag));
|
||||
}, [stagedTagsToRemove, removeStagedContentTag, id]);
|
||||
|
||||
// Handles making requests to the update endpoint when the staged tags need to be committed
|
||||
const commitStagedTags = React.useCallback(() => {
|
||||
// Filter out only leaf nodes of staging tree to commit
|
||||
const explicitStaged = getLeafTags(stagedContentTagsTree);
|
||||
|
||||
// Filter out applied tags that should become implicit because a child tag was committed
|
||||
const stagedLineages = stagedContentTags.map(st => decodeURIComponent(st.value).split(',').slice(0, -1)).flat();
|
||||
const applied = contentTags.map((t) => t.value).filter(t => !stagedLineages.includes(t));
|
||||
|
||||
updateTags.mutate({ tags: [...applied, ...explicitStaged] });
|
||||
}, [contentTags, stagedContentTags, stagedContentTagsTree, updateTags]);
|
||||
}, [contentId, id, canTagObject, checkedTags]);
|
||||
|
||||
// This converts the contentTags prop to the tree structure mentioned above
|
||||
const appliedContentTagsTree = React.useMemo(() => {
|
||||
const appliedContentTags = React.useMemo(() => {
|
||||
let contentTagsCounter = 0;
|
||||
|
||||
// Clear all the tags that have not been commited and the checked boxes when
|
||||
// fresh contentTags passed in so the latest state from the backend is rendered
|
||||
setAddedContentTags({});
|
||||
clear();
|
||||
|
||||
// When an error occurs while updating, the contentTags query is invalidated,
|
||||
// hence they will be recalculated, and the updateTags mutation should be reset.
|
||||
if (updateTags.isError) {
|
||||
@@ -162,12 +134,8 @@ const useContentTagsCollapsibleHelper = (
|
||||
|
||||
// Populating the SelectableBox with "selected" (explicit) tags
|
||||
const value = item.lineage.map(l => encodeURIComponent(l)).join(',');
|
||||
// Clear all the existing applied tags
|
||||
remove(value);
|
||||
// Add only the explicitly applied tags
|
||||
if (isExplicit) {
|
||||
add(value);
|
||||
}
|
||||
// eslint-disable-next-line no-unused-expressions
|
||||
isExplicit ? add(value) : remove(value);
|
||||
contentTagsCounter += 1;
|
||||
}
|
||||
|
||||
@@ -179,53 +147,13 @@ const useContentTagsCollapsibleHelper = (
|
||||
return resultTree;
|
||||
}, [contentTags, updateTags.isError]);
|
||||
|
||||
/**
|
||||
* Util function that removes the tag along with its ancestors if it was
|
||||
* the only explicit child tag. It returns a list of staged tags (and ancestors) that
|
||||
* were unstaged and should be removed
|
||||
*
|
||||
* @param {object} tree - tag tree to remove the tag from
|
||||
* @param {string[]} tagsToRemove - remaining lineage of tag to remove at each recursive level.
|
||||
* eg: ['grand parent', 'parent', 'tag']
|
||||
* @param {boolean} staged - whether we are removing staged tags or not
|
||||
* @param {string[]} fullLineage - Full lineage of tag being removed
|
||||
* @returns {string[]} array of staged tag values (with ancestors) that should be removed from staged tree
|
||||
*
|
||||
*/
|
||||
const removeTags = React.useCallback((tree, tagsToRemove, staged, fullLineage) => {
|
||||
const removedTags = [];
|
||||
|
||||
const traverseAndRemoveTags = (subTree, innerTagsToRemove) => {
|
||||
if (!subTree || !innerTagsToRemove.length) {
|
||||
return;
|
||||
}
|
||||
const key = innerTagsToRemove[0];
|
||||
if (subTree[key]) {
|
||||
traverseAndRemoveTags(subTree[key].children, innerTagsToRemove.slice(1));
|
||||
|
||||
if (
|
||||
Object.keys(subTree[key].children).length === 0
|
||||
&& (subTree[key].explicit === false || innerTagsToRemove.length === 1)
|
||||
) {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
delete subTree[key];
|
||||
|
||||
// Remove tags (including ancestors) from staged tags select menu
|
||||
if (staged) {
|
||||
// Build value from lineage by traversing beginning till key, then encoding them
|
||||
const toRemove = fullLineage.slice(0, fullLineage.indexOf(key) + 1).map(item => encodeURIComponent(item));
|
||||
if (toRemove.length > 0) {
|
||||
removedTags.push(toRemove.join(','));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
traverseAndRemoveTags(tree, tagsToRemove);
|
||||
|
||||
return removedTags;
|
||||
}, []);
|
||||
// This is the source of truth that represents the current state of tags in
|
||||
// this Taxonomy as a tree. Whenever either the `appliedContentTags` (i.e. tags passed in
|
||||
// the prop from the backed) change, or when the `addedContentTags` (i.e. tags added by
|
||||
// selecting/unselecting them in the dropdown) change, the tree is recomputed.
|
||||
const tagsTree = React.useMemo(() => (
|
||||
mergeTrees(appliedContentTags, addedContentTags)
|
||||
), [appliedContentTags, addedContentTags]);
|
||||
|
||||
// Add tag to the tree, and while traversing remove any selected ancestor tags
|
||||
// as they should become implicit
|
||||
@@ -235,10 +163,6 @@ const useContentTagsCollapsibleHelper = (
|
||||
tagLineage.forEach(tag => {
|
||||
const isExplicit = selectedTag === tag;
|
||||
|
||||
// Clear out the ancestor tags leading to newly selected tag
|
||||
// as they automatically become implicit
|
||||
value.push(encodeURIComponent(tag));
|
||||
|
||||
if (!traversal[tag]) {
|
||||
traversal[tag] = {
|
||||
explicit: isExplicit,
|
||||
@@ -250,8 +174,12 @@ const useContentTagsCollapsibleHelper = (
|
||||
traversal[tag].explicit = isExplicit;
|
||||
}
|
||||
|
||||
// Clear out the ancestor tags leading to newly selected tag
|
||||
// as they automatically become implicit
|
||||
value.push(encodeURIComponent(tag));
|
||||
// eslint-disable-next-line no-unused-expressions
|
||||
isExplicit ? add(value.join(',')) : remove(value.join(','));
|
||||
|
||||
traversal = traversal[tag].children;
|
||||
});
|
||||
};
|
||||
@@ -260,62 +188,26 @@ const useContentTagsCollapsibleHelper = (
|
||||
const tagLineage = tagSelectableBoxValue.split(',').map(t => decodeURIComponent(t));
|
||||
const selectedTag = tagLineage.slice(-1)[0];
|
||||
|
||||
const addedTree = { ...addedContentTags };
|
||||
if (checked) {
|
||||
const stagedTree = cloneDeep(stagedContentTagsTree);
|
||||
// We "add" the tag to the SelectableBox.Set inside the addTags method
|
||||
addTags(stagedTree, tagLineage, selectedTag);
|
||||
|
||||
// Update the staged content tags tree
|
||||
setStagedContentTagsTree(stagedTree);
|
||||
|
||||
// Add content tag to taxonomy's staged tags select menu
|
||||
addStagedContentTag(
|
||||
id,
|
||||
{
|
||||
value: tagSelectableBoxValue,
|
||||
label: selectedTag,
|
||||
},
|
||||
);
|
||||
addTags(addedTree, tagLineage, selectedTag);
|
||||
} else {
|
||||
// Remove tag from the SelectableBox.Set
|
||||
remove(tagSelectableBoxValue);
|
||||
|
||||
// Remove tag along with it's from ancestors if it's the only child tag
|
||||
// from the staged tags tree and update the staged content tags tree
|
||||
setStagedContentTagsTree(prevStagedContentTagsTree => {
|
||||
const updatedStagedContentTagsTree = cloneDeep(prevStagedContentTagsTree);
|
||||
const tagsToRemove = removeTags(updatedStagedContentTagsTree, tagLineage, true, tagLineage);
|
||||
setStagedTagsToRemove(tagsToRemove);
|
||||
return updatedStagedContentTagsTree;
|
||||
});
|
||||
// We remove them from both incase we are unselecting from an
|
||||
// existing applied Tag or a newly added one
|
||||
removeTags(addedTree, tagLineage);
|
||||
removeTags(appliedContentTags, tagLineage);
|
||||
}
|
||||
}, [
|
||||
stagedContentTagsTree, setStagedContentTagsTree, addTags, removeTags,
|
||||
id, addStagedContentTag, removeStagedContentTag,
|
||||
]);
|
||||
|
||||
const removeAppliedTagHandler = React.useCallback((tagSelectableBoxValue) => {
|
||||
const tagLineage = tagSelectableBoxValue.split(',').map(t => decodeURIComponent(t));
|
||||
|
||||
// Remove tag from the SelectableBox.Set
|
||||
remove(tagSelectableBoxValue);
|
||||
|
||||
// Remove tags from applied tags
|
||||
const tagsToRemove = removeTags(appliedContentTagsTree, tagLineage, false, tagLineage);
|
||||
setStagedTagsToRemove(tagsToRemove);
|
||||
|
||||
setRemoveAppliedTag(true);
|
||||
}, [appliedContentTagsTree, id, removeStagedContentTag]);
|
||||
setAddedContentTags(addedTree);
|
||||
setUpdatingTags(true);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
tagChangeHandler,
|
||||
removeAppliedTagHandler,
|
||||
appliedContentTagsTree: sortKeysAlphabetically(appliedContentTagsTree),
|
||||
stagedContentTagsTree: sortKeysAlphabetically(stagedContentTagsTree),
|
||||
contentTagsCount,
|
||||
checkedTags,
|
||||
commitStagedTags,
|
||||
updateTags,
|
||||
tagChangeHandler, tagsTree, contentTagsCount, checkedTags,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
// @ts-check
|
||||
import React, {
|
||||
useMemo,
|
||||
useEffect,
|
||||
useState,
|
||||
useCallback,
|
||||
} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { useMemo, useEffect } from 'react';
|
||||
import {
|
||||
Container,
|
||||
CloseButton,
|
||||
Spinner,
|
||||
} from '@openedx/paragon';
|
||||
} from '@edx/paragon';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import messages from './messages';
|
||||
@@ -20,85 +14,42 @@ import {
|
||||
useContentTaxonomyTagsData,
|
||||
useContentData,
|
||||
} from './data/apiHooks';
|
||||
import { useTaxonomyList } from '../taxonomy/data/apiHooks';
|
||||
import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded } from '../taxonomy/data/apiHooks';
|
||||
import Loading from '../generic/Loading';
|
||||
|
||||
/** @typedef {import("../taxonomy/data/types.mjs").TaxonomyData} TaxonomyData */
|
||||
/** @typedef {import("./data/types.mjs").Tag} ContentTagData */
|
||||
|
||||
/**
|
||||
* Drawer with the functionality to show and manage tags in a certain content.
|
||||
* It is used both in interfaces of this MFE and in edx-platform interfaces such as iframe.
|
||||
* - If you want to use it as an iframe, the component obtains the `contentId` from the url parameters.
|
||||
* Functions to close the drawer are handled internally.
|
||||
* TODO: We can delete this method when is no longer used on edx-platform.
|
||||
* - If you want to use it as react component, you need to pass the content id and the close functions
|
||||
* through the component parameters.
|
||||
*/
|
||||
const ContentTagsDrawer = ({ id, onClose }) => {
|
||||
const ContentTagsDrawer = () => {
|
||||
const intl = useIntl();
|
||||
// TODO: We can delete 'params' when the iframe is no longer used on edx-platform
|
||||
const params = useParams();
|
||||
const contentId = id ?? params.contentId;
|
||||
const { contentId } = /** @type {{contentId: string}} */(useParams());
|
||||
|
||||
const org = extractOrgFromContentId(contentId);
|
||||
|
||||
const [stagedContentTags, setStagedContentTags] = useState({});
|
||||
|
||||
// Add a content tags to the staged tags for a taxonomy
|
||||
const addStagedContentTag = useCallback((taxonomyId, addedTag) => {
|
||||
setStagedContentTags(prevStagedContentTags => {
|
||||
const updatedStagedContentTags = {
|
||||
...prevStagedContentTags,
|
||||
[taxonomyId]: [...(prevStagedContentTags[taxonomyId] ?? []), addedTag],
|
||||
};
|
||||
return updatedStagedContentTags;
|
||||
});
|
||||
}, [setStagedContentTags]);
|
||||
|
||||
// Remove a content tag from the staged tags for a taxonomy
|
||||
const removeStagedContentTag = useCallback((taxonomyId, tagValue) => {
|
||||
setStagedContentTags(prevStagedContentTags => ({
|
||||
...prevStagedContentTags,
|
||||
[taxonomyId]: prevStagedContentTags[taxonomyId].filter((t) => t.value !== tagValue),
|
||||
}));
|
||||
}, [setStagedContentTags]);
|
||||
|
||||
// Sets the staged content tags for taxonomy to the provided list of tags
|
||||
const setStagedTags = useCallback((taxonomyId, tagsList) => {
|
||||
setStagedContentTags(prevStagedContentTags => ({ ...prevStagedContentTags, [taxonomyId]: tagsList }));
|
||||
}, [setStagedContentTags]);
|
||||
const useTaxonomyListData = () => {
|
||||
const taxonomyListData = useTaxonomyListDataResponse(org);
|
||||
const isTaxonomyListLoaded = useIsTaxonomyListDataLoaded(org);
|
||||
return { taxonomyListData, isTaxonomyListLoaded };
|
||||
};
|
||||
|
||||
const { data: contentData, isSuccess: isContentDataLoaded } = useContentData(contentId);
|
||||
const {
|
||||
data: contentTaxonomyTagsData,
|
||||
isSuccess: isContentTaxonomyTagsLoaded,
|
||||
} = useContentTaxonomyTagsData(contentId);
|
||||
const { data: taxonomyListData, isSuccess: isTaxonomyListLoaded } = useTaxonomyList(org);
|
||||
const { taxonomyListData, isTaxonomyListLoaded } = useTaxonomyListData();
|
||||
|
||||
let contentName = '';
|
||||
if (isContentDataLoaded) {
|
||||
if ('displayName' in contentData) {
|
||||
contentName = contentData.displayName;
|
||||
} else {
|
||||
contentName = contentData.courseDisplayNameWithDefault;
|
||||
}
|
||||
}
|
||||
|
||||
let onCloseDrawer = onClose;
|
||||
if (onCloseDrawer === undefined) {
|
||||
onCloseDrawer = () => {
|
||||
// "*" allows communication with any origin
|
||||
window.parent.postMessage('closeManageTagsDrawer', '*');
|
||||
};
|
||||
}
|
||||
const closeContentTagsDrawer = () => {
|
||||
// "*" allows communication with any origin
|
||||
window.parent.postMessage('closeManageTagsDrawer', '*');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleEsc = (event) => {
|
||||
/* Close drawer when ESC-key is pressed and selectable dropdown box not open */
|
||||
const selectableBoxOpen = document.querySelector('[data-selectable-box="taxonomy-tags"]');
|
||||
if (event.key === 'Escape' && !selectableBoxOpen) {
|
||||
onCloseDrawer();
|
||||
closeContentTagsDrawer();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleEsc);
|
||||
@@ -135,10 +86,10 @@ const ContentTagsDrawer = ({ id, onClose }) => {
|
||||
|
||||
<div className="mt-1">
|
||||
<Container size="xl">
|
||||
<CloseButton onClick={() => onCloseDrawer()} data-testid="drawer-close-button" />
|
||||
<CloseButton onClick={() => closeContentTagsDrawer()} data-testid="drawer-close-button" />
|
||||
<span>{intl.formatMessage(messages.headerSubtitle)}</span>
|
||||
{ isContentDataLoaded
|
||||
? <h3>{ contentName }</h3>
|
||||
? <h3>{ contentData.displayName }</h3>
|
||||
: (
|
||||
<div className="d-flex justify-content-center align-items-center flex-column">
|
||||
<Spinner
|
||||
@@ -154,14 +105,7 @@ const ContentTagsDrawer = ({ id, onClose }) => {
|
||||
{ isTaxonomyListLoaded && isContentTaxonomyTagsLoaded
|
||||
? taxonomies.map((data) => (
|
||||
<div key={`taxonomy-tags-collapsible-${data.id}`}>
|
||||
<ContentTagsCollapsible
|
||||
contentId={contentId}
|
||||
taxonomyAndTagsData={data}
|
||||
stagedContentTags={stagedContentTags[data.id] || []}
|
||||
addStagedContentTag={addStagedContentTag}
|
||||
removeStagedContentTag={removeStagedContentTag}
|
||||
setStagedTags={setStagedTags}
|
||||
/>
|
||||
<ContentTagsCollapsible contentId={contentId} taxonomyAndTagsData={data} />
|
||||
<hr />
|
||||
</div>
|
||||
))
|
||||
@@ -172,14 +116,4 @@ const ContentTagsDrawer = ({ id, onClose }) => {
|
||||
);
|
||||
};
|
||||
|
||||
ContentTagsDrawer.propTypes = {
|
||||
id: PropTypes.string,
|
||||
onClose: PropTypes.func,
|
||||
};
|
||||
|
||||
ContentTagsDrawer.defaultProps = {
|
||||
id: undefined,
|
||||
onClose: undefined,
|
||||
};
|
||||
|
||||
export default ContentTagsDrawer;
|
||||
|
||||
@@ -1,34 +1,21 @@
|
||||
import React from 'react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
waitFor,
|
||||
screen,
|
||||
} from '@testing-library/react';
|
||||
import { act, render, fireEvent } from '@testing-library/react';
|
||||
|
||||
import ContentTagsDrawer from './ContentTagsDrawer';
|
||||
import {
|
||||
useContentTaxonomyTagsData,
|
||||
useContentData,
|
||||
useTaxonomyTagsData,
|
||||
} from './data/apiHooks';
|
||||
import { getTaxonomyListData } from '../taxonomy/data/api';
|
||||
import messages from './messages';
|
||||
|
||||
const contentId = 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@7f47fe2dbcaf47c5a071671c741fe1ab';
|
||||
const mockOnClose = jest.fn();
|
||||
import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded } from '../taxonomy/data/apiHooks';
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useParams: () => ({
|
||||
contentId,
|
||||
contentId: 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@7f47fe2dbcaf47c5a071671c741fe1ab',
|
||||
}),
|
||||
}));
|
||||
|
||||
// FIXME: replace these mocks with API mocks
|
||||
jest.mock('./data/apiHooks', () => ({
|
||||
useContentTaxonomyTagsData: jest.fn(() => ({
|
||||
isSuccess: false,
|
||||
@@ -41,115 +28,20 @@ jest.mock('./data/apiHooks', () => ({
|
||||
useContentTaxonomyTagsUpdater: jest.fn(() => ({
|
||||
isError: false,
|
||||
})),
|
||||
useTaxonomyTagsData: jest.fn(() => ({
|
||||
hasMorePages: false,
|
||||
tagPages: {
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
canAddTag: false,
|
||||
data: [],
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('../taxonomy/data/api', () => ({
|
||||
// By default, the mock taxonomy list will never load (promise never resolves):
|
||||
getTaxonomyListData: jest.fn(),
|
||||
jest.mock('../taxonomy/data/apiHooks', () => ({
|
||||
useTaxonomyListDataResponse: jest.fn(),
|
||||
useIsTaxonomyListDataLoaded: jest.fn(),
|
||||
}));
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
const RootWrapper = (params) => (
|
||||
const RootWrapper = () => (
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ContentTagsDrawer {...params} />
|
||||
</QueryClientProvider>
|
||||
<ContentTagsDrawer />
|
||||
</IntlProvider>
|
||||
);
|
||||
|
||||
describe('<ContentTagsDrawer />', () => {
|
||||
beforeEach(async () => {
|
||||
await queryClient.resetQueries();
|
||||
// By default, we mock the API call with a promise that never resolves.
|
||||
// You can override this in specific test.
|
||||
getTaxonomyListData.mockReturnValue(new Promise(() => {}));
|
||||
});
|
||||
|
||||
const setupMockDataForStagedTagsTesting = () => {
|
||||
useContentTaxonomyTagsData.mockReturnValue({
|
||||
isSuccess: true,
|
||||
data: {
|
||||
taxonomies: [
|
||||
{
|
||||
name: 'Taxonomy 1',
|
||||
taxonomyId: 123,
|
||||
canTagObject: true,
|
||||
tags: [
|
||||
{
|
||||
value: 'Tag 1',
|
||||
lineage: ['Tag 1'],
|
||||
canDeleteObjecttag: true,
|
||||
},
|
||||
{
|
||||
value: 'Tag 2',
|
||||
lineage: ['Tag 2'],
|
||||
canDeleteObjecttag: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
getTaxonomyListData.mockResolvedValue({
|
||||
results: [{
|
||||
id: 123,
|
||||
name: 'Taxonomy 1',
|
||||
description: 'This is a description 1',
|
||||
canTagObject: true,
|
||||
}],
|
||||
});
|
||||
|
||||
useTaxonomyTagsData.mockReturnValue({
|
||||
hasMorePages: false,
|
||||
canAddTag: false,
|
||||
tagPages: {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: [{
|
||||
value: 'Tag 1',
|
||||
externalId: null,
|
||||
childCount: 0,
|
||||
depth: 0,
|
||||
parentValue: null,
|
||||
id: 12345,
|
||||
subTagsUrl: null,
|
||||
canChangeTag: false,
|
||||
canDeleteTag: false,
|
||||
}, {
|
||||
value: 'Tag 2',
|
||||
externalId: null,
|
||||
childCount: 0,
|
||||
depth: 0,
|
||||
parentValue: null,
|
||||
id: 12346,
|
||||
subTagsUrl: null,
|
||||
canChangeTag: false,
|
||||
canDeleteTag: false,
|
||||
}, {
|
||||
value: 'Tag 3',
|
||||
externalId: null,
|
||||
childCount: 0,
|
||||
depth: 0,
|
||||
parentValue: null,
|
||||
id: 12347,
|
||||
subTagsUrl: null,
|
||||
canChangeTag: false,
|
||||
canDeleteTag: false,
|
||||
}],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
it('should render page and page title correctly', () => {
|
||||
const { getByText } = render(<RootWrapper />);
|
||||
expect(getByText('Manage tags')).toBeInTheDocument();
|
||||
@@ -164,6 +56,7 @@ describe('<ContentTagsDrawer />', () => {
|
||||
});
|
||||
|
||||
it('shows spinner before the taxonomy tags query is complete', async () => {
|
||||
useIsTaxonomyListDataLoaded.mockReturnValue(false);
|
||||
await act(async () => {
|
||||
const { getAllByRole } = render(<RootWrapper />);
|
||||
const spinner = getAllByRole('status')[1];
|
||||
@@ -184,18 +77,8 @@ describe('<ContentTagsDrawer />', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('shows content using params', async () => {
|
||||
useContentData.mockReturnValue({
|
||||
isSuccess: true,
|
||||
data: {
|
||||
displayName: 'Unit 1',
|
||||
},
|
||||
});
|
||||
render(<RootWrapper id={contentId} />);
|
||||
expect(screen.getByText('Unit 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the taxonomies data including tag numbers after the query is complete', async () => {
|
||||
useIsTaxonomyListDataLoaded.mockReturnValue(true);
|
||||
useContentTaxonomyTagsData.mockReturnValue({
|
||||
isSuccess: true,
|
||||
data: {
|
||||
@@ -232,7 +115,7 @@ describe('<ContentTagsDrawer />', () => {
|
||||
],
|
||||
},
|
||||
});
|
||||
getTaxonomyListData.mockResolvedValue({
|
||||
useTaxonomyListDataResponse.mockReturnValue({
|
||||
results: [{
|
||||
id: 123,
|
||||
name: 'Taxonomy 1',
|
||||
@@ -247,7 +130,6 @@ describe('<ContentTagsDrawer />', () => {
|
||||
});
|
||||
await act(async () => {
|
||||
const { container, getByText } = render(<RootWrapper />);
|
||||
await waitFor(() => { expect(getByText('Taxonomy 1')).toBeInTheDocument(); });
|
||||
expect(getByText('Taxonomy 1')).toBeInTheDocument();
|
||||
expect(getByText('Taxonomy 2')).toBeInTheDocument();
|
||||
const tagCountBadges = container.getElementsByClassName('badge');
|
||||
@@ -256,105 +138,7 @@ describe('<ContentTagsDrawer />', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should test adding a content tag to the staged tags for a taxonomy', async () => {
|
||||
setupMockDataForStagedTagsTesting();
|
||||
|
||||
const { container, getByText, getAllByText } = render(<RootWrapper />);
|
||||
await waitFor(() => { expect(getByText('Taxonomy 1')).toBeInTheDocument(); });
|
||||
|
||||
// Expand the Taxonomy to view applied tags and "Add a tag" button
|
||||
const expandToggle = container.getElementsByClassName('collapsible-trigger')[0];
|
||||
|
||||
fireEvent.click(expandToggle);
|
||||
|
||||
// Click on "Add a tag" button to open dropdown
|
||||
const addTagsButton = getByText(messages.collapsibleAddTagsPlaceholderText.defaultMessage);
|
||||
// Use `mouseDown` instead of `click` since the react-select didn't respond to `click`
|
||||
fireEvent.mouseDown(addTagsButton);
|
||||
|
||||
// Tag 3 should only appear in dropdown selector, (i.e. the dropdown is open, since Tag 3 is not applied)
|
||||
expect(getAllByText('Tag 3').length).toBe(1);
|
||||
|
||||
// Click to check Tag 3
|
||||
const tag3 = getByText('Tag 3');
|
||||
fireEvent.click(tag3);
|
||||
|
||||
// Check that Tag 3 has been staged, i.e. there should be 2 of them on the page
|
||||
expect(getAllByText('Tag 3').length).toBe(2);
|
||||
});
|
||||
|
||||
it('should test removing a staged content from a taxonomy', async () => {
|
||||
setupMockDataForStagedTagsTesting();
|
||||
|
||||
const { container, getByText, getAllByText } = render(<RootWrapper />);
|
||||
await waitFor(() => { expect(getByText('Taxonomy 1')).toBeInTheDocument(); });
|
||||
|
||||
// Expand the Taxonomy to view applied tags and "Add a tag" button
|
||||
const expandToggle = container.getElementsByClassName('collapsible-trigger')[0];
|
||||
|
||||
fireEvent.click(expandToggle);
|
||||
|
||||
// Click on "Add a tag" button to open dropdown
|
||||
const addTagsButton = getByText(messages.collapsibleAddTagsPlaceholderText.defaultMessage);
|
||||
// Use `mouseDown` instead of `click` since the react-select didn't respond to `click`
|
||||
fireEvent.mouseDown(addTagsButton);
|
||||
|
||||
// Tag 3 should only appear in dropdown selector, (i.e. the dropdown is open, since Tag 3 is not applied)
|
||||
expect(getAllByText('Tag 3').length).toBe(1);
|
||||
|
||||
// Click to check Tag 3
|
||||
const tag3 = getByText('Tag 3');
|
||||
fireEvent.click(tag3);
|
||||
|
||||
// Check that Tag 3 has been staged, i.e. there should be 2 of them on the page
|
||||
expect(getAllByText('Tag 3').length).toBe(2);
|
||||
|
||||
// Click it again to unstage it and confirm that there is only one on the page
|
||||
fireEvent.click(tag3);
|
||||
expect(getAllByText('Tag 3').length).toBe(1);
|
||||
});
|
||||
|
||||
it('should test clearing staged tags for a taxonomy', async () => {
|
||||
setupMockDataForStagedTagsTesting();
|
||||
|
||||
const {
|
||||
container,
|
||||
getByText,
|
||||
getAllByText,
|
||||
queryByText,
|
||||
} = render(<RootWrapper />);
|
||||
await waitFor(() => { expect(getByText('Taxonomy 1')).toBeInTheDocument(); });
|
||||
|
||||
// Expand the Taxonomy to view applied tags and "Add a tag" button
|
||||
const expandToggle = container.getElementsByClassName('collapsible-trigger')[0];
|
||||
|
||||
fireEvent.click(expandToggle);
|
||||
|
||||
// Click on "Add a tag" button to open dropdown
|
||||
const addTagsButton = getByText(messages.collapsibleAddTagsPlaceholderText.defaultMessage);
|
||||
// Use `mouseDown` instead of `click` since the react-select didn't respond to `click`
|
||||
fireEvent.mouseDown(addTagsButton);
|
||||
|
||||
// Tag 3 should only appear in dropdown selector, (i.e. the dropdown is open, since Tag 3 is not applied)
|
||||
expect(getAllByText('Tag 3').length).toBe(1);
|
||||
|
||||
// Click to check Tag 3
|
||||
const tag3 = getByText('Tag 3');
|
||||
fireEvent.click(tag3);
|
||||
|
||||
// Check that Tag 3 has been staged, i.e. there should be 2 of them on the page
|
||||
expect(getAllByText('Tag 3').length).toBe(2);
|
||||
|
||||
// Click on the Cancel button in the dropdown to clear the staged tags
|
||||
const dropdownCancel = getByText(messages.collapsibleCancelStagedTagsButtonText.defaultMessage);
|
||||
fireEvent.click(dropdownCancel);
|
||||
|
||||
// Check that there are no more Tag 3 on the page, since the staged one is cleared
|
||||
// and the dropdown has been closed
|
||||
expect(queryByText('Tag 3')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call closeManageTagsDrawer when CloseButton is clicked', async () => {
|
||||
it('should call closeContentTagsDrawer when CloseButton is clicked', async () => {
|
||||
const postMessageSpy = jest.spyOn(window.parent, 'postMessage');
|
||||
|
||||
const { getByTestId } = render(<RootWrapper />);
|
||||
@@ -368,17 +152,7 @@ describe('<ContentTagsDrawer />', () => {
|
||||
postMessageSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should call onClose param when CloseButton is clicked', async () => {
|
||||
render(<RootWrapper onClose={mockOnClose} />);
|
||||
|
||||
// Find the CloseButton element by its test ID and trigger a click event
|
||||
const closeButton = screen.getByTestId('drawer-close-button');
|
||||
fireEvent.click(closeButton);
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call closeManageTagsDrawer when Escape key is pressed and no selectable box is active', () => {
|
||||
it('should call closeContentTagsDrawer when Escape key is pressed and no selectable box is active', () => {
|
||||
const postMessageSpy = jest.spyOn(window.parent, 'postMessage');
|
||||
|
||||
const { container } = render(<RootWrapper />);
|
||||
@@ -392,7 +166,7 @@ describe('<ContentTagsDrawer />', () => {
|
||||
postMessageSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should not call closeManageTagsDrawer when Escape key is pressed and a selectable box is active', () => {
|
||||
it('should not call closeContentTagsDrawer when Escape key is pressed and a selectable box is active', () => {
|
||||
const postMessageSpy = jest.spyOn(window.parent, 'postMessage');
|
||||
|
||||
const { container } = render(<RootWrapper />);
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
// @ts-check
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
SelectableBox,
|
||||
Icon,
|
||||
Spinner,
|
||||
Button,
|
||||
} from '@openedx/paragon';
|
||||
import { SelectableBox } from '@edx/frontend-lib-content-components';
|
||||
} from '@edx/paragon';
|
||||
import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import { ArrowDropDown, ArrowDropUp, Add } from '@openedx/paragon/icons';
|
||||
import { ArrowDropDown, ArrowDropUp } from '@edx/paragon/icons';
|
||||
import PropTypes from 'prop-types';
|
||||
import messages from './messages';
|
||||
import './ContentTagsDropDownSelector.scss';
|
||||
|
||||
import { useTaxonomyTagsData } from './data/apiHooks';
|
||||
|
||||
@@ -41,7 +42,7 @@ HighlightedText.defaultProps = {
|
||||
};
|
||||
|
||||
const ContentTagsDropDownSelector = ({
|
||||
taxonomyId, level, lineage, appliedContentTagsTree, stagedContentTagsTree, searchTerm,
|
||||
taxonomyId, level, lineage, tagsTree, searchTerm,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
@@ -88,146 +89,19 @@ const ContentTagsDropDownSelector = ({
|
||||
};
|
||||
|
||||
const isImplicit = (tag) => {
|
||||
// Traverse the applied tags tree using the lineage
|
||||
let appliedTraversal = appliedContentTagsTree;
|
||||
// Traverse the tags tree using the lineage
|
||||
let traversal = tagsTree;
|
||||
lineage.forEach(t => {
|
||||
appliedTraversal = appliedTraversal[t]?.children || {};
|
||||
traversal = traversal[t]?.children || {};
|
||||
});
|
||||
const isAppliedImplicit = (appliedTraversal[tag.value] && !appliedTraversal[tag.value].explicit);
|
||||
|
||||
// Traverse the staged tags tree using the lineage
|
||||
let stagedTraversal = stagedContentTagsTree;
|
||||
lineage.forEach(t => {
|
||||
stagedTraversal = stagedTraversal[t]?.children || {};
|
||||
});
|
||||
const isStagedImplicit = (stagedTraversal[tag.value] && !stagedTraversal[tag.value].explicit);
|
||||
|
||||
return isAppliedImplicit || isStagedImplicit || false;
|
||||
return (traversal[tag.value] && !traversal[tag.value].explicit) || false;
|
||||
};
|
||||
|
||||
const isApplied = (tag) => {
|
||||
// Traverse the applied tags tree using the lineage
|
||||
let appliedTraversal = appliedContentTagsTree;
|
||||
lineage.forEach(t => {
|
||||
appliedTraversal = appliedTraversal[t]?.children || {};
|
||||
});
|
||||
return !!appliedTraversal[tag.value];
|
||||
};
|
||||
|
||||
const isStagedExplicit = (tag) => {
|
||||
// Traverse the staged tags tree using the lineage
|
||||
let stagedTraversal = stagedContentTagsTree;
|
||||
lineage.forEach(t => {
|
||||
stagedTraversal = stagedTraversal[t]?.children || {};
|
||||
});
|
||||
return stagedTraversal[tag.value] && stagedTraversal[tag.value].explicit;
|
||||
};
|
||||
|
||||
// Returns the state of the tag as a string: [Unchecked/Implicit/Checked]
|
||||
const getTagState = (tag) => {
|
||||
if (isApplied(tag) || isStagedExplicit(tag)) {
|
||||
return intl.formatMessage(messages.taxonomyTagChecked);
|
||||
}
|
||||
|
||||
if (isImplicit(tag)) {
|
||||
return intl.formatMessage(messages.taxonomyTagImplicit);
|
||||
}
|
||||
|
||||
return intl.formatMessage(messages.taxonomyTagUnchecked);
|
||||
};
|
||||
|
||||
const isTopOfTagTreeDropdown = (index) => index === 0 && level === 0;
|
||||
|
||||
const loadMoreTags = useCallback(() => {
|
||||
setNumPages((x) => x + 1);
|
||||
}, []);
|
||||
|
||||
const handleKeyBoardNav = (e, hasChildren) => {
|
||||
const keyPressed = e.code;
|
||||
const currentElement = e.target;
|
||||
const encapsulator = currentElement.closest('.dropdown-selector-tag-encapsulator');
|
||||
|
||||
// Get tag value with full lineage, this is URI encoded
|
||||
const tagValueWithLineage = currentElement.querySelector('.pgn__form-checkbox-input')?.value;
|
||||
// Extract and decode the actual tag value
|
||||
let tagValue = tagValueWithLineage.split(',').slice(-1)[0];
|
||||
tagValue = tagValue ? decodeURIComponent(tagValue) : tagValue;
|
||||
|
||||
if (keyPressed === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
if (tagValue && !isOpen(tagValue)) {
|
||||
clickAndEnterHandler(tagValue);
|
||||
}
|
||||
} else if (keyPressed === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
if (tagValue && isOpen(tagValue)) {
|
||||
clickAndEnterHandler(tagValue);
|
||||
} else {
|
||||
// Handles case of jumping out of subtags to previous parent tag
|
||||
const prevParentTagEncapsulator = encapsulator?.parentNode.closest('.dropdown-selector-tag-encapsulator');
|
||||
const prevParentTag = prevParentTagEncapsulator?.querySelector('.dropdown-selector-tag-actions');
|
||||
prevParentTag?.focus();
|
||||
}
|
||||
} else if (keyPressed === 'ArrowUp') {
|
||||
const prevSubTags = encapsulator?.previousElementSibling?.querySelectorAll('.dropdown-selector-tag-actions');
|
||||
const prevSubTag = prevSubTags && prevSubTags[prevSubTags.length - 1];
|
||||
const prevTag = encapsulator?.previousElementSibling?.querySelector('.dropdown-selector-tag-actions');
|
||||
|
||||
if (prevSubTag) {
|
||||
// Handles case of jumping in to subtags
|
||||
prevSubTag.focus();
|
||||
} else if (prevTag) {
|
||||
// Handles case of navigating to previous tag on same level
|
||||
prevTag.focus();
|
||||
} else {
|
||||
// Handles case of jumping out of subtags to previous parent tag
|
||||
const prevParentTagEncapsulator = encapsulator?.parentNode.closest('.dropdown-selector-tag-encapsulator');
|
||||
const prevParentTag = prevParentTagEncapsulator?.querySelector('.dropdown-selector-tag-actions');
|
||||
prevParentTag?.focus();
|
||||
}
|
||||
} else if (keyPressed === 'ArrowDown') {
|
||||
const subTagEncapsulator = encapsulator?.querySelector('.dropdown-selector-tag-encapsulator');
|
||||
const nextSubTag = subTagEncapsulator?.querySelector('.dropdown-selector-tag-actions');
|
||||
const nextTag = encapsulator?.nextElementSibling?.querySelector('.dropdown-selector-tag-actions');
|
||||
|
||||
if (nextSubTag) {
|
||||
// Handles case of jumping into subtags
|
||||
nextSubTag.focus();
|
||||
} else if (nextTag) {
|
||||
// Handles case of navigating to next tag on same level
|
||||
nextTag?.focus();
|
||||
} else {
|
||||
// Handles case of jumping out of subtags to next focusable parent tag
|
||||
let nextParentTagEncapsulator = encapsulator?.parentNode?.closest('.dropdown-selector-tag-encapsulator');
|
||||
|
||||
while (nextParentTagEncapsulator) {
|
||||
const nextParentTag = nextParentTagEncapsulator.nextElementSibling?.querySelector(
|
||||
'.dropdown-selector-tag-actions',
|
||||
);
|
||||
if (nextParentTag) {
|
||||
nextParentTag.focus();
|
||||
break;
|
||||
}
|
||||
nextParentTagEncapsulator = nextParentTagEncapsulator.parentNode.closest(
|
||||
'.dropdown-selector-tag-encapsulator',
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (keyPressed === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (hasChildren && tagValue) {
|
||||
clickAndEnterHandler(tagValue);
|
||||
} else {
|
||||
const checkbox = currentElement.querySelector('.taxonomy-tags-selectable-box');
|
||||
checkbox.click();
|
||||
}
|
||||
} else if (keyPressed === 'Space') {
|
||||
e.preventDefault();
|
||||
const checkbox = currentElement.querySelector('.taxonomy-tags-selectable-box');
|
||||
checkbox.click();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ marginLeft: `${level * 1 }rem` }}>
|
||||
{tagPages.isLoading ? (
|
||||
@@ -241,39 +115,24 @@ const ContentTagsDropDownSelector = ({
|
||||
) : null }
|
||||
{tagPages.isError ? 'Error...' : null /* TODO: show a proper error message */}
|
||||
|
||||
{tagPages.data?.map((tagData, i) => (
|
||||
<div key={tagData.value} className="mt-1 ml-1 dropdown-selector-tag-encapsulator">
|
||||
{tagPages.data?.map((tagData) => (
|
||||
<React.Fragment key={tagData.value}>
|
||||
<div
|
||||
className="d-flex flex-row"
|
||||
style={{
|
||||
minHeight: '44px',
|
||||
}}
|
||||
>
|
||||
{/* The tabIndex and onKeyDown are needed to implement custom keyboard navigation */}
|
||||
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
|
||||
<div
|
||||
className="d-flex dropdown-selector-tag-actions"
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
|
||||
tabIndex={isTopOfTagTreeDropdown(i) ? 0 : -1} // Only enable tab into top of dropdown tree to set focus
|
||||
onKeyDown={(e) => handleKeyBoardNav(e, tagData.childCount > 0)}
|
||||
aria-label={
|
||||
intl.formatMessage(
|
||||
(isTopOfTagTreeDropdown(i)
|
||||
? messages.taxonomyTagActionInstructionsAriaLabel
|
||||
: messages.taxonomyTagActionsAriaLabel),
|
||||
{ tag: tagData.value, tagState: getTagState(tagData) },
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="d-flex">
|
||||
<SelectableBox
|
||||
inputHidden={false}
|
||||
type="checkbox"
|
||||
className="d-flex align-items-center taxonomy-tags-selectable-box"
|
||||
aria-label={intl.formatMessage(messages.taxonomyTagsCheckboxAriaLabel, { tag: tagData.value })}
|
||||
data-selectable-box="taxonomy-tags"
|
||||
value={[...lineage, tagData.value].map(t => encodeURIComponent(t)).join(',')}
|
||||
isIndeterminate={isApplied(tagData) || isImplicit(tagData)}
|
||||
disabled={isApplied(tagData) || isImplicit(tagData)}
|
||||
tabIndex="-1"
|
||||
isIndeterminate={isImplicit(tagData)}
|
||||
disabled={isImplicit(tagData)}
|
||||
>
|
||||
<HighlightedText text={tagData.value} highlight={searchTerm} />
|
||||
</SelectableBox>
|
||||
@@ -283,7 +142,8 @@ const ContentTagsDropDownSelector = ({
|
||||
<Icon
|
||||
src={isOpen(tagData.value) ? ArrowDropUp : ArrowDropDown}
|
||||
onClick={() => clickAndEnterHandler(tagData.value)}
|
||||
tabIndex="-1"
|
||||
tabIndex="0"
|
||||
onKeyPress={(event) => (event.key === 'Enter' ? clickAndEnterHandler(tagData.value) : null)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -296,24 +156,21 @@ const ContentTagsDropDownSelector = ({
|
||||
taxonomyId={taxonomyId}
|
||||
level={level + 1}
|
||||
lineage={[...lineage, tagData.value]}
|
||||
appliedContentTagsTree={appliedContentTagsTree}
|
||||
stagedContentTagsTree={stagedContentTagsTree}
|
||||
tagsTree={tagsTree}
|
||||
searchTerm={searchTerm}
|
||||
/>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
{ hasMorePages
|
||||
? (
|
||||
<div>
|
||||
<div className="d-flex justify-content-center align-items-center flex-row">
|
||||
<Button
|
||||
tabIndex="0"
|
||||
variant="tertiary"
|
||||
iconBefore={Add}
|
||||
variant="outline-primary"
|
||||
onClick={loadMoreTags}
|
||||
className="mb-2 ml-1 taxonomy-tags-load-more-button px-0 text-info-500"
|
||||
className="mb-2 taxonomy-tags-load-more-button"
|
||||
>
|
||||
<FormattedMessage {...messages.loadMoreTagsButtonText} />
|
||||
</Button>
|
||||
@@ -340,13 +197,7 @@ ContentTagsDropDownSelector.propTypes = {
|
||||
taxonomyId: PropTypes.number.isRequired,
|
||||
level: PropTypes.number.isRequired,
|
||||
lineage: PropTypes.arrayOf(PropTypes.string),
|
||||
appliedContentTagsTree: PropTypes.objectOf(
|
||||
PropTypes.shape({
|
||||
explicit: PropTypes.bool.isRequired,
|
||||
children: PropTypes.shape({}).isRequired,
|
||||
}).isRequired,
|
||||
).isRequired,
|
||||
stagedContentTagsTree: PropTypes.objectOf(
|
||||
tagsTree: PropTypes.objectOf(
|
||||
PropTypes.shape({
|
||||
explicit: PropTypes.bool.isRequired,
|
||||
children: PropTypes.shape({}).isRequired,
|
||||
|
||||
@@ -4,24 +4,11 @@
|
||||
|
||||
.taxonomy-tags-load-more-button {
|
||||
flex: 1;
|
||||
|
||||
&:hover {
|
||||
background-color: transparent;
|
||||
color: $info-900 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.pgn__selectable_box.taxonomy-tags-selectable-box {
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
|
||||
// Override indeterminate [-] (implicit) checkbox styles to match checked checkbox styles
|
||||
// In the future, this customizability should be implemented in paragon instead
|
||||
input.pgn__form-checkbox-input {
|
||||
&:indeterminate {
|
||||
@extend :checked; /* stylelint-disable-line scss/at-extend-no-missing-placeholder */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pgn__selectable_box.taxonomy-tags-selectable-box:disabled,
|
||||
@@ -32,8 +19,3 @@
|
||||
.pgn__selectable_box-active.taxonomy-tags-selectable-box {
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
.dropdown-selector-tag-actions:focus-visible {
|
||||
outline: solid 2px $info-900;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@@ -25,12 +25,10 @@ const data = {
|
||||
taxonomyId: 123,
|
||||
level: 0,
|
||||
tagsTree: {},
|
||||
appliedContentTagsTree: {},
|
||||
stagedContentTagsTree: {},
|
||||
};
|
||||
|
||||
const ContentTagsDropDownSelectorComponent = ({
|
||||
taxonomyId, level, lineage, tagsTree, searchTerm, appliedContentTagsTree, stagedContentTagsTree,
|
||||
taxonomyId, level, lineage, tagsTree, searchTerm,
|
||||
}) => (
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
<ContentTagsDropDownSelector
|
||||
@@ -39,8 +37,6 @@ const ContentTagsDropDownSelectorComponent = ({
|
||||
lineage={lineage}
|
||||
tagsTree={tagsTree}
|
||||
searchTerm={searchTerm}
|
||||
appliedContentTagsTree={appliedContentTagsTree}
|
||||
stagedContentTagsTree={stagedContentTagsTree}
|
||||
/>
|
||||
</IntlProvider>
|
||||
);
|
||||
@@ -57,25 +53,15 @@ describe('<ContentTagsDropDownSelector />', () => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
async function getComponent(updatedData) {
|
||||
const componentData = (!updatedData ? data : updatedData);
|
||||
|
||||
return render(
|
||||
<ContentTagsDropDownSelectorComponent
|
||||
taxonomyId={componentData.taxonomyId}
|
||||
level={componentData.level}
|
||||
lineage={componentData.lineage}
|
||||
tagsTree={componentData.tagsTree}
|
||||
searchTerm={componentData.searchTerm}
|
||||
appliedContentTagsTree={componentData.appliedContentTagsTree}
|
||||
stagedContentTagsTree={componentData.stagedContentTagsTree}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
it('should render taxonomy tags drop down selector loading with spinner', async () => {
|
||||
await act(async () => {
|
||||
const { getByRole } = await getComponent();
|
||||
const { getByRole } = render(
|
||||
<ContentTagsDropDownSelectorComponent
|
||||
taxonomyId={data.taxonomyId}
|
||||
level={data.level}
|
||||
tagsTree={data.tagsTree}
|
||||
/>,
|
||||
);
|
||||
const spinner = getByRole('status');
|
||||
expect(spinner.textContent).toEqual('Loading tags'); // Uses <Spinner />
|
||||
});
|
||||
@@ -100,8 +86,14 @@ describe('<ContentTagsDropDownSelector />', () => {
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
const { container, getByText } = await getComponent();
|
||||
|
||||
const { container, getByText } = render(
|
||||
<ContentTagsDropDownSelectorComponent
|
||||
key={`selector-${data.taxonomyId}`}
|
||||
taxonomyId={data.taxonomyId}
|
||||
level={data.level}
|
||||
tagsTree={data.tagsTree}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(getByText('Tag 1')).toBeInTheDocument();
|
||||
expect(container.getElementsByClassName('taxonomy-tags-arrow-drop-down').length).toBe(0);
|
||||
@@ -128,8 +120,13 @@ describe('<ContentTagsDropDownSelector />', () => {
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
const { container, getByText } = await getComponent();
|
||||
|
||||
const { container, getByText } = render(
|
||||
<ContentTagsDropDownSelectorComponent
|
||||
taxonomyId={data.taxonomyId}
|
||||
level={data.level}
|
||||
tagsTree={data.tagsTree}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(getByText('Tag 2')).toBeInTheDocument();
|
||||
expect(container.getElementsByClassName('taxonomy-tags-arrow-drop-down').length).toBe(1);
|
||||
@@ -165,7 +162,13 @@ describe('<ContentTagsDropDownSelector />', () => {
|
||||
},
|
||||
},
|
||||
};
|
||||
const { container, getByText } = await getComponent(dataWithTagsTree);
|
||||
const { container, getByText } = render(
|
||||
<ContentTagsDropDownSelectorComponent
|
||||
taxonomyId={dataWithTagsTree.taxonomyId}
|
||||
level={dataWithTagsTree.level}
|
||||
tagsTree={dataWithTagsTree.tagsTree}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(getByText('Tag 2')).toBeInTheDocument();
|
||||
expect(container.getElementsByClassName('taxonomy-tags-arrow-drop-down').length).toBe(1);
|
||||
@@ -199,6 +202,74 @@ describe('<ContentTagsDropDownSelector />', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should expand on enter key taxonomy tags drop down selector with sub tags', async () => {
|
||||
useTaxonomyTagsData.mockReturnValueOnce({
|
||||
hasMorePages: false,
|
||||
tagPages: {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: [{
|
||||
value: 'Tag 2',
|
||||
externalId: null,
|
||||
childCount: 1,
|
||||
depth: 0,
|
||||
parentValue: null,
|
||||
id: 12345,
|
||||
subTagsUrl: 'http://localhost:18010/api/content_tagging/v1/taxonomies/4/tags/?parent_tag=Tag%202',
|
||||
}],
|
||||
},
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
const dataWithTagsTree = {
|
||||
...data,
|
||||
tagsTree: {
|
||||
'Tag 3': {
|
||||
explicit: false,
|
||||
children: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
const { container, getByText } = render(
|
||||
<ContentTagsDropDownSelectorComponent
|
||||
taxonomyId={dataWithTagsTree.taxonomyId}
|
||||
level={dataWithTagsTree.level}
|
||||
tagsTree={dataWithTagsTree.tagsTree}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(getByText('Tag 2')).toBeInTheDocument();
|
||||
expect(container.getElementsByClassName('taxonomy-tags-arrow-drop-down').length).toBe(1);
|
||||
});
|
||||
|
||||
// Mock useTaxonomyTagsData again since it gets called in the recursive call
|
||||
useTaxonomyTagsData.mockReturnValueOnce({
|
||||
hasMorePages: false,
|
||||
tagPages: {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: [{
|
||||
value: 'Tag 3',
|
||||
externalId: null,
|
||||
childCount: 0,
|
||||
depth: 1,
|
||||
parentValue: 'Tag 2',
|
||||
id: 12346,
|
||||
subTagsUrl: null,
|
||||
}],
|
||||
},
|
||||
});
|
||||
|
||||
// Expand the dropdown to see the subtags selectors
|
||||
const expandToggle = container.querySelector('.taxonomy-tags-arrow-drop-down span');
|
||||
fireEvent.keyPress(expandToggle, { key: 'Enter', charCode: 13 });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText('Tag 3')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should render taxonomy tags drop down selector and change search term', async () => {
|
||||
useTaxonomyTagsData.mockReturnValueOnce({
|
||||
hasMorePages: false,
|
||||
@@ -220,7 +291,15 @@ describe('<ContentTagsDropDownSelector />', () => {
|
||||
|
||||
const initalSearchTerm = 'test 1';
|
||||
await act(async () => {
|
||||
const { rerender } = await getComponent({ ...data, searchTerm: initalSearchTerm });
|
||||
const { rerender } = render(
|
||||
<ContentTagsDropDownSelectorComponent
|
||||
key={`selector-${data.taxonomyId}`}
|
||||
taxonomyId={data.taxonomyId}
|
||||
level={data.level}
|
||||
tagsTree={data.tagsTree}
|
||||
searchTerm={initalSearchTerm}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useTaxonomyTagsData).toBeCalledWith(data.taxonomyId, null, 1, initalSearchTerm);
|
||||
@@ -233,8 +312,6 @@ describe('<ContentTagsDropDownSelector />', () => {
|
||||
level={data.level}
|
||||
tagsTree={data.tagsTree}
|
||||
searchTerm={updatedSearchTerm}
|
||||
appliedContentTagsTree={{}}
|
||||
stagedContentTagsTree={{}}
|
||||
/>);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -249,8 +326,6 @@ describe('<ContentTagsDropDownSelector />', () => {
|
||||
level={data.level}
|
||||
tagsTree={data.tagsTree}
|
||||
searchTerm={cleanSearchTerm}
|
||||
appliedContentTagsTree={{}}
|
||||
stagedContentTagsTree={{}}
|
||||
/>);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -272,7 +347,15 @@ describe('<ContentTagsDropDownSelector />', () => {
|
||||
|
||||
const searchTerm = 'uncommon search term';
|
||||
await act(async () => {
|
||||
const { getByText } = await getComponent({ ...data, searchTerm });
|
||||
const { getByText } = render(
|
||||
<ContentTagsDropDownSelectorComponent
|
||||
key={`selector-${data.taxonomyId}`}
|
||||
taxonomyId={data.taxonomyId}
|
||||
level={data.level}
|
||||
tagsTree={data.tagsTree}
|
||||
searchTerm={searchTerm}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useTaxonomyTagsData).toBeCalledWith(data.taxonomyId, null, 1, searchTerm);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Chip,
|
||||
} from '@openedx/paragon';
|
||||
import { Tag, Close } from '@openedx/paragon/icons';
|
||||
} from '@edx/paragon';
|
||||
import { Tag, Close } from '@edx/paragon/icons';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import TagOutlineIcon from './TagOutlineIcon';
|
||||
@@ -14,7 +14,7 @@ const TagBubble = ({
|
||||
|
||||
const handleClick = React.useCallback(() => {
|
||||
if (!implicit && canRemove) {
|
||||
removeTagHandler(lineage.join(','));
|
||||
removeTagHandler(lineage.join(','), false);
|
||||
}
|
||||
}, [implicit, lineage, canRemove, removeTagHandler]);
|
||||
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
module.exports = {
|
||||
'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b': 20,
|
||||
};
|
||||
@@ -1,35 +0,0 @@
|
||||
module.exports = {
|
||||
'hierarchical taxonomy tag 1': {
|
||||
children: {
|
||||
'hierarchical taxonomy tag 1.7': {
|
||||
children: {
|
||||
'hierarchical taxonomy tag 1.7.59': {
|
||||
children: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'hierarchical taxonomy tag 2': {
|
||||
children: {
|
||||
'hierarchical taxonomy tag 2.13': {
|
||||
children: {
|
||||
'hierarchical taxonomy tag 2.13.46': {
|
||||
children: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'hierarchical taxonomy tag 3': {
|
||||
children: {
|
||||
'hierarchical taxonomy tag 3.4': {
|
||||
children: {
|
||||
'hierarchical taxonomy tag 3.4.50': {
|
||||
children: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -2,5 +2,3 @@ export { default as taxonomyTagsMock } from './taxonomyTagsMock';
|
||||
export { default as contentTaxonomyTagsMock } from './contentTaxonomyTagsMock';
|
||||
export { default as contentDataMock } from './contentDataMock';
|
||||
export { default as updateContentTaxonomyTagsMock } from './updateContentTaxonomyTagsMock';
|
||||
export { default as contentTaxonomyTagsCountMock } from './contentTaxonomyTagsCountMock';
|
||||
export { default as contentTaxonomyTagsTreeMock } from './contentTaxonomyTagsTreeMock';
|
||||
|
||||
@@ -30,9 +30,7 @@ export const getTaxonomyTagsApiUrl = (taxonomyId, options = {}) => {
|
||||
};
|
||||
export const getContentTaxonomyTagsApiUrl = (contentId) => new URL(`api/content_tagging/v1/object_tags/${contentId}/`, getApiBaseUrl()).href;
|
||||
export const getXBlockContentDataApiURL = (contentId) => new URL(`/xblock/outline/${contentId}`, getApiBaseUrl()).href;
|
||||
export const getCourseContentDataApiURL = (contentId) => new URL(`/api/contentstore/v1/course_settings/${contentId}`, getApiBaseUrl()).href;
|
||||
export const getLibraryContentDataApiUrl = (contentId) => new URL(`/api/libraries/v2/blocks/${contentId}/`, getApiBaseUrl()).href;
|
||||
export const getContentTaxonomyTagsCountApiUrl = (contentId) => new URL(`api/content_tagging/v1/object_tag_counts/${contentId}/?count_implicit`, getApiBaseUrl()).href;
|
||||
|
||||
/**
|
||||
* Get all tags that belong to taxonomy.
|
||||
@@ -56,33 +54,15 @@ export async function getContentTaxonomyTagsData(contentId) {
|
||||
return camelCaseObject(data[contentId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the count of tags that are applied to the content object
|
||||
* @param {string} contentId The id of the content object to fetch the count of the applied tags for
|
||||
* @returns {Promise<number>}
|
||||
*/
|
||||
export async function getContentTaxonomyTagsCount(contentId) {
|
||||
const { data } = await getAuthenticatedHttpClient().get(getContentTaxonomyTagsCountApiUrl(contentId));
|
||||
if (contentId in data) {
|
||||
return camelCaseObject(data[contentId]);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch meta data (eg: display_name) about the content object (unit/compoenent)
|
||||
* @param {string} contentId The id of the content object (unit/component)
|
||||
* @returns {Promise<import("./types.mjs").ContentData>}
|
||||
*/
|
||||
export async function getContentData(contentId) {
|
||||
let url;
|
||||
if (contentId.startsWith('lb:')) {
|
||||
url = getLibraryContentDataApiUrl(contentId);
|
||||
} else if (contentId.startsWith('course-v1:')) {
|
||||
url = getCourseContentDataApiURL(contentId);
|
||||
} else {
|
||||
url = getXBlockContentDataApiURL(contentId);
|
||||
}
|
||||
const url = contentId.startsWith('lb:')
|
||||
? getLibraryContentDataApiUrl(contentId)
|
||||
: getXBlockContentDataApiURL(contentId);
|
||||
const { data } = await getAuthenticatedHttpClient().get(url);
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import {
|
||||
taxonomyTagsMock,
|
||||
contentTaxonomyTagsMock,
|
||||
contentTaxonomyTagsCountMock,
|
||||
contentDataMock,
|
||||
updateContentTaxonomyTagsMock,
|
||||
} from '../__mocks__';
|
||||
@@ -20,8 +19,6 @@ import {
|
||||
getContentTaxonomyTagsData,
|
||||
getContentData,
|
||||
updateContentTaxonomyTags,
|
||||
getContentTaxonomyTagsCountApiUrl,
|
||||
getContentTaxonomyTagsCount,
|
||||
} from './api';
|
||||
|
||||
let axiosMock;
|
||||
@@ -91,24 +88,6 @@ describe('content tags drawer api calls', () => {
|
||||
expect(result).toEqual(contentTaxonomyTagsMock[contentId]);
|
||||
});
|
||||
|
||||
it('should get content taxonomy tags count', async () => {
|
||||
const contentId = 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b';
|
||||
axiosMock.onGet(getContentTaxonomyTagsCountApiUrl(contentId)).reply(200, contentTaxonomyTagsCountMock);
|
||||
const result = await getContentTaxonomyTagsCount(contentId);
|
||||
|
||||
expect(axiosMock.history.get[0].url).toEqual(getContentTaxonomyTagsCountApiUrl(contentId));
|
||||
expect(result).toEqual(contentTaxonomyTagsCountMock[contentId]);
|
||||
});
|
||||
|
||||
it('should get content taxonomy tags count as zero', async () => {
|
||||
const contentId = 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b';
|
||||
axiosMock.onGet(getContentTaxonomyTagsCountApiUrl(contentId)).reply(200, {});
|
||||
const result = await getContentTaxonomyTagsCount(contentId);
|
||||
|
||||
expect(axiosMock.history.get[0].url).toEqual(getContentTaxonomyTagsCountApiUrl(contentId));
|
||||
expect(result).toEqual(0);
|
||||
});
|
||||
|
||||
it('should get content data for course component', async () => {
|
||||
const contentId = 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b';
|
||||
axiosMock.onGet(getXBlockContentDataApiURL(contentId)).reply(200, contentDataMock);
|
||||
|
||||
@@ -135,16 +135,8 @@ export const useContentTaxonomyTagsUpdater = (contentId, taxonomyId) => {
|
||||
* >}
|
||||
*/
|
||||
mutationFn: ({ tags }) => updateContentTaxonomyTags(contentId, taxonomyId, tags),
|
||||
onSettled: /* istanbul ignore next */ () => {
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['contentTaxonomyTags', contentId] });
|
||||
/// Invalidate query with pattern on course outline
|
||||
let contentPattern;
|
||||
if (contentId.includes('course-v1')) {
|
||||
contentPattern = contentId;
|
||||
} else {
|
||||
contentPattern = contentId.replace(/\+type@.*$/, '*');
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['contentTagsCount', contentPattern] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} XBlockData
|
||||
* @typedef {Object} ContentData
|
||||
* @property {string} id
|
||||
* @property {string} displayName
|
||||
* @property {string} category
|
||||
@@ -58,12 +58,3 @@
|
||||
* @property {boolean} staffOnlyMessage
|
||||
* @property {boolean} hasPartitionGroupComponents
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} CourseData
|
||||
* @property {string} courseDisplayNameWithDefault
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {XBlockData | CourseData} ContentData
|
||||
*/
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
@import "content-tags-drawer/TagBubble";
|
||||
@import "content-tags-drawer/tags-sidebar-controls/TagsSidebarControls";
|
||||
@@ -25,56 +25,14 @@ const messages = defineMessages({
|
||||
id: 'course-authoring.content-tags-drawer.tags-dropdown-selector.no-tags-found',
|
||||
defaultMessage: 'No tags found with the search term "{searchTerm}"',
|
||||
},
|
||||
taxonomyTagChecked: {
|
||||
id: 'course-authoring.content-tags-drawer.tags-dropdown-selector.tag-checked',
|
||||
defaultMessage: 'Checked',
|
||||
},
|
||||
taxonomyTagUnchecked: {
|
||||
id: 'course-authoring.content-tags-drawer.tags-dropdown-selector.tag-unchecked',
|
||||
defaultMessage: 'Unchecked',
|
||||
},
|
||||
taxonomyTagImplicit: {
|
||||
id: 'course-authoring.content-tags-drawer.tags-dropdown-selector.tag-implicit',
|
||||
defaultMessage: 'Implicit',
|
||||
},
|
||||
taxonomyTagActionInstructionsAriaLabel: {
|
||||
id: 'course-authoring.content-tags-drawer.tags-dropdown-selector.tag-action-instructions.aria.label',
|
||||
defaultMessage: '{tagState} Tag: {tag}. Use the arrow keys to move among the tags in this taxonomy. Press space to select a tag.',
|
||||
},
|
||||
taxonomyTagActionsAriaLabel: {
|
||||
id: 'course-authoring.content-tags-drawer.tags-dropdown-selector.tag-actions.aria.label',
|
||||
defaultMessage: '{tagState} Tag: {tag}',
|
||||
taxonomyTagsCheckboxAriaLabel: {
|
||||
id: 'course-authoring.content-tags-drawer.tags-dropdown-selector.selectable-box.aria.label',
|
||||
defaultMessage: '{tag} checkbox',
|
||||
},
|
||||
taxonomyTagsAriaLabel: {
|
||||
id: 'course-authoring.content-tags-drawer.content-tags-collapsible.selectable-box.selection.aria.label',
|
||||
defaultMessage: 'taxonomy tags selection',
|
||||
},
|
||||
manageTagsButton: {
|
||||
id: 'course-authoring.content-tags-drawer.button.manage',
|
||||
defaultMessage: 'Manage Tags',
|
||||
description: 'Label in the button that opens the drawer to edit content tags',
|
||||
},
|
||||
tagsSidebarTitle: {
|
||||
id: 'course-authoring.course-unit.sidebar.tags.title',
|
||||
defaultMessage: 'Unit Tags',
|
||||
description: 'Title of the tags sidebar',
|
||||
},
|
||||
collapsibleAddTagsPlaceholderText: {
|
||||
id: 'course-authoring.content-tags-drawer.content-tags-collapsible.custom-menu.placeholder-text',
|
||||
defaultMessage: 'Add a tag',
|
||||
},
|
||||
collapsibleAddStagedTagsButtonText: {
|
||||
id: 'course-authoring.content-tags-drawer.content-tags-collapsible.custom-menu.save-staged-tags',
|
||||
defaultMessage: 'Add tags',
|
||||
},
|
||||
collapsibleCancelStagedTagsButtonText: {
|
||||
id: 'course-authoring.content-tags-drawer.content-tags-collapsible.custom-menu.cancel-staged-tags',
|
||||
defaultMessage: 'Cancel',
|
||||
},
|
||||
collapsibleInlineAddStagedTagsButtonText: {
|
||||
id: 'course-authoring.content-tags-drawer.content-tags-collapsible.custom-menu.inline-save-staged-tags',
|
||||
defaultMessage: 'Add',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
// @ts-check
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import {
|
||||
Card, Stack, Button, Sheet, Collapsible, Icon,
|
||||
} from '@openedx/paragon';
|
||||
import { ArrowDropDown, ArrowDropUp } from '@openedx/paragon/icons';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { ContentTagsDrawer } from '..';
|
||||
|
||||
import messages from '../messages';
|
||||
import { useContentTaxonomyTagsData } from '../data/apiHooks';
|
||||
import { LoadingSpinner } from '../../generic/Loading';
|
||||
import TagsTree from './TagsTree';
|
||||
|
||||
const TagsSidebarBody = () => {
|
||||
const intl = useIntl();
|
||||
const [showManageTags, setShowManageTags] = useState(false);
|
||||
const contentId = useParams().blockId;
|
||||
const onClose = () => setShowManageTags(false);
|
||||
|
||||
const {
|
||||
data: contentTaxonomyTagsData,
|
||||
isSuccess: isContentTaxonomyTagsLoaded,
|
||||
} = useContentTaxonomyTagsData(contentId || '');
|
||||
|
||||
const buildTagsTree = (contentTags) => {
|
||||
const resultTree = {};
|
||||
contentTags.forEach(item => {
|
||||
let currentLevel = resultTree;
|
||||
|
||||
item.lineage.forEach((key) => {
|
||||
if (!currentLevel[key]) {
|
||||
currentLevel[key] = {
|
||||
children: {},
|
||||
canChangeObjecttag: item.canChangeObjecttag,
|
||||
canDeleteObjecttag: item.canDeleteObjecttag,
|
||||
};
|
||||
}
|
||||
|
||||
currentLevel = currentLevel[key].children;
|
||||
});
|
||||
});
|
||||
|
||||
return resultTree;
|
||||
};
|
||||
|
||||
const tree = useMemo(() => {
|
||||
const result = [];
|
||||
if (isContentTaxonomyTagsLoaded && contentTaxonomyTagsData) {
|
||||
contentTaxonomyTagsData.taxonomies.forEach((taxonomy) => {
|
||||
result.push({
|
||||
...taxonomy,
|
||||
tags: buildTagsTree(taxonomy.tags),
|
||||
});
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}, [isContentTaxonomyTagsLoaded, contentTaxonomyTagsData]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card.Body
|
||||
className="course-unit-sidebar-date tags-sidebar-body pl-2.5"
|
||||
>
|
||||
<Stack>
|
||||
{ isContentTaxonomyTagsLoaded
|
||||
? (
|
||||
<Stack>
|
||||
{tree.map((taxonomy) => (
|
||||
<div key={taxonomy.name}>
|
||||
<Collapsible
|
||||
className="tags-sidebar-taxonomy border-0 .font-weight-bold"
|
||||
styling="card"
|
||||
title={taxonomy.name}
|
||||
iconWhenClosed={<Icon src={ArrowDropDown} />}
|
||||
iconWhenOpen={<Icon src={ArrowDropUp} />}
|
||||
>
|
||||
<TagsTree tags={taxonomy.tags} parentKey={taxonomy.name} />
|
||||
</Collapsible>
|
||||
</div>
|
||||
))}
|
||||
</Stack>
|
||||
)
|
||||
: (
|
||||
<div className="d-flex justify-content-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button className="mt-3 ml-2" variant="outline-primary" size="sm" onClick={() => setShowManageTags(true)}>
|
||||
{intl.formatMessage(messages.manageTagsButton)}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card.Body>
|
||||
<Sheet
|
||||
position="right"
|
||||
show={showManageTags}
|
||||
onClose={onClose}
|
||||
>
|
||||
<ContentTagsDrawer
|
||||
id={contentId}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</Sheet>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
TagsSidebarBody.propTypes = {};
|
||||
|
||||
export default TagsSidebarBody;
|
||||
@@ -1,55 +0,0 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import TagsSidebarBody from './TagsSidebarBody';
|
||||
import { useContentTaxonomyTagsData } from '../data/apiHooks';
|
||||
import { contentTaxonomyTagsMock } from '../__mocks__';
|
||||
|
||||
const contentId = 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b';
|
||||
|
||||
jest.mock('../data/apiHooks', () => ({
|
||||
useContentTaxonomyTagsData: jest.fn(() => ({
|
||||
isSuccess: false,
|
||||
data: {},
|
||||
})),
|
||||
}));
|
||||
jest.mock('../ContentTagsDrawer', () => jest.fn(() => <div>Mocked ContentTagsDrawer</div>));
|
||||
|
||||
const RootWrapper = () => (
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
<TagsSidebarBody />
|
||||
</IntlProvider>
|
||||
);
|
||||
|
||||
describe('<TagSidebarBody>', () => {
|
||||
it('shows spinner before the content data query is complete', () => {
|
||||
render(<RootWrapper />);
|
||||
expect(screen.getByRole('status')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render data after wuery is complete', () => {
|
||||
useContentTaxonomyTagsData.mockReturnValue({
|
||||
isSuccess: true,
|
||||
data: contentTaxonomyTagsMock[contentId],
|
||||
});
|
||||
render(<RootWrapper />);
|
||||
const taxonomyButton = screen.getByRole('button', { name: /hierarchicaltaxonomy/i });
|
||||
expect(taxonomyButton).toBeInTheDocument();
|
||||
|
||||
/// ContentTagsDrawer must be closed
|
||||
expect(screen.queryByText('Mocked ContentTagsDrawer')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should open ContentTagsDrawer', () => {
|
||||
useContentTaxonomyTagsData.mockReturnValue({
|
||||
isSuccess: true,
|
||||
data: contentTaxonomyTagsMock[contentId],
|
||||
});
|
||||
render(<RootWrapper />);
|
||||
|
||||
const manageButton = screen.getByRole('button', { name: /manage tags/i });
|
||||
fireEvent.click(manageButton);
|
||||
|
||||
expect(screen.getByText('Mocked ContentTagsDrawer')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
.tags-sidebar {
|
||||
.tags-sidebar-body {
|
||||
.tags-sidebar-taxonomy {
|
||||
.collapsible-trigger {
|
||||
font-weight: bold;
|
||||
border: none;
|
||||
justify-content: start;
|
||||
padding-left: 0;
|
||||
padding-bottom: 0;
|
||||
|
||||
.collapsible-icon {
|
||||
order: -1;
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.collapsible-body {
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
// @ts-check
|
||||
import React from 'react';
|
||||
import { Stack } from '@openedx/paragon';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { useContentTagsCount } from '../../generic/data/apiHooks';
|
||||
import messages from '../messages';
|
||||
import TagCount from '../../generic/tag-count';
|
||||
|
||||
const TagsSidebarHeader = () => {
|
||||
const intl = useIntl();
|
||||
const contentId = useParams().blockId;
|
||||
|
||||
const {
|
||||
data: contentTagsCount,
|
||||
isSuccess: isContentTagsCountLoaded,
|
||||
} = useContentTagsCount(contentId || '');
|
||||
|
||||
return (
|
||||
<Stack
|
||||
className="course-unit-sidebar-header justify-content-between pb-1"
|
||||
direction="horizontal"
|
||||
>
|
||||
<h3 className="course-unit-sidebar-header-title m-0">
|
||||
{intl.formatMessage(messages.tagsSidebarTitle)}
|
||||
</h3>
|
||||
{ isContentTagsCountLoaded
|
||||
&& <TagCount count={contentTagsCount} />}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
TagsSidebarHeader.propTypes = {};
|
||||
|
||||
export default TagsSidebarHeader;
|
||||
@@ -1,39 +0,0 @@
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import TagsSidebarHeader from './TagsSidebarHeader';
|
||||
|
||||
const mockGetTagsCount = jest.fn();
|
||||
|
||||
jest.mock('../../generic/data/api', () => ({
|
||||
...jest.requireActual('../../generic/data/api'),
|
||||
getTagsCount: () => mockGetTagsCount(),
|
||||
}));
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useParams: () => ({ blockId: '123' }),
|
||||
}));
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
const RootWrapper = () => (
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TagsSidebarHeader />
|
||||
</QueryClientProvider>
|
||||
</IntlProvider>
|
||||
);
|
||||
|
||||
describe('<TagsSidebarHeader>', () => {
|
||||
it('should render count only after query is complete', async () => {
|
||||
let resolvePromise;
|
||||
mockGetTagsCount.mockReturnValueOnce(new Promise((resolve) => { resolvePromise = resolve; }));
|
||||
render(<RootWrapper />);
|
||||
expect(screen.getByRole('heading', { name: /unit tags/i })).toBeInTheDocument();
|
||||
expect(screen.queryByText('17')).not.toBeInTheDocument();
|
||||
resolvePromise({ 123: 17 });
|
||||
expect(await screen.findByText('17')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,50 +0,0 @@
|
||||
// @ts-check
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Icon } from '@openedx/paragon';
|
||||
import { Tag } from '@openedx/paragon/icons';
|
||||
|
||||
const TagsTree = ({ tags, rootDepth, parentKey }) => {
|
||||
if (Object.keys(tags).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Used to Generate tabs for the parents of this tree
|
||||
const tabsNumberArray = Array.from({ length: rootDepth }, (_, index) => index + 1);
|
||||
|
||||
return (
|
||||
<div className="tags-tree" key={parentKey}>
|
||||
{Object.keys(tags).map((key) => (
|
||||
<div className="mt-1.5 mb-1.5" key={key}>
|
||||
<div className="d-flex pl-2.5" key={key}>
|
||||
{
|
||||
tabsNumberArray.map((index) => <span className="d-inline-block ml-4" key={`${key}-${index}`} />)
|
||||
}
|
||||
<Icon src={Tag} className="mr-1 pb-1.5 text-info-500" />{key}
|
||||
</div>
|
||||
{ tags[key].children
|
||||
&& (
|
||||
<TagsTree
|
||||
tags={tags[key].children}
|
||||
rootDepth={rootDepth + 1}
|
||||
parentKey={key}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
TagsTree.propTypes = {
|
||||
tags: PropTypes.shape({}).isRequired,
|
||||
parentKey: PropTypes.string,
|
||||
rootDepth: PropTypes.number,
|
||||
};
|
||||
|
||||
TagsTree.defaultProps = {
|
||||
rootDepth: 0,
|
||||
parentKey: undefined,
|
||||
};
|
||||
|
||||
export default TagsTree;
|
||||
@@ -1,13 +0,0 @@
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import TagsTree from './TagsTree';
|
||||
import { contentTaxonomyTagsTreeMock } from '../__mocks__';
|
||||
|
||||
describe('<TagsTree>', () => {
|
||||
it('should render component and tags correctly', () => {
|
||||
render(<TagsTree tags={contentTaxonomyTagsTreeMock} />);
|
||||
expect(screen.getByText('hierarchical taxonomy tag 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('hierarchical taxonomy tag 2.13')).toBeInTheDocument();
|
||||
expect(screen.getByText('hierarchical taxonomy tag 3.4.50')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,13 +0,0 @@
|
||||
import TagsSidebarHeader from './TagsSidebarHeader';
|
||||
import TagsSidebarBody from './TagsSidebarBody';
|
||||
|
||||
const TagsSidebarControls = () => (
|
||||
<>
|
||||
<TagsSidebarHeader />
|
||||
<TagsSidebarBody />
|
||||
</>
|
||||
);
|
||||
|
||||
TagsSidebarControls.propTypes = {};
|
||||
|
||||
export default TagsSidebarControls;
|
||||
@@ -1,36 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { injectIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
const AriaLiveRegion = ({
|
||||
isCourseLaunchChecklistLoading,
|
||||
isCourseBestPracticeChecklistLoading,
|
||||
enableQuality,
|
||||
}) => {
|
||||
const courseLaunchLoadingMessage = isCourseLaunchChecklistLoading
|
||||
? <FormattedMessage {...messages.launchChecklistLoadingLabel} />
|
||||
: <FormattedMessage {...messages.launchChecklistDoneLoadingLabel} />;
|
||||
|
||||
const courseBestPracticesLoadingMessage = isCourseBestPracticeChecklistLoading
|
||||
? <FormattedMessage {...messages.bestPracticesChecklistLoadingLabel} />
|
||||
: <FormattedMessage {...messages.bestPracticesChecklistDoneLoadingLabel} />;
|
||||
|
||||
return (
|
||||
<div className="sr-only" aria-live="polite" role="status">
|
||||
<div>
|
||||
{courseLaunchLoadingMessage}
|
||||
</div>
|
||||
{enableQuality ? <div>{courseBestPracticesLoadingMessage}</div> : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
AriaLiveRegion.propTypes = {
|
||||
isCourseLaunchChecklistLoading: PropTypes.bool.isRequired,
|
||||
isCourseBestPracticeChecklistLoading: PropTypes.bool.isRequired,
|
||||
enableQuality: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(AriaLiveRegion);
|
||||
@@ -1,70 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
ActionRow,
|
||||
Button,
|
||||
Hyperlink,
|
||||
Icon,
|
||||
} from '@openedx/paragon';
|
||||
import { CheckCircle, RadioButtonUnchecked } from '@openedx/paragon/icons';
|
||||
import messages from './messages';
|
||||
|
||||
const ChecklistItemBody = ({
|
||||
checkId,
|
||||
isCompleted,
|
||||
updateLink,
|
||||
// injected
|
||||
intl,
|
||||
}) => (
|
||||
<ActionRow>
|
||||
<div className="mr-3" id={`icon-${checkId}`} data-testid={`icon-${checkId}`}>
|
||||
{isCompleted ? (
|
||||
<Icon
|
||||
data-testid="completed-icon"
|
||||
src={CheckCircle}
|
||||
className="text-success"
|
||||
style={{ height: '32px', width: '32px' }}
|
||||
screenReaderText={intl.formatMessage(messages.completedItemLabel)}
|
||||
/>
|
||||
) : (
|
||||
<Icon
|
||||
data-testid="uncompleted-icon"
|
||||
src={RadioButtonUnchecked}
|
||||
style={{ height: '32px', width: '32px' }}
|
||||
screenReaderText={intl.formatMessage(messages.uncompletedItemLabel)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div>
|
||||
<FormattedMessage {...messages[`${checkId}ShortDescription`]} />
|
||||
</div>
|
||||
<div className="small">
|
||||
<FormattedMessage {...messages[`${checkId}LongDescription`]} />
|
||||
</div>
|
||||
</div>
|
||||
<ActionRow.Spacer />
|
||||
{updateLink && (
|
||||
<Hyperlink destination={updateLink} data-testid="update-hyperlink">
|
||||
<Button size="sm">
|
||||
<FormattedMessage {...messages.updateLinkLabel} />
|
||||
</Button>
|
||||
</Hyperlink>
|
||||
)}
|
||||
</ActionRow>
|
||||
);
|
||||
|
||||
ChecklistItemBody.defaultProps = {
|
||||
updateLink: null,
|
||||
};
|
||||
|
||||
ChecklistItemBody.propTypes = {
|
||||
checkId: PropTypes.string.isRequired,
|
||||
isCompleted: PropTypes.bool.isRequired,
|
||||
updateLink: PropTypes.string,
|
||||
// injected
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(ChecklistItemBody);
|
||||
@@ -1,123 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { injectIntl, FormattedMessage, FormattedNumber } from '@edx/frontend-platform/i18n';
|
||||
import { Hyperlink, Icon } from '@openedx/paragon';
|
||||
import { ModeComment } from '@openedx/paragon/icons';
|
||||
import messages from './messages';
|
||||
|
||||
const ChecklistItemComment = ({
|
||||
checkId,
|
||||
outlineUrl,
|
||||
data,
|
||||
}) => {
|
||||
const commentWrapper = (comment) => (
|
||||
<div className="row m-0 mt-3 pt-3 border-top align-items-center" data-identifier="comment">
|
||||
<div className="mr-4">
|
||||
<Icon src={ModeComment} size="lg" style={{ height: '32px', width: '32px' }} />
|
||||
</div>
|
||||
<div className="small">
|
||||
{comment}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (checkId === 'gradingPolicy') {
|
||||
const sumOfWeights = data?.grades.sumOfWeights || 0;
|
||||
const showGradingCommentSection = Object.keys(data).length > 0 && sumOfWeights !== 1;
|
||||
|
||||
const weightSumPercentage = (sumOfWeights * 100).toFixed(2);
|
||||
const comment = (
|
||||
<FormattedMessage
|
||||
{...messages.gradingPolicyComment}
|
||||
values={{
|
||||
percent: (
|
||||
<FormattedNumber
|
||||
maximumFractionDigits={2}
|
||||
minimumFractionDigits={2}
|
||||
value={weightSumPercentage}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
return (showGradingCommentSection ? (
|
||||
commentWrapper(comment)
|
||||
) : null);
|
||||
}
|
||||
|
||||
if (checkId === 'assignmentDeadlines') {
|
||||
const showDeadlinesCommentSection = Object.keys(data).length > 0
|
||||
&& (
|
||||
data.assignments.assignmentsWithDatesBeforeStart.length > 0
|
||||
|| data?.assignments.assignmentsWithDatesAfterEnd.length > 0
|
||||
|| data?.assignments.assignmentsWithOraDatesBeforeStart.length > 0
|
||||
|| data?.assignments.assignmentsWithOraDatesAfterEnd.length > 0
|
||||
);
|
||||
|
||||
const allGradedAssignmentsOutsideDateRange = [].concat(
|
||||
data?.assignments.assignmentsWithDatesBeforeStart,
|
||||
data?.assignments.assignmentsWithDatesAfterEnd,
|
||||
data?.assignments.assignmentsWithOraDatesBeforeStart,
|
||||
data?.assignments.assignmentsWithOraDatesAfterEnd,
|
||||
);
|
||||
|
||||
// de-dupe in case one assignment has multiple violations
|
||||
const assignmentsMap = new Map();
|
||||
allGradedAssignmentsOutsideDateRange.forEach(
|
||||
(assignment) => { assignmentsMap.set(assignment.id, assignment); },
|
||||
);
|
||||
const gradedAssignmentsOutsideDateRange = [];
|
||||
assignmentsMap.forEach(
|
||||
(value) => {
|
||||
gradedAssignmentsOutsideDateRange.push(value);
|
||||
},
|
||||
);
|
||||
|
||||
const comment = (
|
||||
<>
|
||||
<FormattedMessage {...messages.assignmentDeadlinesComment} />
|
||||
<ul className="assignment-list">
|
||||
{gradedAssignmentsOutsideDateRange.map(assignment => (
|
||||
<li className="assignment-list-item" key={assignment.id}>
|
||||
<Hyperlink
|
||||
content={assignment.displayName}
|
||||
destination={`${outlineUrl}#${assignment.id}`}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
return (showDeadlinesCommentSection ? (
|
||||
commentWrapper(comment)
|
||||
) : null);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
ChecklistItemComment.propTypes = {
|
||||
checkId: PropTypes.string.isRequired,
|
||||
outlineUrl: PropTypes.string.isRequired,
|
||||
data: PropTypes.oneOfType([
|
||||
PropTypes.shape({
|
||||
grades: PropTypes.shape({
|
||||
sumOfWeights: PropTypes.number,
|
||||
}),
|
||||
}).isRequired,
|
||||
PropTypes.shape({
|
||||
assignments: PropTypes.shape({
|
||||
totalNumber: PropTypes.number,
|
||||
totalVisible: PropTypes.number,
|
||||
/* eslint-disable react/forbid-prop-types */
|
||||
assignmentsWithDatesBeforeStart: PropTypes.array,
|
||||
assignmentsWithDatesAfterEnd: PropTypes.array,
|
||||
assignmentsWithOraDatesBeforeStart: PropTypes.array,
|
||||
assignmentsWithOraDatesAfterEnd: PropTypes.array,
|
||||
/* eslint-enable react/forbid-prop-types */
|
||||
}),
|
||||
}).isRequired,
|
||||
]).isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(ChecklistItemComment);
|
||||
@@ -1,142 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { injectIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Container, Stack } from '@openedx/paragon';
|
||||
|
||||
import { LoadingSpinner } from '../../generic/Loading';
|
||||
import { getCompletionCount, useChecklistState } from './hooks';
|
||||
import ChecklistItemBody from './ChecklistItemBody';
|
||||
import ChecklistItemComment from './ChecklistItemComment';
|
||||
import { checklistItems } from './utils/courseChecklistData';
|
||||
|
||||
const ChecklistSection = ({
|
||||
dataHeading,
|
||||
data,
|
||||
idPrefix,
|
||||
isLoading,
|
||||
updateLinks,
|
||||
}) => {
|
||||
const dataList = checklistItems[idPrefix];
|
||||
const getCompletionCountID = () => (`${idPrefix}-completion-count`);
|
||||
const { checklistState } = useChecklistState({ data, dataList });
|
||||
const { checks, totalCompletedChecks, values } = checklistState;
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<h3 aria-describedby={getCompletionCountID()} className="lead">{dataHeading}</h3>
|
||||
{isLoading ? (
|
||||
<div className="row justify-content-center" data-testid="loading-spinner">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div data-testid="completion-subheader">
|
||||
{getCompletionCount(checks, totalCompletedChecks)}
|
||||
</div>
|
||||
<Stack gap={3} className="mt-3">
|
||||
{checks.map(check => {
|
||||
const checkId = check.id;
|
||||
const isCompleted = values[checkId];
|
||||
const updateLink = updateLinks?.[checkId];
|
||||
const outlineUrl = updateLinks.outline;
|
||||
return (
|
||||
<div
|
||||
className={`bg-white border py-3 px-4 ${isCompleted && 'checklist-item-complete'}`}
|
||||
id={`checklist-item-${checkId}`}
|
||||
data-testid={`checklist-item-${checkId}`}
|
||||
key={checkId}
|
||||
>
|
||||
<ChecklistItemBody {...{ checkId, isCompleted, updateLink }} />
|
||||
<div data-testid={`comment-section-${checkId}`}>
|
||||
<ChecklistItemComment {...{ checkId, outlineUrl, data }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
ChecklistSection.defaultProps = {
|
||||
updateLinks: {},
|
||||
data: {},
|
||||
};
|
||||
|
||||
ChecklistSection.propTypes = {
|
||||
dataHeading: PropTypes.string.isRequired,
|
||||
data: PropTypes.oneOfType([
|
||||
PropTypes.shape({
|
||||
assignments: PropTypes.shape({
|
||||
totalNumber: PropTypes.number,
|
||||
totalVisible: PropTypes.number,
|
||||
numWithDatesBeforeEnd: PropTypes.number,
|
||||
numWithDates: PropTypes.number,
|
||||
numWithDatesAfterStart: PropTypes.number,
|
||||
}),
|
||||
dates: PropTypes.shape({
|
||||
hasStartDate: PropTypes.bool,
|
||||
hasEndDate: PropTypes.bool,
|
||||
}),
|
||||
updates: PropTypes.shape({
|
||||
hasUpdate: PropTypes.bool,
|
||||
}),
|
||||
certificates: PropTypes.shape({
|
||||
isEnabled: PropTypes.bool,
|
||||
isActivated: PropTypes.bool,
|
||||
hasCertificate: PropTypes.bool,
|
||||
}),
|
||||
grades: PropTypes.shape({
|
||||
sumOfWeights: PropTypes.number,
|
||||
}),
|
||||
is_self_paced: PropTypes.bool,
|
||||
}).isRequired,
|
||||
PropTypes.shape({
|
||||
assignments: PropTypes.shape({
|
||||
totalNumber: PropTypes.number,
|
||||
totalVisible: PropTypes.number,
|
||||
/* eslint-disable react/forbid-prop-types */
|
||||
assignmentsWithDatesBeforeStart: PropTypes.array,
|
||||
assignmentsWithDatesAfterEnd: PropTypes.array,
|
||||
assignmentsWithOraDatesBeforeStart: PropTypes.array,
|
||||
assignmentsWithOraDatesAfterEnd: PropTypes.array,
|
||||
/* eslint-enable react/forbid-prop-types */
|
||||
}),
|
||||
dates: PropTypes.shape({
|
||||
hasStartDate: PropTypes.bool,
|
||||
hasEndDate: PropTypes.bool,
|
||||
}),
|
||||
updates: PropTypes.shape({
|
||||
hasUpdate: PropTypes.bool,
|
||||
}),
|
||||
certificates: PropTypes.shape({
|
||||
isEnabled: PropTypes.bool,
|
||||
isActivated: PropTypes.bool,
|
||||
hasCertificate: PropTypes.bool,
|
||||
}),
|
||||
grades: PropTypes.shape({
|
||||
hasGradingPolicy: PropTypes.bool,
|
||||
sumOfWeights: PropTypes.number,
|
||||
}),
|
||||
proctoring: PropTypes.shape({
|
||||
needsProctoringEscalationEmail: PropTypes.bool,
|
||||
hasProctoringEscalation_email: PropTypes.bool,
|
||||
}),
|
||||
isSelfPaced: PropTypes.bool,
|
||||
}).isRequired,
|
||||
]),
|
||||
idPrefix: PropTypes.string.isRequired,
|
||||
isLoading: PropTypes.bool.isRequired,
|
||||
updateLinks: PropTypes.shape({
|
||||
welcomeMessage: PropTypes.string,
|
||||
gradingPolicy: PropTypes.string,
|
||||
certificate: PropTypes.string,
|
||||
courseDates: PropTypes.string,
|
||||
proctoringEmail: PropTypes.string,
|
||||
outline: PropTypes.string,
|
||||
}),
|
||||
};
|
||||
|
||||
export default injectIntl(ChecklistSection);
|
||||
@@ -1,22 +0,0 @@
|
||||
.assignment-list-item {
|
||||
list-style: none;
|
||||
display: inline-block;
|
||||
|
||||
&::after {
|
||||
content: ",";
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
&::after { content: ""; }
|
||||
}
|
||||
}
|
||||
|
||||
.assignment-list {
|
||||
display: inline;
|
||||
padding-inline-start: map-get($spacers, 1);
|
||||
}
|
||||
|
||||
//complete checklist item style
|
||||
.checklist-item-complete {
|
||||
box-shadow: -5px 0 0 0 $success-500;
|
||||
}
|
||||
@@ -1,255 +0,0 @@
|
||||
/* eslint-disable */
|
||||
import {
|
||||
render,
|
||||
within,
|
||||
screen,
|
||||
} from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { camelCaseObject, initializeMockApp } from '@edx/frontend-platform';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import initializeStore from '../../store';
|
||||
import { initialState,generateCourseLaunchData } from '../factories/mockApiResponses';
|
||||
import messages from './messages';
|
||||
import ChecklistSection from './index';
|
||||
import { checklistItems } from './utils/courseChecklistData';
|
||||
import getUpdateLinks from '../utils';
|
||||
|
||||
const testData = camelCaseObject(generateCourseLaunchData());
|
||||
|
||||
|
||||
const defaultProps = {
|
||||
data: testData,
|
||||
dataHeading: 'Test checklist',
|
||||
idPrefix: 'launchChecklist',
|
||||
updateLinks: getUpdateLinks('courseId'),
|
||||
isLoading: false,
|
||||
};
|
||||
|
||||
const testChecklistData = checklistItems[defaultProps.idPrefix];
|
||||
|
||||
const completedItemIds = ['welcomeMessage', 'courseDates']
|
||||
|
||||
const renderComponent = (props) => {
|
||||
render(
|
||||
<IntlProvider locale="en">
|
||||
<AppProvider store={store}>
|
||||
<ChecklistSection {...props} />
|
||||
</AppProvider>
|
||||
</IntlProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
let store;
|
||||
|
||||
describe('ChecklistSection', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: false,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore(initialState);
|
||||
});
|
||||
|
||||
it('a heading using the dataHeading prop', () => {
|
||||
renderComponent(defaultProps);
|
||||
|
||||
expect(screen.getByText(defaultProps.dataHeading)).toBeVisible();
|
||||
});
|
||||
|
||||
it('completion count text', () => {
|
||||
renderComponent(defaultProps);
|
||||
const completionText = `${completedItemIds.length}/6 completed`;
|
||||
expect(screen.getByTestId('completion-subheader').textContent).toEqual(completionText);
|
||||
});
|
||||
|
||||
it('a loading spinner when isLoading prop is true', () => {
|
||||
renderComponent({ ...defaultProps, isLoading: true });
|
||||
|
||||
const completionSubheader = screen.queryByTestId('completion-subheader');
|
||||
expect(completionSubheader).toBeNull();
|
||||
|
||||
const loadingSpinner = screen.getByTestId('loading-spinner');
|
||||
expect(loadingSpinner).toBeVisible();
|
||||
});
|
||||
|
||||
it('the correct number of checks', () => {
|
||||
renderComponent(defaultProps);
|
||||
|
||||
const listItems = screen.getAllByTestId('checklist-item', { exact: false });
|
||||
expect(listItems).toHaveLength(6);
|
||||
});
|
||||
|
||||
it('welcomeMessage comment section should be null', () => {
|
||||
renderComponent(defaultProps);
|
||||
|
||||
const comment = screen.getByTestId('comment-section-welcomeMessage');
|
||||
expect(comment.children).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('certificate comment section should be null', () => {
|
||||
renderComponent(defaultProps);
|
||||
|
||||
const comment = screen.getByTestId('comment-section-certificate');
|
||||
expect(comment.children).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('courseDates comment section should be null', () => {
|
||||
renderComponent(defaultProps);
|
||||
|
||||
const comment = screen.getByTestId('comment-section-courseDates');
|
||||
expect(comment.children).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('proctoringEmail comment section should be null', () => {
|
||||
renderComponent(defaultProps);
|
||||
|
||||
const comment = screen.getByTestId('comment-section-proctoringEmail');
|
||||
expect(comment.children).toHaveLength(0);
|
||||
});
|
||||
|
||||
describe('gradingPolicy comment section', () => {
|
||||
it('should be null if sum of weights is equal to 1', () => {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
data: {
|
||||
...defaultProps.data,
|
||||
grades: {
|
||||
...defaultProps.data.grades,
|
||||
sumOfWeights: 1,
|
||||
}
|
||||
},
|
||||
};
|
||||
renderComponent(props);
|
||||
|
||||
const comment = screen.getByTestId('comment-section-gradingPolicy');
|
||||
expect(comment.children).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should have comment section', () => {
|
||||
renderComponent(defaultProps);
|
||||
|
||||
const comment = screen.getByTestId('comment-section-gradingPolicy');
|
||||
expect(comment.children).toHaveLength(1);
|
||||
|
||||
expect(screen.getByText(
|
||||
'Your current grading policy adds up to',
|
||||
{ exact: false },
|
||||
)).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
describe('assignmentDeadlines comment section', () => {
|
||||
it('should be null if assignments with dates before start and after end are empty', () => {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
data: {
|
||||
...defaultProps.data,
|
||||
assignments: {
|
||||
...defaultProps.data.assignments,
|
||||
assignmentsWithDatesAfterEnd: [],
|
||||
assignmentsWithOraDatesBeforeStart: [],
|
||||
}
|
||||
},
|
||||
};
|
||||
renderComponent(props);
|
||||
|
||||
const comment = screen.getByTestId('comment-section-assignmentDeadlines');
|
||||
expect(comment.children).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should have comment section', () => {
|
||||
renderComponent(defaultProps);
|
||||
|
||||
const comment = screen.getByTestId('comment-section-assignmentDeadlines');
|
||||
const assigmentLinks = within(comment).getAllByRole('link');
|
||||
|
||||
expect(comment.children).toHaveLength(1);
|
||||
|
||||
expect(screen.getByText(
|
||||
messages.assignmentDeadlinesComment.defaultMessage,
|
||||
{ exact: false },
|
||||
)).toBeVisible();
|
||||
|
||||
expect(assigmentLinks).toHaveLength(2);
|
||||
|
||||
expect(assigmentLinks[0].textContent).toEqual('Subsection');
|
||||
|
||||
expect(assigmentLinks[1].textContent).toEqual('ORA subsection');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
testChecklistData.forEach((check) => {
|
||||
describe(`check with id '${check.id}'`, () => {
|
||||
let checkItem;
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: false,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore(initialState);
|
||||
renderComponent(defaultProps);
|
||||
checkItem = screen.getAllByTestId(`checklist-item-${check.id}`);
|
||||
});
|
||||
|
||||
it('renders', () => {
|
||||
expect(checkItem).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('has correct icon', () => {
|
||||
const icon = screen.getAllByTestId(`icon-${check.id}`)
|
||||
|
||||
expect(icon).toHaveLength(1);
|
||||
|
||||
const { queryByTestId } = within(icon[0]);
|
||||
if (completedItemIds.includes(check.id)) {
|
||||
expect(queryByTestId('completed-icon')).not.toBeNull();
|
||||
} else {
|
||||
expect(queryByTestId('uncompleted-icon')).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('has correct short description', () => {
|
||||
const { getByText } = within(checkItem[0]);
|
||||
const shortDescription = messages[`${check.id}ShortDescription`].defaultMessage;
|
||||
expect(getByText(shortDescription)).toBeVisible();
|
||||
});
|
||||
|
||||
it('has correct long description', () => {
|
||||
const { getByText } = within(checkItem[0]);
|
||||
const longDescription = messages[`${check.id}LongDescription`].defaultMessage;
|
||||
expect(getByText(longDescription)).toBeVisible();
|
||||
});
|
||||
|
||||
describe('has correct link', () => {
|
||||
const links = getUpdateLinks('courseId')
|
||||
const shouldShowLink = Object.keys(links).includes(check.id);
|
||||
|
||||
if (shouldShowLink) {
|
||||
it('with a Hyperlink', () => {
|
||||
const { getByRole, getByText } = within(checkItem[0]);
|
||||
|
||||
expect(getByText('Update')).toBeVisible();
|
||||
|
||||
expect(getByRole('link').href).toMatch(links[check.id]);
|
||||
});
|
||||
} else {
|
||||
it('without a Hyperlink', () => {
|
||||
const { queryByText } = within(checkItem[0]);
|
||||
|
||||
expect(queryByText('Update')).toBeNull();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,71 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import messages from './messages';
|
||||
import getFilteredChecklist from './utils/getFilteredChecklist';
|
||||
import getValidatedValue from './utils/getValidatedValue';
|
||||
|
||||
export const useChecklistState = ({ data, dataList }) => {
|
||||
const [checklistState, setChecklistState] = useState({
|
||||
checks: [],
|
||||
totalCompletedChecks: 0,
|
||||
values: {},
|
||||
});
|
||||
|
||||
const updateChecklistState = () => {
|
||||
if (Object.keys(data).length > 0) {
|
||||
const { isSelfPaced } = data;
|
||||
const hasCertificatesEnabled = data.certificates && data.certificates.isEnabled;
|
||||
const hasHighlightsEnabled = data.sections && data.sections.highlightsEnabled;
|
||||
const needsProctoringEscalationEmail = (
|
||||
data.proctoring && data.proctoring.needsProctoringEscalationEmail
|
||||
);
|
||||
const checks = getFilteredChecklist(
|
||||
dataList,
|
||||
isSelfPaced,
|
||||
hasCertificatesEnabled,
|
||||
hasHighlightsEnabled,
|
||||
needsProctoringEscalationEmail,
|
||||
);
|
||||
|
||||
const values = {};
|
||||
let totalCompletedChecks = 0;
|
||||
|
||||
checks.forEach((check) => {
|
||||
const value = getValidatedValue(data, check.id);
|
||||
|
||||
if (value) {
|
||||
totalCompletedChecks += 1;
|
||||
}
|
||||
|
||||
values[check.id] = value;
|
||||
});
|
||||
|
||||
setChecklistState({
|
||||
checks,
|
||||
totalCompletedChecks,
|
||||
values,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
updateChecklistState();
|
||||
}, [data]);
|
||||
|
||||
return {
|
||||
checklistState,
|
||||
setChecklistState,
|
||||
};
|
||||
};
|
||||
|
||||
export const getCompletionCount = (checks, totalCompletedChecks) => {
|
||||
const totalChecks = Object.values(checks).length;
|
||||
|
||||
return (
|
||||
<FormattedMessage
|
||||
{...messages.completionCountLabel}
|
||||
values={{ completed: totalCompletedChecks, total: totalChecks }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,3 +0,0 @@
|
||||
import ChecklistSection from './ChecklistSection';
|
||||
|
||||
export default ChecklistSection;
|
||||
@@ -1,146 +0,0 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
welcomeMessageShortDescription: {
|
||||
id: 'welcomeMessageShortDescription',
|
||||
defaultMessage: 'Add a welcome message',
|
||||
description: 'Label for a section that describes a welcome message for a course',
|
||||
},
|
||||
welcomeMessageLongDescription: {
|
||||
id: 'welcomeMessageLongDescription',
|
||||
defaultMessage: 'Personally welcome learners into your course and prepare learners for a positive course experience.',
|
||||
description: 'Description for a section that prompts a user to enter a welcome message for a course',
|
||||
},
|
||||
gradingPolicyShortDescription: {
|
||||
id: 'gradingPolicyShortDescription',
|
||||
defaultMessage: 'Create your course grading policy',
|
||||
description: 'Label for a section that describes a grading policy for a course',
|
||||
},
|
||||
gradingPolicyLongDescription: {
|
||||
id: 'gradingPolicyLongDescription',
|
||||
defaultMessage: 'Establish your grading policy, including assignment types and passing score. All assignments add up to 100%.',
|
||||
description: 'Description for a section that prompts a user to enter a grading policy for a course',
|
||||
},
|
||||
gradingPolicyComment: {
|
||||
id: 'gradingPolicyComment',
|
||||
defaultMessage: 'Your current grading policy adds up to {percent}%.',
|
||||
description: 'Description for a section that displays a course\'s grading policy total',
|
||||
},
|
||||
certificateShortDescription: {
|
||||
id: 'certificateShortDescription',
|
||||
defaultMessage: 'Enable your certificate',
|
||||
description: 'Label for a section that describes a certificate for completing a course',
|
||||
},
|
||||
certificateLongDescription: {
|
||||
id: 'certificateLongDescription',
|
||||
defaultMessage: 'Make sure that all text is correct, signatures have been uploaded, and the certificate has been activated.',
|
||||
description: 'Description for a section that prompts a user to create a course completion certificate',
|
||||
},
|
||||
courseDatesShortDescription: {
|
||||
id: 'courseDatesShortDescription',
|
||||
defaultMessage: 'Set important course dates',
|
||||
description: 'Label for a section that describes a certificate for completing a course',
|
||||
},
|
||||
courseDatesLongDescription: {
|
||||
id: 'courseDatesLongDescription',
|
||||
defaultMessage: 'Establish your course schedule, including when the course starts and ends.',
|
||||
description: 'Description for a section that prompts a user to set up a course schedule',
|
||||
},
|
||||
assignmentDeadlinesShortDescription: {
|
||||
id: 'assignmentDeadlinesShortDescription',
|
||||
defaultMessage: 'Validate assignment deadlines',
|
||||
description: 'Label for a section that describes course assignment deadlines',
|
||||
},
|
||||
assignmentDeadlinesLongDescription: {
|
||||
id: 'assignmentDeadlinesLongDescription',
|
||||
defaultMessage: 'Ensure all assignment deadlines are between course start and end dates.',
|
||||
description: 'Description for a section that prompts a user to enter course assignment deadlines',
|
||||
},
|
||||
assignmentDeadlinesComment: {
|
||||
id: 'assignmentDeadlinesComment',
|
||||
defaultMessage: 'The following assignments have deadlines that do not fall between course start and end date:',
|
||||
description: 'Description for a section that displays which assignments are outside of a course\'s start and end date',
|
||||
},
|
||||
videoDurationShortDescription: {
|
||||
id: 'videoDurationShortDescription',
|
||||
defaultMessage: 'Check video duration',
|
||||
description: 'Label for a section that describes video durations',
|
||||
},
|
||||
videoDurationLongDescription: {
|
||||
id: 'videoDurationLongDescription',
|
||||
defaultMessage: 'Learners engage best with short videos followed by opportunities to practice. Ensure that 80% or more of course videos are less than 10 minutes long.',
|
||||
description: 'Description for a section that prompts a user to follow best practices for video length',
|
||||
},
|
||||
mobileFriendlyVideoShortDescription: {
|
||||
id: 'mobileFriendlyVideoShortDescription',
|
||||
defaultMessage: 'Create mobile-friendly video',
|
||||
description: 'Label for a section that describes mobile friendly videos',
|
||||
},
|
||||
mobileFriendlyVideoLongDescription: {
|
||||
id: 'mobileFriendlyVideoLongDescription',
|
||||
defaultMessage: 'Mobile-friendly videos can be viewed across all supported devices. Ensure that at least 90% of course videos are mobile friendly by uploading course videos to the edX video pipeline.',
|
||||
description: 'Description for a section that prompts a user to follow best practices for mobile friendly videos',
|
||||
},
|
||||
diverseSequencesShortDescription: {
|
||||
id: 'diverseSequencesShortDescription',
|
||||
defaultMessage: 'Build diverse learning sequences',
|
||||
description: 'Label for a section that describes diverse sequences of educational content',
|
||||
},
|
||||
diverseSequencesLongDescription: {
|
||||
id: 'diverseSequencesLongDescription',
|
||||
defaultMessage: 'Research shows that a diverse content experience drives learner engagement. We recommend that 80% or more of your learning sequences or subsections include multiple content types (such as video, discussion, or problem).',
|
||||
description: 'Description for a section that prompts a user to follow best practices diverse sequences of educational content',
|
||||
},
|
||||
weeklyHighlightsShortDescription: {
|
||||
id: 'weeklyHighlightsShortDescription',
|
||||
defaultMessage: 'Set weekly highlights',
|
||||
description: 'Label for a section that describes weekly highlights',
|
||||
},
|
||||
weeklyHighlightsLongDescription: {
|
||||
id: 'weeklyHighlightsLongDescription',
|
||||
defaultMessage: 'Enable and specify weekly highlights to keep learners engaged and on track in your course.',
|
||||
description: 'Description for a section that prompts a user to follow best practices for course weekly highlights',
|
||||
},
|
||||
unitDepthShortDescription: {
|
||||
id: 'unitDepthShortDescription',
|
||||
defaultMessage: 'Manage unit depth',
|
||||
description: 'Label for a section that describes course unit depth',
|
||||
},
|
||||
unitDepthLongDescription: {
|
||||
id: 'unitDepthLongDescription',
|
||||
defaultMessage: 'Breaking up course content into manageable pieces promotes learner engagement. We recommend units contain no more than three components.',
|
||||
description: 'Description for a section that prompts a user to follow best practices for course unit depth',
|
||||
},
|
||||
proctoringEmailShortDescription: {
|
||||
id: 'proctoringEmailShortDescription',
|
||||
defaultMessage: 'Add a proctortrack escalation email',
|
||||
description: 'Label for a section that describes proctoring escalation email',
|
||||
},
|
||||
proctoringEmailLongDescription: {
|
||||
id: 'proctoringEmailLongDescription',
|
||||
defaultMessage: 'Courses using Proctortrack require an escalation email. Ensure learners and Support can contact your course team regarding proctoring issues (e.g. appeals, exam resets, etc).',
|
||||
description: 'Description for a section that prompts the user to add a Proctortrack escalation email for the course',
|
||||
},
|
||||
updateLinkLabel: {
|
||||
id: 'updateLinkLabel',
|
||||
defaultMessage: 'Update',
|
||||
description: 'Label for a link that takes the user to a page where they can update settings',
|
||||
},
|
||||
completionCountLabel: {
|
||||
id: 'completionCountLabel',
|
||||
defaultMessage: '{completed}/{total} completed',
|
||||
description: 'Label that describes how many tasks have been completed out of a total number of tasks',
|
||||
},
|
||||
completedItemLabel: {
|
||||
id: 'completedItemLabel',
|
||||
defaultMessage: 'completed',
|
||||
description: 'Label that describes a completed task',
|
||||
},
|
||||
uncompletedItemLabel: {
|
||||
id: 'uncompletedItemLabel',
|
||||
defaultMessage: 'uncompleted',
|
||||
description: 'Label that describes an uncompleted task',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -1,56 +0,0 @@
|
||||
export const filters = {
|
||||
ALL: 'ALL',
|
||||
SELF_PACED: 'SELF_PACED',
|
||||
INSTRUCTOR_PACED: 'INSTRUCTOR_PACED',
|
||||
};
|
||||
|
||||
export const checklistItems = {
|
||||
launchChecklist: [
|
||||
{
|
||||
id: 'welcomeMessage',
|
||||
pacingTypeFilter: filters.ALL,
|
||||
},
|
||||
{
|
||||
id: 'gradingPolicy',
|
||||
pacingTypeFilter: filters.ALL,
|
||||
},
|
||||
{
|
||||
id: 'certificate',
|
||||
pacingTypeFilter: filters.ALL,
|
||||
},
|
||||
{
|
||||
id: 'courseDates',
|
||||
pacingTypeFilter: filters.ALL,
|
||||
},
|
||||
{
|
||||
id: 'assignmentDeadlines',
|
||||
pacingTypeFilter: filters.INSTRUCTOR_PACED,
|
||||
},
|
||||
{
|
||||
id: 'proctoringEmail',
|
||||
pacingTypeFilter: filters.ALL,
|
||||
},
|
||||
],
|
||||
bestPracticesChecklist: [
|
||||
{
|
||||
id: 'videoDuration',
|
||||
pacingTypeFilter: filters.ALL,
|
||||
},
|
||||
{
|
||||
id: 'mobileFriendlyVideo',
|
||||
pacingTypeFilter: filters.ALL,
|
||||
},
|
||||
{
|
||||
id: 'diverseSequences',
|
||||
pacingTypeFilter: filters.ALL,
|
||||
},
|
||||
{
|
||||
id: 'weeklyHighlights',
|
||||
pacingTypeFilter: filters.SELF_PACED,
|
||||
},
|
||||
{
|
||||
id: 'unitDepth',
|
||||
pacingTypeFilter: filters.ALL,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -1,76 +0,0 @@
|
||||
export const hasWelcomeMessage = updates => (
|
||||
updates.hasUpdate
|
||||
);
|
||||
|
||||
export const hasGradingPolicy = grades => (
|
||||
grades.hasGradingPolicy
|
||||
&& parseFloat(grades.sumOfWeights.toPrecision(2), 10) === 1.0
|
||||
);
|
||||
|
||||
export const hasCertificate = certificates => (
|
||||
certificates.isActivated && certificates.hasCertificate
|
||||
);
|
||||
|
||||
export const hasDates = dates => (
|
||||
dates.hasStartDate && dates.hasEndDate
|
||||
);
|
||||
|
||||
export const hasAssignmentDeadlines = (assignments, dates) => {
|
||||
if (!hasDates(dates)) {
|
||||
return false;
|
||||
} if (assignments.totalNumber === 0) {
|
||||
return false;
|
||||
} if (assignments.assignmentsWithDatesBeforeStart.length > 0) {
|
||||
return false;
|
||||
} if (assignments.assignmentsWithDatesAfterEnd.length > 0) {
|
||||
return false;
|
||||
} if (assignments.assignmentsWithOraDatesBeforeStart.length > 0) {
|
||||
return false;
|
||||
} if (assignments.assignmentsWithOraDatesAfterEnd.length > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const hasShortVideoDuration = (videos) => {
|
||||
if (videos.totalNumber === 0) {
|
||||
return true;
|
||||
} if (videos.totalNumber > 0 && videos.durations.median <= 600) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const hasMobileFriendlyVideos = (videos) => {
|
||||
if (videos.totalNumber === 0) {
|
||||
return true;
|
||||
} if (videos.totalNumber > 0 && (videos.numMobileEncoded / videos.totalNumber) >= 0.9) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const hasDiverseSequences = (subsections) => {
|
||||
if (subsections.totalVisible === 0) {
|
||||
return false;
|
||||
} if (subsections.totalVisible > 0) {
|
||||
return ((subsections.numWithOneBlockType / subsections.totalVisible) < 0.2);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const hasWeeklyHighlights = sections => (
|
||||
sections.highlightsActiveForCourse && sections.highlightsEnabled
|
||||
);
|
||||
|
||||
export const hasShortUnitDepth = units => (
|
||||
units.numBlocks.median <= 3
|
||||
);
|
||||
|
||||
export const hasProctoringEscalationEmail = proctoring => (
|
||||
proctoring.hasProctoringEscalationEmail
|
||||
);
|
||||
@@ -1,297 +0,0 @@
|
||||
import * as validators from './courseChecklistValidators';
|
||||
|
||||
describe('courseCheckValidators utility functions', () => {
|
||||
describe('hasWelcomeMessage', () => {
|
||||
it('returns true when course run has an update', () => {
|
||||
expect(validators.hasWelcomeMessage({ hasUpdate: true })).toEqual(true);
|
||||
});
|
||||
|
||||
it('returns false when course run does not have an update', () => {
|
||||
expect(validators.hasWelcomeMessage({ hasUpdate: false })).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasGradingPolicy', () => {
|
||||
it('returns true when sum of weights is 1', () => {
|
||||
expect(validators.hasGradingPolicy(
|
||||
{ hasGradingPolicy: true, sumOfWeights: 1 },
|
||||
)).toEqual(true);
|
||||
});
|
||||
|
||||
it('returns true when sum of weights is not 1 due to floating point approximation (1.00004)', () => {
|
||||
expect(validators.hasGradingPolicy(
|
||||
{ hasGradingPolicy: true, sumOfWeights: 1.00004 },
|
||||
)).toEqual(true);
|
||||
});
|
||||
|
||||
it('returns false when sum of weights is not 1', () => {
|
||||
expect(validators.hasGradingPolicy(
|
||||
{ hasGradingPolicy: true, sumOfWeights: 2 },
|
||||
)).toEqual(false);
|
||||
});
|
||||
|
||||
it('returns true when hasGradingPolicy is true', () => {
|
||||
expect(validators.hasGradingPolicy(
|
||||
{ hasGradingPolicy: true, sumOfWeights: 1 },
|
||||
)).toEqual(true);
|
||||
});
|
||||
|
||||
it('returns false when hasGradingPolicy is false', () => {
|
||||
expect(validators.hasGradingPolicy(
|
||||
{ hasGradingPolicy: false, sumOfWeights: 1 },
|
||||
)).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasCertificate', () => {
|
||||
it('returns true when certificates are activated and course run has a certificate', () => {
|
||||
expect(validators.hasCertificate({ isActivated: true, hasCertificate: true }))
|
||||
.toEqual(true);
|
||||
});
|
||||
|
||||
it('returns false when certificates are not activated and course run has a certificate', () => {
|
||||
expect(validators.hasCertificate({ isActivated: false, hasCertificate: true }))
|
||||
.toEqual(false);
|
||||
});
|
||||
|
||||
it('returns false when certificates are activated and course run does not have a certificate', () => {
|
||||
expect(validators.hasCertificate({ isActivated: true, hasCertificate: false }))
|
||||
.toEqual(false);
|
||||
});
|
||||
|
||||
it('returns false when certificates are not activated and course run does not have a certificate', () => {
|
||||
expect(validators.hasCertificate({ isActivated: false, hasCertificate: false }))
|
||||
.toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasDates', () => {
|
||||
it('returns true when course run has start date and end date', () => {
|
||||
expect(validators.hasDates({ hasStartDate: true, hasEndDate: true })).toEqual(true);
|
||||
});
|
||||
|
||||
it('returns false when course run has no start date and end date', () => {
|
||||
expect(validators.hasDates({ hasStartDate: false, hasEndDate: true })).toEqual(false);
|
||||
});
|
||||
|
||||
it('returns true when course run has start date and no end date', () => {
|
||||
expect(validators.hasDates({ hasStartDate: true, hasEndDate: false })).toEqual(false);
|
||||
});
|
||||
|
||||
it('returns true when course run has no start date and no end date', () => {
|
||||
expect(validators.hasDates({ hasStartDate: false, hasEndDate: false })).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasAssignmentDeadlines', () => {
|
||||
it('returns true when a course run has start and end date and all assignments are within range', () => {
|
||||
expect(validators.hasAssignmentDeadlines(
|
||||
{
|
||||
assignmentsWithDatesBeforeStart: 0,
|
||||
assignmentsWithDatesAfterEnd: 0,
|
||||
assignmentsWithOraDatesAfterEnd: 0,
|
||||
assignmentsWithOraDatesBeforeStart: 0,
|
||||
},
|
||||
{
|
||||
hasStartDate: true,
|
||||
hasEndDate: true,
|
||||
},
|
||||
)).toEqual(true);
|
||||
});
|
||||
|
||||
it('returns false when a course run has no start and no end date', () => {
|
||||
expect(validators.hasAssignmentDeadlines(
|
||||
{},
|
||||
{
|
||||
hasStartDate: false,
|
||||
hasEndDate: false,
|
||||
},
|
||||
)).toEqual(false);
|
||||
});
|
||||
|
||||
it('returns false when a course has start and end date and no assignments', () => {
|
||||
expect(validators.hasAssignmentDeadlines(
|
||||
{
|
||||
totalNumber: 0,
|
||||
},
|
||||
{
|
||||
hasStartDate: true,
|
||||
hasEndDate: true,
|
||||
},
|
||||
)).toEqual(false);
|
||||
});
|
||||
|
||||
it('returns false when a course run has start and end date and assignments before start', () => {
|
||||
expect(validators.hasAssignmentDeadlines(
|
||||
{
|
||||
assignmentsWithDatesBeforeStart: ['test'],
|
||||
assignmentsWithDatesAfterEnd: 0,
|
||||
assignmentsWithOraDatesAfterEnd: 0,
|
||||
assignmentsWithOraDatesBeforeStart: 0,
|
||||
},
|
||||
{
|
||||
hasStartDate: true,
|
||||
hasEndDate: true,
|
||||
},
|
||||
)).toEqual(false);
|
||||
});
|
||||
|
||||
it('returns false when a course run has start and end date and assignments after end', () => {
|
||||
expect(validators.hasAssignmentDeadlines(
|
||||
{
|
||||
assignmentsWithDatesBeforeStart: 0,
|
||||
assignmentsWithDatesAfterEnd: ['test'],
|
||||
assignmentsWithOraDatesAfterEnd: 0,
|
||||
assignmentsWithOraDatesBeforeStart: 0,
|
||||
},
|
||||
{
|
||||
hasStartDate: true,
|
||||
hasEndDate: true,
|
||||
},
|
||||
)).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
it(
|
||||
'returns false when a course run has start and end date and an ora with a date before start',
|
||||
() => {
|
||||
expect(validators.hasAssignmentDeadlines(
|
||||
{
|
||||
assignmentsWithDatesBeforeStart: 0,
|
||||
assignmentsWithDatesAfterEnd: 0,
|
||||
assignmentsWithOraDatesAfterEnd: 0,
|
||||
assignmentsWithOraDatesBeforeStart: ['test'],
|
||||
},
|
||||
{
|
||||
hasStartDate: true,
|
||||
hasEndDate: true,
|
||||
},
|
||||
)).toEqual(false);
|
||||
},
|
||||
);
|
||||
|
||||
it(
|
||||
'returns false when a course run has start and end date and an ora with a date after end',
|
||||
() => {
|
||||
expect(validators.hasAssignmentDeadlines(
|
||||
{
|
||||
assignmentsWithDatesBeforeStart: 0,
|
||||
assignmentsWithDatesAfterEnd: 0,
|
||||
assignmentsWithOraDatesAfterEnd: ['test'],
|
||||
assignmentsWithOraDatesBeforeStart: 0,
|
||||
},
|
||||
{
|
||||
hasStartDate: true,
|
||||
hasEndDate: true,
|
||||
},
|
||||
)).toEqual(false);
|
||||
},
|
||||
);
|
||||
|
||||
describe('hasShortVideoDuration', () => {
|
||||
it('returns true if course run has no videos', () => {
|
||||
expect(validators.hasShortVideoDuration({ totalNumber: 0 })).toEqual(true);
|
||||
});
|
||||
|
||||
it('returns true if course run videos have a median duration <= to 600', () => {
|
||||
expect(validators.hasShortVideoDuration({ totalNumber: 1, durations: { median: 100 } }))
|
||||
.toEqual(true);
|
||||
});
|
||||
|
||||
it('returns true if course run videos have a median duration > to 600', () => {
|
||||
expect(validators.hasShortVideoDuration({ totalNumber: 10, durations: { median: 700 } }))
|
||||
.toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasMobileFriendlyVideos', () => {
|
||||
it('returns true if course run has no videos', () => {
|
||||
expect(validators.hasMobileFriendlyVideos({ totalNumber: 0 })).toEqual(true);
|
||||
});
|
||||
|
||||
it('returns true if course run videos are >= 90% mobile friendly', () => {
|
||||
expect(validators.hasMobileFriendlyVideos({ totalNumber: 10, numMobileEncoded: 9 }))
|
||||
.toEqual(true);
|
||||
});
|
||||
|
||||
it('returns true if course run videos are < 90% mobile friendly', () => {
|
||||
expect(validators.hasMobileFriendlyVideos({ totalNumber: 10, numMobileEncoded: 8 }))
|
||||
.toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasDiverseSequences', () => {
|
||||
it('returns true if < 20% of visible subsections have more than one block type', () => {
|
||||
expect(validators.hasDiverseSequences({ totalVisible: 10, numWithOneBlockType: 1 }))
|
||||
.toEqual(true);
|
||||
});
|
||||
|
||||
it('returns false if no visible subsections', () => {
|
||||
expect(validators.hasDiverseSequences({ totalVisible: 0 })).toEqual(false);
|
||||
});
|
||||
|
||||
it('returns false if >= 20% of visible subsections have more than one block type', () => {
|
||||
expect(validators.hasDiverseSequences({ totalVisible: 10, numWithOneBlockType: 3 }))
|
||||
.toEqual(false);
|
||||
});
|
||||
|
||||
it('return false if < 0 visible subsections', () => {
|
||||
expect(validators.hasDiverseSequences({ totalVisible: -1, numWithOneBlockType: 1 }))
|
||||
.toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasWeeklyHighlights', () => {
|
||||
it('returns true when course run has highlights enabled', () => {
|
||||
const data = { highlightsActiveForCourse: true, highlightsEnabled: true };
|
||||
expect(validators.hasWeeklyHighlights(data)).toEqual(true);
|
||||
});
|
||||
|
||||
it('returns false when course run has highlights enabled', () => {
|
||||
const data = { highlightsActiveForCourse: false, highlightsEnabled: false };
|
||||
expect(validators.hasWeeklyHighlights(data)).toEqual(false);
|
||||
|
||||
data.highlightsEnabled = true;
|
||||
data.highlightsActiveForCourse = false;
|
||||
expect(validators.hasWeeklyHighlights(data)).toEqual(false);
|
||||
|
||||
data.highlightsEnabled = false;
|
||||
data.highlightsActiveForCourse = true;
|
||||
expect(validators.hasWeeklyHighlights(data)).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasShortUnitDepth', () => {
|
||||
it('returns true when course run has median number of blocks <= 3', () => {
|
||||
const units = {
|
||||
numBlocks: {
|
||||
median: 3,
|
||||
},
|
||||
};
|
||||
|
||||
expect(validators.hasShortUnitDepth(units)).toEqual(true);
|
||||
});
|
||||
|
||||
it('returns false when course run has median number of blocks > 3', () => {
|
||||
const units = {
|
||||
numBlocks: {
|
||||
median: 4,
|
||||
},
|
||||
};
|
||||
|
||||
expect(validators.hasShortUnitDepth(units)).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasProctoringEscalationEmail', () => {
|
||||
it('returns true when the course has a proctoring escalation email', () => {
|
||||
const proctoring = { hasProctoringEscalationEmail: true };
|
||||
expect(validators.hasProctoringEscalationEmail(proctoring)).toEqual(true);
|
||||
});
|
||||
|
||||
it('returns false when the course does not have a proctoring escalation email', () => {
|
||||
const proctoring = { hasProctoringEscalationEmail: false };
|
||||
expect(validators.hasProctoringEscalationEmail(proctoring)).toEqual(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,32 +0,0 @@
|
||||
import { filters } from './courseChecklistData';
|
||||
|
||||
const getFilteredChecklist = (
|
||||
checklist,
|
||||
isSelfPaced,
|
||||
hasCertificatesEnabled,
|
||||
hasHighlightsEnabled,
|
||||
needsProctoringEscalationEmail,
|
||||
) => {
|
||||
let filteredCheckList;
|
||||
|
||||
if (isSelfPaced) {
|
||||
filteredCheckList = checklist.filter(data => data.pacingTypeFilter === filters.ALL
|
||||
|| data.pacingTypeFilter === filters.SELF_PACED);
|
||||
} else {
|
||||
filteredCheckList = checklist.filter(data => data.pacingTypeFilter === filters.ALL
|
||||
|| data.pacingTypeFilter === filters.INSTRUCTOR_PACED);
|
||||
}
|
||||
|
||||
filteredCheckList = filteredCheckList.filter(data => data.id !== 'certificate'
|
||||
|| hasCertificatesEnabled);
|
||||
|
||||
filteredCheckList = filteredCheckList.filter(data => data.id !== 'weeklyHighlights'
|
||||
|| hasHighlightsEnabled);
|
||||
|
||||
filteredCheckList = filteredCheckList.filter(data => data.id !== 'proctoringEmail'
|
||||
|| needsProctoringEscalationEmail);
|
||||
|
||||
return filteredCheckList;
|
||||
};
|
||||
|
||||
export default getFilteredChecklist;
|
||||
@@ -1,149 +0,0 @@
|
||||
import { filters } from './courseChecklistData';
|
||||
import getFilteredChecklist from './getFilteredChecklist';
|
||||
|
||||
const checklist = [
|
||||
{
|
||||
id: 'welcomeMessage',
|
||||
pacingTypeFilter: filters.ALL,
|
||||
},
|
||||
{
|
||||
id: 'gradingPolicy',
|
||||
pacingTypeFilter: filters.ALL,
|
||||
},
|
||||
{
|
||||
id: 'certificate',
|
||||
pacingTypeFilter: filters.ALL,
|
||||
},
|
||||
{
|
||||
id: 'courseDates',
|
||||
pacingTypeFilter: filters.ALL,
|
||||
},
|
||||
{
|
||||
id: 'assignmentDeadlines',
|
||||
pacingTypeFilter: filters.INSTRUCTOR_PACED,
|
||||
},
|
||||
{
|
||||
id: 'weeklyHighlights',
|
||||
pacingTypeFilter: filters.SELF_PACED,
|
||||
},
|
||||
{
|
||||
id: 'proctoringEmail',
|
||||
pacingTypeFilter: filters.ALL,
|
||||
},
|
||||
];
|
||||
|
||||
let courseData;
|
||||
describe('getFilteredChecklist utility function', () => {
|
||||
beforeEach(() => {
|
||||
courseData = {
|
||||
isSelfPaced: true,
|
||||
hasCertificatesEnabled: true,
|
||||
hasHighlightsEnabled: true,
|
||||
needsProctoringEscalationEmail: true,
|
||||
};
|
||||
});
|
||||
it('returns only checklist items with filters ALL and SELF_PACED when isSelfPaced is true', () => {
|
||||
const filteredChecklist = getFilteredChecklist(
|
||||
checklist,
|
||||
courseData.isSelfPaced,
|
||||
courseData.hasCertificatesEnabled,
|
||||
courseData.hasHighlightsEnabled,
|
||||
courseData.needsProctoringEscalationEmail,
|
||||
);
|
||||
|
||||
filteredChecklist.forEach(((
|
||||
item => expect(item.pacingTypeFilter === filters.ALL
|
||||
|| item.pacingTypeFilter === filters.SELF_PACED)
|
||||
)));
|
||||
|
||||
expect(filteredChecklist.filter(item => item.pacingTypeFilter === filters.ALL).length)
|
||||
.toEqual(checklist.filter(item => item.pacingTypeFilter === filters.ALL).length);
|
||||
expect(filteredChecklist.filter(item => item.pacingTypeFilter === filters.SELF_PACED).length)
|
||||
.toEqual(checklist.filter(item => item.pacingTypeFilter === filters.SELF_PACED).length);
|
||||
});
|
||||
|
||||
it('returns only checklist items with filters ALL and INSTRUCTOR_PACED when isSelfPaced is false', () => {
|
||||
courseData.isSelfPaced = false;
|
||||
const filteredChecklist = getFilteredChecklist(
|
||||
checklist,
|
||||
courseData.isSelfPaced,
|
||||
courseData.hasCertificatesEnabled,
|
||||
courseData.hasHighlightsEnabled,
|
||||
courseData.needsProctoringEscalationEmail,
|
||||
);
|
||||
|
||||
filteredChecklist.forEach(((
|
||||
item => expect(item.pacingTypeFilter === filters.ALL
|
||||
|| item.pacingTypeFilter === filters.INSTRUCTOR_PACED)
|
||||
)));
|
||||
|
||||
expect(filteredChecklist.filter(item => item.pacingTypeFilter === filters.ALL).length)
|
||||
.toEqual(checklist.filter(item => item.pacingTypeFilter === filters.ALL).length);
|
||||
expect(filteredChecklist
|
||||
.filter(item => item.pacingTypeFilter === filters.INSTRUCTOR_PACED).length)
|
||||
.toEqual(checklist.filter(item => item.pacingTypeFilter === filters.INSTRUCTOR_PACED).length);
|
||||
});
|
||||
|
||||
it('excludes certificates when they are disabled', () => {
|
||||
let filteredChecklist = getFilteredChecklist(
|
||||
checklist,
|
||||
courseData.isSelfPaced,
|
||||
courseData.hasCertificatesEnabled,
|
||||
courseData.hasHighlightsEnabled,
|
||||
courseData.needsProctoringEscalationEmail,
|
||||
);
|
||||
expect(checklist.filter(item => item.id === 'certificate').length).toEqual(1);
|
||||
|
||||
courseData.hasCertificatesEnabled = false;
|
||||
filteredChecklist = getFilteredChecklist(
|
||||
checklist,
|
||||
courseData.isSelfPaced,
|
||||
courseData.hasCertificatesEnabled,
|
||||
courseData.hasHighlightsEnabled,
|
||||
courseData.needsProctoringEscalationEmail,
|
||||
);
|
||||
expect(filteredChecklist.filter(item => item.id === 'certificate').length).toEqual(0);
|
||||
});
|
||||
|
||||
it('excludes weekly highlights when they are disabled', () => {
|
||||
let filteredChecklist = getFilteredChecklist(
|
||||
checklist,
|
||||
courseData.isSelfPaced,
|
||||
courseData.hasCertificatesEnabled,
|
||||
courseData.hasHighlightsEnabled,
|
||||
courseData.needsProctoringEscalationEmail,
|
||||
);
|
||||
expect(filteredChecklist.filter(item => item.id === 'weeklyHighlights').length).toEqual(1);
|
||||
|
||||
courseData.hasHighlightsEnabled = false;
|
||||
filteredChecklist = getFilteredChecklist(
|
||||
checklist,
|
||||
courseData.isSelfPaced,
|
||||
courseData.hasCertificatesEnabled,
|
||||
courseData.hasHighlightsEnabled,
|
||||
courseData.needsProctoringEscalationEmail,
|
||||
);
|
||||
expect(filteredChecklist.filter(item => item.id === 'weeklyHighlights').length).toEqual(0);
|
||||
});
|
||||
|
||||
it('excludes proctoring escalation email when not needed', () => {
|
||||
let filteredChecklist = getFilteredChecklist(
|
||||
checklist,
|
||||
courseData.isSelfPaced,
|
||||
courseData.hasCertificatesEnabled,
|
||||
courseData.hasHighlightsEnabled,
|
||||
courseData.needsProctoringEscalationEmail,
|
||||
);
|
||||
expect(filteredChecklist.filter(item => item.id === 'proctoringEmail').length).toEqual(1);
|
||||
|
||||
courseData.needsProctoringEscalationEmail = false;
|
||||
filteredChecklist = getFilteredChecklist(
|
||||
checklist,
|
||||
courseData.isSelfPaced,
|
||||
courseData.hasCertificatesEnabled,
|
||||
courseData.hasHighlightsEnabled,
|
||||
courseData.needsProctoringEscalationEmail,
|
||||
);
|
||||
expect(filteredChecklist.filter(item => item.id === 'proctoringEmail').length).toEqual(0);
|
||||
});
|
||||
});
|
||||
@@ -1,32 +0,0 @@
|
||||
import * as healthValidators from './courseChecklistValidators';
|
||||
|
||||
const getValidatedValue = (data, id) => {
|
||||
switch (id) {
|
||||
case 'welcomeMessage':
|
||||
return healthValidators.hasWelcomeMessage(data.updates);
|
||||
case 'gradingPolicy':
|
||||
return healthValidators.hasGradingPolicy(data.grades);
|
||||
case 'certificate':
|
||||
return healthValidators.hasCertificate(data.certificates);
|
||||
case 'courseDates':
|
||||
return healthValidators.hasDates(data.dates);
|
||||
case 'assignmentDeadlines':
|
||||
return healthValidators.hasAssignmentDeadlines(data.assignments, data.dates);
|
||||
case 'videoDuration':
|
||||
return healthValidators.hasShortVideoDuration(data.videos);
|
||||
case 'mobileFriendlyVideo':
|
||||
return healthValidators.hasMobileFriendlyVideos(data.videos);
|
||||
case 'diverseSequences':
|
||||
return healthValidators.hasDiverseSequences(data.subsections);
|
||||
case 'weeklyHighlights':
|
||||
return healthValidators.hasWeeklyHighlights(data.sections);
|
||||
case 'unitDepth':
|
||||
return healthValidators.hasShortUnitDepth(data.units);
|
||||
case 'proctoringEmail':
|
||||
return healthValidators.hasProctoringEscalationEmail(data.proctoring);
|
||||
default:
|
||||
throw new Error(`Unknown validator ${id}.`);
|
||||
}
|
||||
};
|
||||
|
||||
export default getValidatedValue;
|
||||
@@ -1,166 +0,0 @@
|
||||
import * as validators from './courseChecklistValidators';
|
||||
import getValidatedValue from './getValidatedValue';
|
||||
|
||||
describe('getValidatedValue utility function', () => {
|
||||
const localValidators = validators;
|
||||
it('welcome message', () => {
|
||||
const spy = jest.fn();
|
||||
localValidators.hasWelcomeMessage = spy;
|
||||
|
||||
const props = {
|
||||
data: {
|
||||
updates: {},
|
||||
},
|
||||
};
|
||||
|
||||
getValidatedValue(props, 'welcomeMessage');
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('grading policy', () => {
|
||||
const spy = jest.fn();
|
||||
localValidators.hasGradingPolicy = spy;
|
||||
|
||||
const props = {
|
||||
data: {
|
||||
grades: {},
|
||||
},
|
||||
};
|
||||
|
||||
getValidatedValue(props, 'gradingPolicy');
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('certificate', () => {
|
||||
const spy = jest.fn();
|
||||
localValidators.hasCertificate = spy;
|
||||
|
||||
const props = {
|
||||
data: {
|
||||
certificates: {},
|
||||
},
|
||||
};
|
||||
|
||||
getValidatedValue(props, 'certificate');
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('course dates', () => {
|
||||
const spy = jest.fn();
|
||||
localValidators.hasDates = spy;
|
||||
|
||||
const props = {
|
||||
data: {
|
||||
dates: {},
|
||||
},
|
||||
};
|
||||
|
||||
getValidatedValue(props, 'courseDates');
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('assignment deadlines', () => {
|
||||
const spy = jest.fn();
|
||||
localValidators.hasAssignmentDeadlines = spy;
|
||||
|
||||
const props = {
|
||||
data: {
|
||||
assignments: {},
|
||||
dates: {},
|
||||
},
|
||||
};
|
||||
|
||||
getValidatedValue(props, 'assignmentDeadlines');
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('video duration', () => {
|
||||
const spy = jest.fn();
|
||||
localValidators.hasShortVideoDuration = spy;
|
||||
|
||||
const props = {
|
||||
data: {
|
||||
videos: {},
|
||||
},
|
||||
};
|
||||
|
||||
getValidatedValue(props, 'videoDuration');
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('mobile friendly video', () => {
|
||||
const spy = jest.fn();
|
||||
localValidators.hasMobileFriendlyVideos = spy;
|
||||
|
||||
const props = {
|
||||
data: {
|
||||
videos: {},
|
||||
},
|
||||
};
|
||||
|
||||
getValidatedValue(props, 'mobileFriendlyVideo');
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('diverse sequences', () => {
|
||||
const spy = jest.fn();
|
||||
localValidators.hasDiverseSequences = spy;
|
||||
|
||||
const props = {
|
||||
data: {
|
||||
subsections: {},
|
||||
},
|
||||
};
|
||||
|
||||
getValidatedValue(props, 'diverseSequences');
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('weekly highlights', () => {
|
||||
const spy = jest.fn();
|
||||
localValidators.hasWeeklyHighlights = spy;
|
||||
|
||||
const props = {
|
||||
data: {
|
||||
sections: {},
|
||||
},
|
||||
};
|
||||
|
||||
getValidatedValue(props, 'weeklyHighlights');
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('unit depth', () => {
|
||||
const spy = jest.fn();
|
||||
localValidators.hasShortUnitDepth = spy;
|
||||
|
||||
const props = {
|
||||
data: {
|
||||
units: {},
|
||||
},
|
||||
};
|
||||
|
||||
getValidatedValue(props, 'unitDepth');
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('proctoring email', () => {
|
||||
const spy = jest.fn();
|
||||
localValidators.hasProctoringEscalationEmail = spy;
|
||||
|
||||
const props = {
|
||||
data: {
|
||||
proctoring: {},
|
||||
},
|
||||
};
|
||||
|
||||
getValidatedValue(props, 'proctoringEmail');
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('other', () => {
|
||||
const sampleID = 'edX';
|
||||
expect(() => getValidatedValue({}, sampleID)).toThrow(Error);
|
||||
expect(() => getValidatedValue({}, sampleID)).toThrow(`Unknown validator ${sampleID}`);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user