Compare commits

..

1 Commits

Author SHA1 Message Date
Jesper Hodge
c044186dba refactor: extract useAdvancedSettings 2023-09-03 19:39:50 +00:00
855 changed files with 26099 additions and 68869 deletions

16
.env
View File

@@ -23,7 +23,6 @@ PUBLISHER_BASE_URL=''
REFRESH_ACCESS_TOKEN_ENDPOINT=''
SEGMENT_KEY=''
SITE_NAME=''
STUDIO_SHORT_NAME='Studio'
SUPPORT_EMAIL=''
SUPPORT_URL=''
USER_INFO_COOKIE_NAME=''
@@ -31,14 +30,17 @@ ENABLE_ACCESSIBILITY_PAGE=false
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
ENABLE_NEW_HOME_PAGE = false
ENABLE_NEW_COURSE_OUTLINE_PAGE = false
ENABLE_NEW_VIDEO_UPLOAD_PAGE = false
ENABLE_NEW_GRADING_PAGE = false
ENABLE_NEW_COURSE_TEAM_PAGE = false
ENABLE_NEW_IMPORT_PAGE = false
ENABLE_NEW_EXPORT_PAGE = false
ENABLE_UNIT_PAGE = false
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN = false
BBB_LEARN_MORE_URL=''
HOTJAR_APP_ID=''
HOTJAR_VERSION=6
HOTJAR_DEBUG=false
INVITE_STUDENTS_EMAIL_TO=''
AI_TRANSLATIONS_BASE_URL=''
ENABLE_CHECKLIST_QUALITY=''

View File

@@ -25,7 +25,6 @@ REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
SEGMENT_KEY=null
SITE_NAME='Your Plaform Name Here'
STUDIO_BASE_URL='http://localhost:18010'
STUDIO_SHORT_NAME='Studio'
SUPPORT_EMAIL=
SUPPORT_URL='https://support.edx.org'
USER_INFO_COOKIE_NAME='edx-user-info'
@@ -33,14 +32,17 @@ ENABLE_ACCESSIBILITY_PAGE=false
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
ENABLE_NEW_HOME_PAGE = false
ENABLE_NEW_COURSE_OUTLINE_PAGE = false
ENABLE_NEW_VIDEO_UPLOAD_PAGE = false
ENABLE_NEW_GRADING_PAGE = false
ENABLE_NEW_COURSE_TEAM_PAGE = false
ENABLE_NEW_IMPORT_PAGE = false
ENABLE_NEW_EXPORT_PAGE = false
ENABLE_UNIT_PAGE = false
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN = false
BBB_LEARN_MORE_URL=''
HOTJAR_APP_ID=''
HOTJAR_VERSION=6
HOTJAR_DEBUG=true
INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
AI_TRANSLATIONS_BASE_URL='http://localhost:18760'
ENABLE_CHECKLIST_QUALITY=true

View File

@@ -22,17 +22,20 @@ REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
SEGMENT_KEY=null
SITE_NAME='edX'
STUDIO_BASE_URL='http://localhost:18010'
STUDIO_SHORT_NAME='Studio'
SUPPORT_EMAIL='support@example.com'
SUPPORT_URL='https://support.edx.org'
USER_INFO_COOKIE_NAME='edx-user-info'
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
ENABLE_NEW_HOME_PAGE = false
ENABLE_NEW_COURSE_OUTLINE_PAGE = true
ENABLE_NEW_VIDEO_UPLOAD_PAGE = true
ENABLE_NEW_GRADING_PAGE = true
ENABLE_NEW_COURSE_TEAM_PAGE = true
ENABLE_NEW_IMPORT_PAGE = true
ENABLE_NEW_EXPORT_PAGE = true
ENABLE_UNIT_PAGE = true
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN = true
BBB_LEARN_MORE_URL=''
INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
ENABLE_CHECKLIST_QUALITY=true

View File

@@ -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',
},
},
],
},
);

View File

@@ -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.

6
.gitignore vendored
View File

@@ -20,9 +20,3 @@ temp/babel-plugin-react-intl
/temp
/.vscode
/module.config.js
# Local environment overrides
.env.private
# Messages .json files fetched by atlas
src/i18n/messages/

View File

@@ -8,7 +8,7 @@
"ignoreUnits": ["\\.5"]
}],
"property-no-vendor-prefix": [true, {
"ignoreProperties": ["animation", "filter", "transform", "transition"]
"ignoreProperties": ["animation", "filter"]
}],
"value-no-vendor-prefix": [true, {
"ignoreValues": ["fill-available"]

9
.tx/config Normal file
View 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

View File

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

View File

@@ -1,17 +1,21 @@
transifex_resource = frontend-app-course-authoring
export TRANSIFEX_RESOURCE = ${transifex_resource}
transifex_langs = "ar,fr,es_419,zh_CN,pt,it,de,uk,ru,hi,fr_CA,it_IT,pt_PT,de_DE"
intl_imports = ./node_modules/.bin/intl-imports.js
transifex_utils = ./node_modules/.bin/transifex-utils.js
i18n = ./src/i18n
transifex_input = $(i18n)/transifex_input.json
# This directory must match .babelrc .
transifex_temp = ./temp/babel-plugin-formatjs
transifex_temp = ./temp/babel-plugin-react-intl
precommit:
npm run lint
npm audit
requirements:
npm ci
npm install
i18n.extract:
# Pulling display strings from .jsx files into .json files...
@@ -29,19 +33,34 @@ 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
cd src/i18n/messages \
&& atlas pull $(ATLAS_OPTIONS) \
translations/frontend-component-ai-translations/src/i18n/messages:frontend-component-ai-translations \
translations/frontend-lib-content-components/src/i18n/messages:frontend-lib-content-components \
translations/frontend-platform/src/i18n/messages:frontend-platform \
&& atlas pull --filter=$(transifex_langs) \
translations/paragon/src/i18n/messages:paragon \
translations/frontend-component-footer/src/i18n/messages:frontend-component-footer \
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
$(intl_imports) paragon frontend-component-footer frontend-app-course-authoring
endif
# This target is used by Travis.
validate-no-uncommitted-package-lock-changes:
@@ -53,7 +72,6 @@ validate:
make validate-no-uncommitted-package-lock-changes
npm run i18n_extract
npm run lint -- --max-warnings 0
npm run types
npm run test
npm run build

View File

@@ -12,6 +12,7 @@ This is the Course Authoring micro-frontend, currently under development by `2U
Its purpose is to provide both a framework and UI for new or replacement React-based authoring features outside ``edx-platform``. You can find the current set described below.
************
Getting Started
************
@@ -31,11 +32,6 @@ to the `relevant tutor-mfe documentation`_ to get started using it.
.. _relevant tutor-mfe documentation: https://github.com/overhangio/tutor-mfe#mfe-development
Configuration
=============
All features that integrate into the edx-platform CMS require that the ``COURSE_AUTHORING_MICROFRONTEND_URL`` Django setting is set in the CMS environment and points to this MFE's deployment URL. This should be done automatically if you are using devstack or tutor-mfe.
Cloning and Startup
===================
@@ -64,7 +60,7 @@ Cloning and Startup
The dev server is running at `http://localhost:2001 <http://localhost:2001>`_.
or whatever port you setup.
********
Features
********
@@ -73,12 +69,14 @@ Feature: Pages and Resources Studio Tab
Enables a "Pages & Resources" menu item in Studio, under the "Content" menu.
.. image:: ./docs/readme-images/feature-pages-resources.png
Requirements
------------
The following are requirements for this feature to function correctly:
The following are external requirements for this feature to function correctly:
* ``edx-platform`` Django settings:
* ``COURSE_AUTHORING_MICROFRONTEND_URL``: must be set in the CMS environment and point to this MFE's deployment URL.
* ``edx-platform`` Waffle flags:
@@ -127,13 +125,15 @@ For a particular course, this page allows one to:
Feature: New React XBlock Editors
=================================
.. image:: ./docs/readme-images/feature-problem-editor.png
This allows an operator to enable the use of new React editors for the HTML, Video, and Problem XBlocks, all of which are provided here.
Requirements
------------
* ``edx-platform`` Django settings:
* ``COURSE_AUTHORING_MICROFRONTEND_URL``: must be set in the CMS environment and point to this MFE's deployment URL.
* ``edx-platform`` Waffle flags:
* ``new_core_editors.use_new_text_editor``: must be enabled for the new HTML Xblock editor to be used in Studio
@@ -145,7 +145,7 @@ Configuration
In additional to the standard settings, the following local configuration item is required:
* ``ENABLE_NEW_EDITOR_PAGES``: must be enabled in order to actually present the new XBlock editors (on by default)
* ``ENABLE_NEW_EDITOR_PAGES``: must be enabled in order to actually present the new XBlock editors
Feature Description
-------------------
@@ -159,13 +159,12 @@ When a corresponding waffle flag is set, upon editing a block in Studio, the vie
Feature: New Proctoring Exams View
==================================
.. image:: ./docs/readme-images/feature-proctored-exams.png
Requirements
------------
* ``edx-platform`` Django settings:
* ``COURSE_AUTHORING_MICROFRONTEND_URL``: must be set in the CMS environment and point to this MFE's deployment URL.
* ``ZENDESK_*``: necessary if automatic ZenDesk ticket creation is desired
* ``edx-platform`` Feature flags:
@@ -191,85 +190,8 @@ In Studio, a new item ("Proctored Exam Settings") is added to "Other Course Sett
* Select a proctoring provider
* Enable automatic creation of Zendesk tickets for "suspicious" proctored exam attempts
Feature: Advanced Settings
==========================
.. image:: ./docs/readme-images/feature-advanced-settings.png
Requirements
------------
* ``edx-platform`` Waffle flags:
* ``contentstore.new_studio_mfe.use_new_advanced_settings_page``: this feature flag must be enabled for the link to the settings view to be shown. It can be enabled on a per-course basis.
Feature Description
-------------------
In Studio, the "Advanced Settings" page for each enabled course will now be served by this frontend, instead of the UI built into edx-platform. The advanced settings page holds many different settings for the course, such as what features or XBlocks are enabled.
Feature: Files & Uploads
==========================
.. image:: ./docs/readme-images/feature-files-uploads.png
Requirements
------------
* ``edx-platform`` Waffle flags:
* ``contentstore.new_studio_mfe.use_new_files_uploads_page``: this feature flag must be enabled for the link to the Files & Uploads page to go to the MFE. It can be enabled on a per-course basis.
Feature Description
-------------------
In Studio, the "Files & Uploads" page for each enabled course will now be served by this frontend, instead of the UI built into edx-platform. This page allows managing static asset files like PDFs, images, etc. used for the course.
Feature: Course Updates
==========================
.. image:: ./docs/readme-images/feature-course-updates.png
Requirements
------------
* ``edx-platform`` Waffle flags:
* ``contentstore.new_studio_mfe.use_new_updates_page``: this feature flag must be enabled.
Feature: Import/Export Pages
============================
.. image:: ./docs/readme-images/feature-export.png
Requirements
------------
* ``edx-platform`` Waffle flags:
* ``contentstore.new_studio_mfe.use_new_export_page``: this feature flag will change the CMS to link to the new export page.
* ``contentstore.new_studio_mfe.use_new_import_page``: this feature flag will change the CMS to link to the new import page.
Feature: Tagging/Taxonomy Pages
================================
.. image:: ./docs/readme-images/feature-tagging-taxonomy-pages.png
Requirements
------------
* ``edx-platform`` Waffle flags:
* ``new_studio_mfe.use_tagging_taxonomy_list_page``: this feature flag must be enabled.
Configuration
-------------
In additional to the standard settings, the following local configuration items are required:
* ``ENABLE_TAGGING_TAXONOMY_PAGES``: must be enabled in order to actually present the new Tagging/Taxonomy pages.
**********
Developing
**********
@@ -278,7 +200,8 @@ Developing
If your devstack includes the default Demo course, you can visit the following URLs to see content:
- `Pages and Resources <http://localhost:2001/course/course-v1:edX+DemoX+Demo_Course/pages-and-resources>`_
- `Proctored Exam Settings <http://localhost:2001/course/course-v1:edX+DemoX+Demo_Course/proctored-exam-settings>`_
- `Pages and Resources <http://localhost:2001/course/course-v1:edX+DemoX+Demo_Course/pages-and-resources>`_ (work in progress)
Troubleshooting
========================
@@ -289,7 +212,7 @@ Troubleshooting
If there is still an error, look for "no package [...] found" in the error message and install missing package via brew.
(https://github.com/Automattic/node-canvas/issues/1733)
*********
Deploying
*********
@@ -313,7 +236,6 @@ internationalization.
.. _frontend-platform i18n howto: https://github.com/openedx/frontend-platform/blob/master/docs/how_tos/i18n.rst
Getting Help
************
@@ -337,7 +259,6 @@ For more information about these options, see the `Getting Help`_ page.
.. _#wg-frontend channel: https://openedx.slack.com/archives/C04BM6YC7A6
.. _Getting Help: https://openedx.org/community/connect
License
*******
@@ -346,7 +267,6 @@ noted.
Please see `LICENSE <LICENSE>`_ for details.
Contributing
************
@@ -361,7 +281,6 @@ beginning development to maximize the chances of your change being accepted.
You can start a conversation by creating a new issue on this repo summarizing
your idea.
The Open edX Code of Conduct
****************************
@@ -378,7 +297,6 @@ file in this repo.
.. _Backstage: https://open-edx-backstage.herokuapp.com/catalog/default/component/frontend-app-course-authoring
Reporting Security Issues
*************************
@@ -392,4 +310,4 @@ Please do not report security issues in public, and email security@openedx.org i
.. |codecov-badge| image:: https://codecov.io/github/openedx/frontend-app-course-authoring/coverage.svg?branch=master
:target: https://codecov.io/github/openedx/frontend-app-course-authoring?branch=master
:alt: Codecov
:alt: Codecov

View File

@@ -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'

View File

@@ -8,6 +8,3 @@ coverage:
default:
target: auto
threshold: 0%
ignore:
- "src/grading-settings/grading-scale/react-ranger.js"
- "src/index.js"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

View File

@@ -1,19 +1,17 @@
const { createConfig } = require('@openedx/frontend-build');
const { createConfig } = require('@edx/frontend-build');
module.exports = createConfig('jest', {
setupFilesAfterEnv: [
'jest-expect-message',
'<rootDir>/src/setupTest.js',
],
coveragePathIgnorePatterns: [
'src/setupTest.js',
'src/i18n',
],
snapshotSerializers: [
'enzyme-to-json/serializer',
],
moduleNameMapper: {
'^lodash-es$': 'lodash',
'^CourseAuthoring/(.*)$': '<rootDir>/src/$1',
},
modulePathIgnorePatterns: [
'/src/pages-and-resources/utils.test.jsx',
],
});

18666
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,15 +11,13 @@
],
"scripts": {
"build": "fedx-scripts webpack",
"i18n_extract": "fedx-scripts formatjs extract",
"i18n_extract": "BABEL_ENV=i18n fedx-scripts babel src --quiet > /dev/null",
"stylelint": "stylelint \"src/**/*.scss\" \"scss/**/*.scss\" --config .stylelintrc.json",
"lint": "npm run stylelint && fedx-scripts eslint --ext .js --ext .jsx .",
"lint:fix": "npm run stylelint && fedx-scripts eslint --ext .js --ext .jsx . --fix",
"snapshot": "TZ=UTC fedx-scripts jest --updateSnapshot",
"snapshot": "fedx-scripts jest --updateSnapshot",
"start": "fedx-scripts webpack-dev-server --progress",
"start:with-theme": "paragon install-theme && npm start && npm install",
"test": "TZ=UTC fedx-scripts jest --coverage --passWithNoTests",
"types": "tsc --noEmit"
"test": "fedx-scripts jest --coverage --passWithNoTests"
},
"husky": {
"hooks": {
@@ -36,87 +34,57 @@
"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-enterprise-hotjar": "^2.0.0",
"@edx/frontend-lib-content-components": "^2.1.4",
"@edx/frontend-platform": "7.0.1",
"@edx/openedx-atlas": "^0.6.0",
"@fortawesome/fontawesome-svg-core": "1.2.36",
"@fortawesome/free-brands-svg-icons": "5.15.4",
"@fortawesome/free-regular-svg-icons": "5.15.4",
"@fortawesome/free-solid-svg-icons": "5.15.4",
"@fortawesome/react-fontawesome": "0.2.0",
"@openedx-plugins/course-app-calculator": "file:plugins/course-apps/calculator",
"@openedx-plugins/course-app-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",
"@edx/brand": "npm:@edx/brand-openedx@1.1.0",
"@edx/frontend-component-footer": "12.0.0",
"@edx/frontend-enterprise-hotjar": "^1.2.1",
"@edx/frontend-lib-content-components": "^1.169.3",
"@edx/frontend-platform": "4.2.0",
"@edx/paragon": "^20.45.4",
"@fortawesome/fontawesome-svg-core": "1.2.28",
"@fortawesome/free-brands-svg-icons": "5.11.2",
"@fortawesome/free-regular-svg-icons": "5.11.2",
"@fortawesome/free-solid-svg-icons": "5.11.2",
"@fortawesome/react-fontawesome": "0.1.9",
"@reduxjs/toolkit": "1.5.0",
"classnames": "2.2.6",
"core-js": "3.8.1",
"email-validator": "2.0.4",
"file-saver": "^2.0.5",
"formik": "2.2.6",
"jszip": "^3.10.1",
"lodash": "4.17.21",
"moment": "2.29.4",
"moment": "2.29.2",
"prop-types": "15.7.2",
"react": "17.0.2",
"react": "16.14.0",
"react-datepicker": "^4.13.0",
"react-dom": "17.0.2",
"react-dom": "16.14.0",
"react-helmet": "^6.1.0",
"react-redux": "7.2.9",
"react-responsive": "9.0.2",
"react-router": "6.16.0",
"react-router-dom": "6.16.0",
"react-select": "5.8.0",
"react-ranger": "^2.1.0",
"react-redux": "7.1.3",
"react-responsive": "8.1.0",
"react-router": "5.2.0",
"react-router-dom": "5.2.0",
"react-textarea-autosize": "^8.4.1",
"react-transition-group": "4.4.5",
"react-transition-group": "4.4.1",
"redux": "4.0.5",
"regenerator-runtime": "0.13.7",
"universal-cookie": "^4.0.4",
"uuid": "^3.4.0",
"yup": "0.31.1"
},
"devDependencies": {
"@edx/browserslist-config": "1.2.0",
"@edx/react-unit-test-utils": "^2.0.0",
"@edx/browserslist-config": "1.0.0",
"@edx/frontend-build": "12.8.6",
"@edx/reactifex": "^1.0.3",
"@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",
"@edx/stylelint-config-edx": "^2.3.0",
"@testing-library/jest-dom": "5.16.4",
"@testing-library/react": "12.1.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",
"jest-canvas-mock": "^2.5.2",
"jest-expect-message": "^1.1.3",
"react-test-renderer": "17.0.2",
"reactifex": "1.1.1",
"ts-loader": "^9.5.0"
},
"peerDependencies": {
"decode-uri-component": ">=0.2.2"
"axios-mock-adapter": "1.20.0",
"enzyme": "3.11.0",
"enzyme-adapter-react-16": "1.15.6",
"enzyme-to-json": "^3.6.2",
"glob": "7.1.6",
"husky": "3.1.0",
"react-test-renderer": "16.9.0",
"reactifex": "1.1.1"
}
}

View File

@@ -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;

View File

@@ -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
}
}
}

View File

@@ -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;

View File

@@ -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
}
}
}

View File

@@ -1,63 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Hyperlink } from '@openedx/paragon';
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
import { useModel } from 'CourseAuthoring/generic/model-store';
import messages from './messages';
const LearningAssistantSettings = ({ onClose }) => {
const appId = 'learning_assistant';
const appInfo = useModel('courseApps', appId);
const intl = useIntl();
// We need to render more than one link, so we use the bodyChildren prop.
const bodyChildren = (
appInfo?.documentationLinks?.learnMoreOpenaiDataPrivacy && appInfo?.documentationLinks?.learnMoreOpenai
? (
<div className="d-flex flex-column">
{appInfo.documentationLinks?.learnMoreOpenaiDataPrivacy && (
<Hyperlink
className="text-primary-500"
destination={appInfo.documentationLinks.learnMoreOpenaiDataPrivacy}
target="_blank"
rel="noreferrer noopener"
>
{intl.formatMessage(messages.learningAssistantOpenAIDataPrivacyLink)}
</Hyperlink>
)}
{appInfo.documentationLinks?.learnMoreOpenai && (
<Hyperlink
className="text-primary-500"
destination={appInfo.documentationLinks.learnMoreOpenai}
target="_blank"
rel="noreferrer noopener"
>
{intl.formatMessage(messages.learningAssistantOpenAILink)}
</Hyperlink>
)}
</div>
)
: null
);
return (
<AppSettingsModal
appId={appId}
title={intl.formatMessage(messages.heading)}
enableAppHelp={intl.formatMessage(messages.enableLearningAssistantHelp)}
enableAppLabel={intl.formatMessage(messages.enableLearningAssistantLabel)}
bodyChildren={bodyChildren}
onClose={onClose}
/>
);
};
LearningAssistantSettings.propTypes = {
onClose: PropTypes.func.isRequired,
};
export default LearningAssistantSettings;

View File

@@ -1,59 +0,0 @@
import React from 'react';
import { screen, waitFor } from '@testing-library/react';
import { RequestStatus } from 'CourseAuthoring/data/constants';
import { render } from 'CourseAuthoring/pages-and-resources/utils.test';
import LearningAssistantSettings from './Settings';
const onClose = () => { };
describe('Learning Assistant Settings', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('renders', async () => {
const initialState = {
models: {
courseApps: {
learning_assistant:
{
id: 'learning_assistant',
enabled: true,
name: 'Learning Assistant',
description: 'Learning Assistant description',
allowedOperations: {
configure: false,
enable: true,
},
documentationLinks: {
learnMoreOpenaiDataPrivacy: 'www.example.com/learn-more-data-privacy',
learnMoreOpenai: 'www.example.com/learn-more',
},
},
},
},
pagesAndResources: {
loadingStatus: RequestStatus.SUCCESSFUL,
},
};
render(
<LearningAssistantSettings
onClose={onClose}
/>,
{
preloadedState: initialState,
},
);
const toggleDescription = 'Reinforce learning concepts by sharing text-based course content '
+ 'with OpenAI (via API) to power an in-course Learning Assistant. Learners can leave feedback about the quality '
+ 'of the AI-powered experience for use by edX to improve the performance of the tool.';
await waitFor(() => expect(screen.getByRole('heading', { name: 'Configure Learning Assistant' })).toBeInTheDocument());
await waitFor(() => expect(screen.getByText(toggleDescription)).toBeInTheDocument());
await waitFor(() => expect(screen.getByText('Learn more about how OpenAI handles data')).toBeInTheDocument());
await waitFor(() => expect(screen.getByText('Learn more about OpenAI API data privacy')).toBeInTheDocument());
});
});

View File

@@ -1,28 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
heading: {
id: 'course-authoring.pages-resources.learning-assistant.heading',
defaultMessage: 'Configure Learning Assistant',
},
enableLearningAssistantLabel: {
id: 'course-authoring.pages-resources.learning_assistant.enable-learning-assistant.label',
defaultMessage: 'Learning Assistant',
},
enableLearningAssistantHelp: {
id: 'course-authoring.pages-resources.learning_assistant.enable-learning-assistant.help',
defaultMessage: `Reinforce learning concepts by sharing text-based course content with OpenAI (via API) to power
an in-course Learning Assistant. Learners can leave feedback about the quality of the AI-powered experience for
use by edX to improve the performance of the tool.`,
},
learningAssistantOpenAILink: {
id: 'course-authoring.pages-resources.learning_assistant.open-ai.link',
defaultMessage: 'Learn more about how OpenAI handles data',
},
learningAssistantOpenAIDataPrivacyLink: {
id: 'course-authoring.pages-resources.learning_assistant.open-ai.data-privacy.link',
defaultMessage: 'Learn more about OpenAI API data privacy',
},
});
export default messages;

View File

@@ -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
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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();
});
});
});

View File

@@ -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
}
}
}

View File

@@ -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;
};

View File

@@ -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);
});
});
});

View File

@@ -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
}
}
}

View File

@@ -1,4 +0,0 @@
Xpert Unit Summaries Configuration Plugin
=========================================
Install this using ``npm install plugins/course-apps/xpert_unit_summary/ --no-save``.

View File

@@ -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
}
}
}

View File

@@ -4,7 +4,7 @@
<title>Course Authoring | <%= process.env.SITE_NAME %></title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="shortcut icon" href="<%=htmlWebpackPlugin.options.FAVICON_URL%>" type="image/x-icon" />
<link rel="shortcut icon" href="<%= process.env.FAVICON_URL %>" type="image/x-icon" />
</head>
<body>
<div id="root"></div>

View File

@@ -1,33 +1,19 @@
{
"extends": [
"config:base",
"schedule:weekly",
"schedule:daily",
":rebaseStalePrs",
":semanticCommits",
":dependencyDashboard"
":semanticCommits"
],
"timezone": "America/New_York",
"patch": {
"automerge": false
"automerge": true
},
"rebaseStalePrs": true,
"packageRules": [
{
"extends": [
"schedule:daily"
],
"matchPackagePatterns": ["@edx", "@openedx"],
"matchPackagePatterns": ["@edx"],
"matchUpdateTypes": ["minor", "patch"],
"automerge": false
},
{
"matchPackagePatterns": ["@edx/frontend-lib-content-components"],
"matchUpdateTypes": ["minor", "patch"],
"automerge": false,
"schedule": [
"after 1am",
"before 11pm"
]
"automerge": true
}
]
}

View File

@@ -5,11 +5,10 @@ import { useDispatch, useSelector } from 'react-redux';
import {
useLocation,
} from 'react-router-dom';
import { StudioFooter } from '@edx/frontend-component-footer';
import Header from './header';
import { Footer } from '@edx/frontend-lib-content-components';
import Header from './studio-header/Header';
import { fetchCourseDetail } from './data/thunks';
import { useModel } from './generic/model-store';
import NotFoundAlert from './generic/NotFoundAlert';
import PermissionDeniedAlert from './generic/PermissionDeniedAlert';
import { getCourseAppsApiStatus } from './pages-and-resources/data/selectors';
import { RequestStatus } from './data/constants';
@@ -38,6 +37,21 @@ AppHeader.defaultProps = {
courseOrg: null,
};
const AppFooter = () => (
<div className="mt-6">
<Footer
marketingBaseUrl={process.env.MARKETING_SITE_BASE_URL}
termsOfServiceUrl={process.env.TERMS_OF_SERVICE_URL}
privacyPolicyUrl={process.env.PRIVACY_POLICY_URL}
supportEmail={process.env.SUPPORT_EMAIL}
platformName={process.env.SITE_NAME}
lmsBaseUrl={process.env.LMS_BASE_URL}
studioBaseUrl={process.env.STUDIO_BASE_URL}
showAccessibilityPage={process.env.ENABLE_ACCESSIBILITY_PAGE === 'true'}
/>
</div>
);
const CourseAuthoringPage = ({ courseId, children }) => {
const dispatch = useDispatch();
@@ -51,16 +65,10 @@ const CourseAuthoringPage = ({ courseId, children }) => {
const courseOrg = courseDetail ? courseDetail.org : null;
const courseTitle = courseDetail ? courseDetail.name : courseId;
const courseAppsApiStatus = useSelector(getCourseAppsApiStatus);
const courseDetailStatus = useSelector(state => state.courseDetail.status);
const inProgress = courseDetailStatus === RequestStatus.IN_PROGRESS;
const inProgress = useSelector(state => state.courseDetail.status) === RequestStatus.IN_PROGRESS;
const { pathname } = useLocation();
const isEditor = pathname.includes('/editor');
const showHeader = !pathname.includes('/editor');
if (courseDetailStatus === RequestStatus.NOT_FOUND && !isEditor) {
return (
<NotFoundAlert />
);
}
if (courseAppsApiStatus === RequestStatus.DENIED) {
return (
<PermissionDeniedAlert />
@@ -72,8 +80,8 @@ const CourseAuthoringPage = ({ courseId, children }) => {
using url pattern containing /editor/,
we shouldn't have the header and footer on these pages.
This functionality will be removed in TNL-9591 */}
{inProgress ? !isEditor && <Loading />
: (!isEditor && (
{inProgress ? showHeader && <Loading />
: (showHeader && (
<AppHeader
courseNumber={courseNumber}
courseOrg={courseOrg}
@@ -83,7 +91,7 @@ const CourseAuthoringPage = ({ courseId, children }) => {
)
)}
{children}
{!inProgress && !isEditor && <StudioFooter />}
{!inProgress && showHeader && <AppFooter />}
</div>
);
};

View File

@@ -12,7 +12,6 @@ import CourseAuthoringPage from './CourseAuthoringPage';
import PagesAndResources from './pages-and-resources/PagesAndResources';
import { executeThunk } from './utils';
import { fetchCourseApps } from './pages-and-resources/data/thunks';
import { fetchCourseDetail } from './data/thunks';
const courseId = 'course-v1:edX+TestX+Test_Course';
let mockPathname = '/evilguy/';
@@ -25,19 +24,6 @@ jest.mock('react-router-dom', () => ({
let axiosMock;
let store;
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
});
describe('Editor Pages Load no header', () => {
const mockStoreSuccess = async () => {
const apiBaseUrl = getConfig().STUDIO_BASE_URL;
@@ -47,6 +33,18 @@ describe('Editor Pages Load no header', () => {
});
await executeThunk(fetchCourseApps(courseId), store.dispatch);
};
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
});
test('renders no loading wheel on editor pages', async () => {
mockPathname = '/editor/';
await mockStoreSuccess();
@@ -78,56 +76,3 @@ describe('Editor Pages Load no header', () => {
expect(wrapper.queryByRole('status')).toBeInTheDocument();
});
});
describe('Course authoring page', () => {
const lmsApiBaseUrl = getConfig().LMS_BASE_URL;
const courseDetailApiUrl = `${lmsApiBaseUrl}/api/courses/v1/courses`;
const mockStoreNotFound = async () => {
axiosMock.onGet(
`${courseDetailApiUrl}/${courseId}?username=abc123`,
).reply(404, {
response: { status: 404 },
});
await executeThunk(fetchCourseDetail(courseId), store.dispatch);
};
const mockStoreError = async () => {
axiosMock.onGet(
`${courseDetailApiUrl}/${courseId}?username=abc123`,
).reply(500, {
response: { status: 500 },
});
await executeThunk(fetchCourseDetail(courseId), store.dispatch);
};
test('renders not found page on non-existent course key', async () => {
await mockStoreNotFound();
const wrapper = render(
<AppProvider store={store}>
<IntlProvider locale="en">
<CourseAuthoringPage courseId={courseId} />
</IntlProvider>
</AppProvider>
,
);
expect(await wrapper.findByTestId('notFoundAlert')).toBeInTheDocument();
});
test('does not render not found page on other kinds of error', async () => {
await mockStoreError();
// Currently, loading errors are not handled, so we wait for the child
// content to be rendered -which happens when request status is no longer
// IN_PROGRESS but also not NOT_FOUND or DENIED- then check that the not
// found alert is not present.
const contentTestId = 'courseAuthoringPageContent';
const wrapper = render(
<AppProvider store={store}>
<IntlProvider locale="en">
<CourseAuthoringPage courseId={courseId}>
<div data-testid={contentTestId} />
</CourseAuthoringPage>
</IntlProvider>
</AppProvider>
,
);
expect(await wrapper.findByTestId(contentTestId)).toBeInTheDocument();
expect(wrapper.queryByTestId('notFoundAlert')).not.toBeInTheDocument();
});
});

View File

@@ -1,26 +1,20 @@
import React from 'react';
import {
Navigate, Routes, Route, useParams,
} from 'react-router-dom';
import { getConfig } from '@edx/frontend-platform';
import { PageWrap } from '@edx/frontend-platform/react';
import PropTypes from 'prop-types';
import { Switch, useRouteMatch } from 'react-router';
import { PageRoute } from '@edx/frontend-platform/react';
import Placeholder from '@edx/frontend-lib-content-components';
import CourseAuthoringPage from './CourseAuthoringPage';
import { PagesAndResources } from './pages-and-resources';
import ProctoredExamSettings from './proctored-exam-settings/ProctoredExamSettings';
import EditorContainer from './editors/EditorContainer';
import VideoSelectorContainer from './selectors/VideoSelectorContainer';
import CustomPages from './custom-pages';
import { FilesPage, VideosPage } from './files-and-videos';
import FilesAndUploads from './files-and-uploads';
import { AdvancedSettings } from './advanced-settings';
import { CourseOutline } from './course-outline';
import ScheduleAndDetails from './schedule-and-details';
import { GradingSettings } from './grading-settings';
import CourseTeam from './course-team/CourseTeam';
import { CourseUpdates } from './course-updates';
import { CourseUnit } from './course-unit';
import CourseExportPage from './export-page/CourseExportPage';
import CourseImportPage from './import-page/CourseImportPage';
import { DECODED_ROUTES } from './constants';
import CourseChecklist from './course-checklist';
/**
* As of this writing, these routes are mounted at a path prefixed with the following:
@@ -38,86 +32,91 @@ import CourseChecklist from './course-checklist';
* can move the Header/Footer rendering to this component and likely pull the course detail loading
* in as well, and it'd feel a bit better-factored and the roles would feel more clear.
*/
const CourseAuthoringRoutes = () => {
const { courseId } = useParams();
const CourseAuthoringRoutes = ({ courseId }) => {
const { path } = useRouteMatch();
return (
<CourseAuthoringPage courseId={courseId}>
<Routes>
<Route
path="/"
element={<PageWrap><CourseOutline courseId={courseId} /></PageWrap>}
/>
<Route
path="course_info"
element={<PageWrap><CourseUpdates courseId={courseId} /></PageWrap>}
/>
<Route
path="assets"
element={<PageWrap><FilesPage courseId={courseId} /></PageWrap>}
/>
<Route
path="videos"
element={getConfig().ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN === 'true' ? <PageWrap><VideosPage courseId={courseId} /></PageWrap> : null}
/>
<Route
path="pages-and-resources/*"
element={<PageWrap><PagesAndResources courseId={courseId} /></PageWrap>}
/>
<Route
path="proctored-exam-settings"
element={<Navigate replace to={`/course/${courseId}/pages-and-resources`} />}
/>
<Route
path="custom-pages/*"
element={<PageWrap><CustomPages courseId={courseId} /></PageWrap>}
/>
{DECODED_ROUTES.COURSE_UNIT.map((path) => (
<Route
key={path}
path={path}
element={<PageWrap><CourseUnit courseId={courseId} /></PageWrap>}
/>
))}
<Route
path="editor/course-videos/:blockId"
element={getConfig().ENABLE_NEW_EDITOR_PAGES === 'true' ? <PageWrap><VideoSelectorContainer courseId={courseId} /></PageWrap> : null}
/>
<Route
path="editor/:blockType/:blockId?"
element={getConfig().ENABLE_NEW_EDITOR_PAGES === 'true' ? <PageWrap><EditorContainer courseId={courseId} /></PageWrap> : null}
/>
<Route
path="settings/details"
element={<PageWrap><ScheduleAndDetails courseId={courseId} /></PageWrap>}
/>
<Route
path="settings/grading"
element={<PageWrap><GradingSettings courseId={courseId} /></PageWrap>}
/>
<Route
path="course_team"
element={<PageWrap><CourseTeam courseId={courseId} /></PageWrap>}
/>
<Route
path="settings/advanced"
element={<PageWrap><AdvancedSettings courseId={courseId} /></PageWrap>}
/>
<Route
path="import"
element={<PageWrap><CourseImportPage courseId={courseId} /></PageWrap>}
/>
<Route
path="export"
element={<PageWrap><CourseExportPage courseId={courseId} /></PageWrap>}
/>
<Route
path="checklists"
element={<PageWrap><CourseChecklist courseId={courseId} /></PageWrap>}
/>
</Routes>
<Switch>
<PageRoute path={`${path}/outline`}>
{process.env.ENABLE_NEW_COURSE_OUTLINE_PAGE === 'true'
&& (
<Placeholder />
)}
</PageRoute>
<PageRoute path={`${path}/course_info`}>
<CourseUpdates courseId={courseId} />
</PageRoute>
<PageRoute path={`${path}/assets`}>
<FilesAndUploads courseId={courseId} />
</PageRoute>
<PageRoute path={`${path}/videos`}>
{process.env.ENABLE_NEW_VIDEO_UPLOAD_PAGE === 'true'
&& (
<Placeholder />
)}
</PageRoute>
<PageRoute path={`${path}/pages-and-resources`}>
<PagesAndResources courseId={courseId} />
</PageRoute>
<PageRoute path={`${path}/proctored-exam-settings`}>
<ProctoredExamSettings courseId={courseId} />
</PageRoute>
<PageRoute path={`${path}/custom-pages`}>
<CustomPages courseId={courseId} />
</PageRoute>
<PageRoute path={`${path}/container/:blockId`}>
{process.env.ENABLE_UNIT_PAGE === 'true'
&& (
<Placeholder />
)}
</PageRoute>
<PageRoute path={`${path}/editor/course-videos/:blockId`}>
{process.env.ENABLE_NEW_EDITOR_PAGES === 'true'
&& (
<VideoSelectorContainer
courseId={courseId}
/>
)}
</PageRoute>
<PageRoute path={`${path}/editor/:blockType/:blockId?`}>
{process.env.ENABLE_NEW_EDITOR_PAGES === 'true'
&& (
<EditorContainer
courseId={courseId}
/>
)}
</PageRoute>
<PageRoute path={`${path}/settings/details`}>
<ScheduleAndDetails courseId={courseId} />
</PageRoute>
<PageRoute path={`${path}/settings/grading`}>
<GradingSettings courseId={courseId} />
</PageRoute>
<PageRoute path={`${path}/course_team`}>
<CourseTeam courseId={courseId} />
</PageRoute>
<PageRoute path={`${path}/settings/advanced`}>
<AdvancedSettings courseId={courseId} />
</PageRoute>
<PageRoute path={`${path}/import`}>
{process.env.ENABLE_NEW_IMPORT_PAGE === 'true'
&& (
<Placeholder />
)}
</PageRoute>
<PageRoute path={`${path}/export`}>
{process.env.ENABLE_NEW_EXPORT_PAGE === 'true'
&& (
<Placeholder />
)}
</PageRoute>
</Switch>
</CourseAuthoringPage>
);
};
CourseAuthoringRoutes.propTypes = {
courseId: PropTypes.string.isRequired,
};
export default CourseAuthoringRoutes;

View File

@@ -8,19 +8,13 @@ import initializeStore from './store';
const courseId = 'course-v1:edX+TestX+Test_Course';
const pagesAndResourcesMockText = 'Pages And Resources';
const proctoredExamSeetingsMockText = 'Proctored Exam Settings';
const editorContainerMockText = 'Editor Container';
const videoSelectorContainerMockText = 'Video Selector Container';
const customPagesMockText = 'Custom Pages';
let store;
const mockComponentFn = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: () => ({
courseId,
}),
}));
// Mock the TinyMceWidget from frontend-lib-content-components
jest.mock('@edx/frontend-lib-content-components', () => ({
TinyMceWidget: () => <div>Widget</div>,
@@ -31,10 +25,20 @@ jest.mock('@edx/frontend-lib-content-components', () => ({
})),
}));
jest.mock('react-router', () => ({
...jest.requireActual('react-router'),
useRouteMatch: () => ({
path: `/course/${courseId}`,
}),
}));
jest.mock('./pages-and-resources/PagesAndResources', () => (props) => {
mockComponentFn(props);
return pagesAndResourcesMockText;
});
jest.mock('./proctored-exam-settings/ProctoredExamSettings', () => (props) => {
mockComponentFn(props);
return proctoredExamSeetingsMockText;
});
jest.mock('./editors/EditorContainer', () => (props) => {
mockComponentFn(props);
return editorContainerMockText;
@@ -61,16 +65,39 @@ describe('<CourseAuthoringRoutes>', () => {
store = initializeStore();
});
fit('renders the PagesAndResources component when the pages and resources route is active', () => {
// TODO: This test needs to be corrected.
// The problem arose after moving new commits (https://github.com/raccoongang/frontend-app-course-authoring/pull/25)
it.skip('renders the PagesAndResources component when the pages and resources route is active', () => {
render(
<AppProvider store={store} wrapWithRouter={false}>
<MemoryRouter initialEntries={['/pages-and-resources']}>
<CourseAuthoringRoutes />
<AppProvider store={store}>
<MemoryRouter initialEntries={[`/course/${courseId}/pages-and-resources`]}>
<CourseAuthoringRoutes courseId={courseId} />
</MemoryRouter>
</AppProvider>,
);
expect(screen.getByText(pagesAndResourcesMockText)).toBeVisible();
expect(screen.queryByText(pagesAndResourcesMockText)).toBeInTheDocument();
expect(screen.queryByText(proctoredExamSeetingsMockText)).not.toBeInTheDocument();
expect(mockComponentFn).toHaveBeenCalledWith(
expect.objectContaining({
courseId,
}),
);
});
// TODO: This test needs to be corrected.
// The problem arose after moving new commits (https://github.com/raccoongang/frontend-app-course-authoring/pull/25)
it.skip('renders the ProctoredExamSettings component when the proctored exam settings route is active', () => {
render(
<AppProvider store={store}>
<MemoryRouter initialEntries={[`/course/${courseId}/proctored-exam-settings`]}>
<CourseAuthoringRoutes courseId={courseId} />
</MemoryRouter>
</AppProvider>,
);
expect(screen.queryByText(proctoredExamSeetingsMockText)).toBeInTheDocument();
expect(screen.queryByText(pagesAndResourcesMockText)).not.toBeInTheDocument();
expect(mockComponentFn).toHaveBeenCalledWith(
expect.objectContaining({
courseId,
@@ -80,9 +107,9 @@ describe('<CourseAuthoringRoutes>', () => {
it('renders the EditorContainer component when the course editor route is active', () => {
render(
<AppProvider store={store} wrapWithRouter={false}>
<MemoryRouter initialEntries={['/editor/video/block-id']}>
<CourseAuthoringRoutes />
<AppProvider store={store}>
<MemoryRouter initialEntries={[`/course/${courseId}/editor/video/block-id`]}>
<CourseAuthoringRoutes courseId={courseId} />
</MemoryRouter>
</AppProvider>,
);
@@ -98,9 +125,9 @@ describe('<CourseAuthoringRoutes>', () => {
it('renders the VideoSelectorContainer component when the course videos route is active', () => {
render(
<AppProvider store={store} wrapWithRouter={false}>
<MemoryRouter initialEntries={['/editor/course-videos/block-id']}>
<CourseAuthoringRoutes />
<AppProvider store={store}>
<MemoryRouter initialEntries={[`/course/${courseId}/editor/course-videos/block-id`]}>
<CourseAuthoringRoutes courseId={courseId} />
</MemoryRouter>
</AppProvider>,
);

View File

@@ -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);

View File

@@ -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);
});
});
});

View File

@@ -1,3 +0,0 @@
import AccessibilityBody from './AccessibilityBody';
export default AccessibilityBody;

View File

@@ -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;

View File

@@ -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);

View File

@@ -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();
});
});
});

View File

@@ -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;

View File

@@ -1,3 +0,0 @@
import AccessibilityForm from './AccessibilityForm';
export default AccessibilityForm;

View File

@@ -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;

View File

@@ -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);

View File

@@ -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();
});
});
});

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -1,3 +0,0 @@
import AccessibilityPage from './AccessibilityPage';
export default AccessibilityPage;

View File

@@ -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;

View File

@@ -3,13 +3,12 @@ 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';
import AlertProctoringError from '../generic/AlertProctoringError';
import { useModel } from '../generic/model-store';
import InternetConnectionAlert from '../generic/internet-connection-alert';
import { parseArrayOrObjectValues } from '../utils';
import { RequestStatus } from '../data/constants';
@@ -24,10 +23,11 @@ import SettingsSidebar from './settings-sidebar/SettingsSidebar';
import validateAdvancedSettingsData from './utils';
import messages from './messages';
import ModalError from './modal-error/ModalError';
import getPageHeadTitle from '../generic/utils';
import { useAdvancedSettings } from './hooks';
const AdvancedSettings = ({ intl, courseId }) => {
const dispatch = useDispatch();
const [saveSettingsPrompt, showSaveSettingsPrompt] = useState(false);
const [showDeprecated, setShowDeprecated] = useState(false);
const [errorModal, showErrorModal] = useState(false);
@@ -38,46 +38,26 @@ const AdvancedSettings = ({ intl, courseId }) => {
const [isEditableState, setIsEditableState] = useState(false);
const [hasInternetConnectionError, setInternetConnectionError] = useState(false);
const courseDetails = useModel('courseDetails', courseId);
document.title = getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.headingTitle));
useEffect(() => {
dispatch(fetchCourseAppSettings(courseId));
dispatch(fetchProctoringExamErrors(courseId));
}, [courseId]);
const advancedSettingsData = useSelector(getCourseAppSettings);
const savingStatus = useSelector(getSavingStatus);
const proctoringExamErrors = useSelector(getProctoringExamErrors);
const settingsWithSendErrors = useSelector(getSendRequestErrors) || {};
const loadingSettingsStatus = useSelector(getLoadingStatus);
const isLoading = loadingSettingsStatus === RequestStatus.IN_PROGRESS;
const updateSettingsButtonState = {
labels: {
default: intl.formatMessage(messages.buttonSaveText),
pending: intl.formatMessage(messages.buttonSavingText),
},
disabledStates: ['pending'],
};
const {
advancedSettingsData,
isLoading,
updateSettingsButtonState,
proctoringErrors,
mfeProctoredExamSettingsUrl,
} = proctoringExamErrors;
useEffect(() => {
if (savingStatus === RequestStatus.SUCCESSFUL) {
setIsQueryPending(false);
setShowSuccessAlert(true);
setIsEditableState(false);
setTimeout(() => setShowSuccessAlert(false), 15000);
window.scrollTo({ top: 0, behavior: 'smooth' });
showSaveSettingsPrompt(false);
} else if (savingStatus === RequestStatus.FAILED && !hasInternetConnectionError) {
setErrorFields(settingsWithSendErrors);
showErrorModal(true);
}
}, [savingStatus]);
loadingSettingsStatus,
savingStatus,
} = useAdvancedSettings({
dispatch,
courseId,
intl,
setIsQueryPending,
setShowSuccessAlert,
setIsEditableState,
showSaveSettingsPrompt,
showErrorModal,
setErrorFields,
hasInternetConnectionError,
});
if (isLoading) {
// eslint-disable-next-line react/jsx-no-useless-fragment
@@ -85,7 +65,7 @@ const AdvancedSettings = ({ intl, courseId }) => {
}
if (loadingSettingsStatus === RequestStatus.DENIED) {
return (
<div className="row justify-content-center m-6">
<div className="row justify-contnt-center m-6">
<Placeholder />
</div>
);
@@ -130,7 +110,7 @@ const AdvancedSettings = ({ intl, courseId }) => {
return (
<>
<Container size="xl" className="advanced-settings px-4">
<Container size="xl" className="px-4">
<div className="setting-header mt-5">
{(proctoringErrors?.length > 0) && (
<AlertProctoringError
@@ -157,6 +137,11 @@ const AdvancedSettings = ({ intl, courseId }) => {
) : null}
</TransitionReplace>
</div>
<SubHeader
subtitle={intl.formatMessage(messages.headingSubtitle)}
title={intl.formatMessage(messages.headingTitle)}
contentTitle={intl.formatMessage(messages.policy)}
/>
<section className="setting-items mb-4">
<Layout
lg={[{ span: 9 }, { span: 3 }]}
@@ -166,11 +151,6 @@ const AdvancedSettings = ({ intl, courseId }) => {
xl={[{ span: 9 }, { span: 3 }]}
>
<Layout.Element>
<SubHeader
subtitle={intl.formatMessage(messages.headingSubtitle)}
title={intl.formatMessage(messages.headingTitle)}
contentTitle={intl.formatMessage(messages.policy)}
/>
<article>
<div>
<section className="setting-items-policies">

View File

@@ -0,0 +1,62 @@
import { useEffect } from 'react';
import { useSelector } from 'react-redux';
import { RequestStatus } from '../data/constants';
import { fetchCourseAppSettings, fetchProctoringExamErrors } from './data/thunks';
import {
getCourseAppSettings, getSavingStatus, getProctoringExamErrors, getSendRequestErrors, getLoadingStatus,
} from './data/selectors';
import messages from './messages';
/* eslint-disable import/prefer-default-export */
export const useAdvancedSettings = ({
dispatch, courseId, intl, setIsQueryPending, setShowSuccessAlert, setIsEditableState, showSaveSettingsPrompt,
showErrorModal, setErrorFields, hasInternetConnectionError,
}) => {
useEffect(() => {
dispatch(fetchCourseAppSettings(courseId));
dispatch(fetchProctoringExamErrors(courseId));
}, [courseId]);
const advancedSettingsData = useSelector(getCourseAppSettings);
const savingStatus = useSelector(getSavingStatus);
const proctoringExamErrors = useSelector(getProctoringExamErrors);
const settingsWithSendErrors = useSelector(getSendRequestErrors) || {};
const loadingSettingsStatus = useSelector(getLoadingStatus);
const isLoading = loadingSettingsStatus === RequestStatus.IN_PROGRESS;
const updateSettingsButtonState = {
labels: {
default: intl.formatMessage(messages.buttonSaveText),
pending: intl.formatMessage(messages.buttonSavingText),
},
disabledStates: ['pending'],
};
const {
proctoringErrors,
mfeProctoredExamSettingsUrl,
} = proctoringExamErrors;
useEffect(() => {
if (savingStatus === RequestStatus.SUCCESSFUL) {
setIsQueryPending(false);
setShowSuccessAlert(true);
setIsEditableState(false);
setTimeout(() => setShowSuccessAlert(false), 15000);
window.scrollTo({ top: 0, behavior: 'smooth' });
showSaveSettingsPrompt(false);
} else if (savingStatus === RequestStatus.FAILED && !hasInternetConnectionError) {
setErrorFields(settingsWithSendErrors);
showErrorModal(true);
}
}, [savingStatus]);
return {
advancedSettingsData,
isLoading,
updateSettingsButtonState,
proctoringErrors,
mfeProctoredExamSettingsUrl,
loadingSettingsStatus,
savingStatus,
};
};

View File

@@ -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';

View File

@@ -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';

View File

@@ -1,29 +1,23 @@
@import "variables";
.advanced-settings {
.help-sidebar {
margin-top: 8.75rem;
}
.setting-items-policies {
.setting-items-deprecated-setting {
float: right;
margin-bottom: 1.75rem;
}
.instructions,
strong {
color: $text-color-base;
font-weight: 400;
}
}
.setting-card {
.setting-items-policies {
.setting-items-deprecated-setting {
float: right;
margin-bottom: 1.75rem;
}
.pgn__card-header .pgn__card-header-title-md {
font-size: 1.125rem;
}
.instructions,
strong {
color: $text-color-base;
font-weight: 400;
}
}
.setting-card {
margin-bottom: 1.75rem;
.pgn__card-header .pgn__card-header-title-md {
font-size: 1.125rem;
}
}
@@ -46,11 +40,16 @@
.form-control {
min-height: 2.75rem;
flex-grow: 1;
width: $setting-form-control-width;
}
.pgn__card-section {
max-width: $setting-form-control-width;
}
.pgn__card-header {
padding: 0 0 0 1.5rem;
flex-grow: 1;
}
.pgn__card-status {
@@ -60,6 +59,8 @@
.pgn__card-header-content {
margin-top: 1.438rem;
margin-bottom: 1.438rem;
display: flex;
flex-direction: revert;
}
}

View File

@@ -1 +1,2 @@
$text-color-base: $gray-700;
$setting-form-control-width: 34.375rem;

View File

@@ -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';
@@ -58,9 +58,8 @@ const SettingCard = ({
return (
<li className="field-group course-advanced-policy-list-item">
<Card className="flex-column setting-card">
<Card.Body className="d-flex row m-0 align-items-center">
<Card.Body className="d-flex">
<Card.Header
className="col-6"
title={(
<ActionRow>
{capitalize(displayName)}
@@ -87,11 +86,10 @@ const SettingCard = ({
dangerouslySetInnerHTML={{ __html: help }}
/>
</ModalPopup>
<ActionRow.Spacer />
</ActionRow>
)}
/>
<Card.Section className="col-6 flex-grow-1">
<Card.Section>
<Form.Group className="m-0">
<Form.Control
as={TextareaAutosize}

View File

@@ -6,7 +6,7 @@ import {
} from '@edx/frontend-platform/i18n';
import PropTypes from 'prop-types';
import { HelpSidebar } from '../../generic/help-sidebar';
import HelpSidebar from '../../generic/help-sidebar';
import messages from './messages';
const SettingsSidebar = ({ intl, courseId, proctoredExamSettingsUrl }) => (

View File

@@ -1,11 +1,3 @@
.text-black {
color: $black;
}
.h-200px {
height: 200px;
}
.mw-300px {
max-width: 300px;
}

View File

@@ -4,9 +4,8 @@ export const DATE_TIME_FORMAT = 'YYYY-MM-DDTHH:mm:ss\\Z';
export const COMMA_SEPARATED_DATE_FORMAT = 'MMMM D, YYYY';
export const DEFAULT_EMPTY_WYSIWYG_VALUE = '<p>&nbsp;</p>';
export const STATEFUL_BUTTON_STATES = {
default: 'default',
pending: 'pending',
error: 'error',
default: 'default',
};
export const USER_ROLES = {
@@ -20,32 +19,7 @@ export const BADGE_STATES = {
};
export const NOTIFICATION_MESSAGES = {
adding: 'Adding',
saving: 'Saving',
duplicating: 'Duplicating',
deleting: 'Deleting',
copying: 'Copying',
pasting: 'Pasting',
discardChanges: 'Discarding changes',
publishing: 'Publishing',
hidingFromStudents: 'Hiding from students',
makingVisibleToStudents: 'Making visible to students',
empty: '',
};
export const DEFAULT_TIME_STAMP = '00:00';
export const COURSE_CREATOR_STATES = {
unrequested: 'unrequested',
pending: 'pending',
granted: 'granted',
denied: 'denied',
disallowedForThisSite: 'disallowed_for_this_site',
};
export const DECODED_ROUTES = {
COURSE_UNIT: [
'/container/:blockId/:sequenceId',
'/container/:blockId',
],
};

View File

@@ -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;

View File

@@ -1,436 +0,0 @@
// @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,
Button,
Spinner,
} from '@openedx/paragon';
import classNames from 'classnames';
import { SelectableBox } from '@edx/frontend-lib-content-components';
import { useIntl } from '@edx/frontend-platform/i18n';
import { debounce } from 'lodash';
import messages from './messages';
import ContentTagsDropDownSelector from './ContentTagsDropDownSelector';
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
* from a dropdown list.
*
* This component also handles all the logic with selecting/deselecting tags and keeps track of the
* tags tree in the state. That is used to render the Tag bubbgles as well as the populating the
* state of the tags in the dropdown selectors.
*
* The `contentTags` that is passed are consolidated and converted to a tree structure. For example:
*
* FROM:
*
* [
* {
* "value": "DNA Sequencing",
* "lineage": [
* "Science and Research",
* "Genetics Subcategory",
* "DNA Sequencing"
* ]
* },
* {
* "value": "Virology",
* "lineage": [
* "Science and Research",
* "Molecular, Cellular, and Microbiology",
* "Virology"
* ]
* }
* ]
*
* TO:
*
* {
* "Science and Research": {
* explicit: false,
* children: {
* "Genetics Subcategory": {
* explicit: false,
* children: {
* "DNA Sequencing": {
* explicit: true,
* children: {}
* }
* }
* },
* "Molecular, Cellular, and Microbiology": {
* explicit: false,
* children: {
* "Virology": {
* explicit: true,
* children: {}
* }
* }
* }
* }
* }
* };
*
*
* It also keeps track of newly added tags as they are selected in the dropdown selectors.
* They are store in the same format above, and then merged to one tree that is used as the
* source of truth for both the tag bubble and the dropdowns. They keys are order alphabetically.
*
* In the dropdowns, the value of each SelectableBox is stored along with it's lineage and is URI encoded.
* Ths is so we are able to traverse and manipulate different parts of the tree leading to it.
* Here is an example of what the value of the "Virology" tag would be:
*
* "Science%20and%20Research,Molecular%2C%20Cellular%2C%20and%20Microbiology,Virology"
*
* @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 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 {
tagChangeHandler,
removeAppliedTagHandler,
appliedContentTagsTree,
stagedContentTagsTree,
contentTagsCount,
checkedTags,
commitStagedTags,
updateTags,
} = useContentTagsCollapsibleHelper(
contentId,
taxonomyAndTagsData,
addStagedContentTag,
removeStagedContentTag,
stagedContentTags,
);
const [searchTerm, setSearchTerm] = React.useState('');
const handleSelectableBoxChange = React.useCallback((e) => {
tagChangeHandler(e.target.value, e.target.checked);
}, [tagChangeHandler]);
const handleSearch = debounce((term) => {
setSearchTerm(term.trim());
}, 500); // Perform search after 500ms
const handleSearchChange = React.useCallback((value, { 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);
}
}
}, [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();
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>
<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}
/>
)}
</div>
</Collapsible>
<div className="d-flex">
<Badge
variant="light"
pill
className={classNames('align-self-start', 'mt-3', {
invisible: contentTagsCount === 0,
})}
>
{contentTagsCount}
</Badge>
</div>
</div>
);
};
export default ContentTagsCollapsible;

View File

@@ -1,59 +0,0 @@
.taxonomy-tags-collapsible {
flex: 1;
border: none !important;
.collapsible-trigger {
border: none !important;
}
}
.taxonomy-tags-selector-menu {
button {
flex: 1;
}
}
.taxonomy-tags-selector-menu + div {
width: 100%;
}
.taxonomy-tags-selectable-box-set {
grid-auto-rows: unset !important;
grid-gap: unset !important;
overflow-y: scroll;
max-height: 20rem;
}
.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;
}
}

View File

@@ -1,558 +0,0 @@
import React from 'react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import {
act,
render,
fireEvent,
} from '@testing-library/react';
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,
}],
},
};
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;
}),
}));
const data = {
contentId: 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@7f47fe2dbcaf47c5a071671c741fe1ab',
taxonomyAndTagsData: {
id: 123,
name: 'Taxonomy 1',
canTagObject: true,
contentTags: [
{
value: 'Tag 1',
lineage: ['Tag 1'],
canDeleteObjecttag: true,
},
{
value: 'Tag 1.1',
lineage: ['Tag 1', 'Tag 1.1'],
canDeleteObjecttag: true,
},
{
value: 'Tag 2',
lineage: ['Tag 2'],
canDeleteObjecttag: true,
},
],
},
stagedContentTags: [],
addStagedContentTag: jest.fn(),
removeStagedContentTag: jest.fn(),
setStagedTags: jest.fn(),
};
const ContentTagsCollapsibleComponent = ({
contentId,
taxonomyAndTagsData,
stagedContentTags,
addStagedContentTag,
removeStagedContentTag,
setStagedTags,
}) => (
<IntlProvider locale="en" messages={{}}>
<ContentTagsCollapsible
contentId={contentId}
taxonomyAndTagsData={taxonomyAndTagsData}
stagedContentTags={stagedContentTags}
addStagedContentTag={addStagedContentTag}
removeStagedContentTag={removeStagedContentTag}
setStagedTags={setStagedTags}
/>
</IntlProvider>
);
ContentTagsCollapsibleComponent.propTypes = ContentTagsCollapsible.propTypes;
describe('<ContentTagsCollapsible />', () => {
beforeAll(() => {
jest.useFakeTimers(); // To account for debounce timer
});
afterAll(() => {
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);
return render(
<ContentTagsCollapsibleComponent
contentId={componentData.contentId}
taxonomyAndTagsData={componentData.taxonomyAndTagsData}
stagedContentTags={componentData.stagedContentTags}
addStagedContentTag={componentData.addStagedContentTag}
removeStagedContentTag={componentData.removeStagedContentTag}
setStagedTags={componentData.setStagedTags}
/>,
);
}
it('should render taxonomy tags data along content tags number badge', async () => {
const { container, getByText } = await getComponent();
expect(getByText('Taxonomy 1')).toBeInTheDocument();
expect(container.getElementsByClassName('badge').length).toBe(1);
expect(getByText('3')).toBeInTheDocument();
});
it('should call `addStagedContentTag` when tag checked 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 and check the `addStagedContentTag` was called with the correct params
const tag3 = getByText('Tag 3');
fireEvent.click(tag3);
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();
});
it('should handle search term change', async () => {
const {
container, getByText, getByRole, getByDisplayValue,
} = 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);
// Get the search field
const searchField = getByRole('combobox');
const searchTerm = 'memo';
// Trigger a change in the search field
userEvent.type(searchField, searchTerm);
await act(async () => {
// Fast-forward time by 500 milliseconds (for the debounce delay)
jest.advanceTimersByTime(500);
});
// Check that the search term has been set
expect(searchField).toHaveValue(searchTerm);
expect(getByDisplayValue(searchTerm)).toBeInTheDocument();
// Clear search
userEvent.clear(searchField);
// Check that the search term has been cleared
expect(searchField).toHaveValue('');
});
it('should close dropdown selector when clicking away', async () => {
const { container, getByText, queryByText } = 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();
// Simulate clicking outside the dropdown remove focus
userEvent.click(document.body);
// Simulate clicking outside the dropdown again to close it
userEvent.click(document.body);
// Wait for the dropdown selector for tags to close, Tag 3 is no longer on
// the page
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 };
updatedData.taxonomyAndTagsData.contentTags = [];
const { container, getByText } = await getComponent(updatedData);
expect(getByText('Taxonomy 1')).toBeInTheDocument();
expect(container.getElementsByClassName('invisible').length).toBe(1);
});
});

View File

@@ -1,322 +0,0 @@
// @ts-check
import React from 'react';
import { useCheckboxSetValues } from '@openedx/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.
*
* @param {object} tree - tree that needs it's keys sorted
* @returns {object} sorted tree
*/
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;
};
/**
* 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 = [];
function traverse(node) {
Object.keys(node).forEach(key => {
const child = node[key];
if (Object.keys(child.children).length === 0) {
leafKeys.push(key);
} else {
traverse(child.children);
}
});
}
traverse(tree);
return leafKeys;
};
/**
* 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>
* }}
*/
const useContentTagsCollapsibleHelper = (
contentId,
taxonomyAndTagsData,
addStagedContentTag,
removeStagedContentTag,
stagedContentTags,
) => {
const {
id, contentTags, canTagObject,
} = taxonomyAndTagsData;
// State to determine whether an applied tag was removed so we make a call
// to the update endpoint to the reflect those changes
const [removingAppliedTag, setRemoveAppliedTag] = React.useState(false);
const updateTags = useContentTaxonomyTagsUpdater(contentId, id);
// Keeps track of the content objects tags count (both implicit and explicit)
const [contentTagsCount, setContentTagsCount] = React.useState(0);
// Keeps track of the tree structure for tags that are add by selecting/unselecting
// tags in the dropdowns.
const [stagedContentTagsTree, setStagedContentTagsTree] = React.useState({});
// To handle checking/unchecking tags in the SelectableBox
const [checkedTags, { add, remove }] = 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
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
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 });
}
}, [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]);
// This converts the contentTags prop to the tree structure mentioned above
const appliedContentTagsTree = React.useMemo(() => {
let contentTagsCounter = 0;
// 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) {
updateTags.reset();
}
const resultTree = {};
contentTags.forEach(item => {
let currentLevel = resultTree;
item.lineage.forEach((key, index) => {
if (!currentLevel[key]) {
const isExplicit = index === item.lineage.length - 1;
currentLevel[key] = {
explicit: isExplicit,
children: {},
canChangeObjecttag: item.canChangeObjecttag,
canDeleteObjecttag: item.canDeleteObjecttag,
};
// 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);
}
contentTagsCounter += 1;
}
currentLevel = currentLevel[key].children;
});
});
setContentTagsCount(contentTagsCounter);
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;
}, []);
// Add tag to the tree, and while traversing remove any selected ancestor tags
// as they should become implicit
const addTags = (tree, tagLineage, selectedTag) => {
const value = [];
let traversal = tree;
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,
children: {},
canChangeObjecttag: false,
canDeleteObjecttag: false,
};
} else {
traversal[tag].explicit = isExplicit;
}
// eslint-disable-next-line no-unused-expressions
isExplicit ? add(value.join(',')) : remove(value.join(','));
traversal = traversal[tag].children;
});
};
const tagChangeHandler = React.useCallback((tagSelectableBoxValue, checked) => {
const tagLineage = tagSelectableBoxValue.split(',').map(t => decodeURIComponent(t));
const selectedTag = tagLineage.slice(-1)[0];
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,
},
);
} 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;
});
}
}, [
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]);
return {
tagChangeHandler,
removeAppliedTagHandler,
appliedContentTagsTree: sortKeysAlphabetically(appliedContentTagsTree),
stagedContentTagsTree: sortKeysAlphabetically(stagedContentTagsTree),
contentTagsCount,
checkedTags,
commitStagedTags,
updateTags,
};
};
export default useContentTagsCollapsibleHelper;

View File

@@ -1,185 +0,0 @@
// @ts-check
import React, {
useMemo,
useEffect,
useState,
useCallback,
} from 'react';
import PropTypes from 'prop-types';
import {
Container,
CloseButton,
Spinner,
} from '@openedx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useParams } from 'react-router-dom';
import messages from './messages';
import ContentTagsCollapsible from './ContentTagsCollapsible';
import { extractOrgFromContentId } from './utils';
import {
useContentTaxonomyTagsData,
useContentData,
} from './data/apiHooks';
import { useTaxonomyList } 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 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 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 { data: contentData, isSuccess: isContentDataLoaded } = useContentData(contentId);
const {
data: contentTaxonomyTagsData,
isSuccess: isContentTaxonomyTagsLoaded,
} = useContentTaxonomyTagsData(contentId);
const { data: taxonomyListData, isSuccess: isTaxonomyListLoaded } = useTaxonomyList(org);
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', '*');
};
}
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();
}
};
document.addEventListener('keydown', handleEsc);
return () => {
document.removeEventListener('keydown', handleEsc);
};
}, []);
const taxonomies = useMemo(() => {
if (taxonomyListData && contentTaxonomyTagsData) {
// Initialize list of content tags in taxonomies to populate
const taxonomiesList = taxonomyListData.results.map((taxonomy) => ({
...taxonomy,
contentTags: /** @type {ContentTagData[]} */([]),
}));
const contentTaxonomies = contentTaxonomyTagsData.taxonomies;
// eslint-disable-next-line array-callback-return
contentTaxonomies.map((contentTaxonomyTags) => {
const contentTaxonomy = taxonomiesList.find((taxonomy) => taxonomy.id === contentTaxonomyTags.taxonomyId);
if (contentTaxonomy) {
contentTaxonomy.contentTags = contentTaxonomyTags.tags;
}
});
return taxonomiesList;
}
return [];
}, [taxonomyListData, contentTaxonomyTagsData]);
return (
<div className="mt-1">
<Container size="xl">
<CloseButton onClick={() => onCloseDrawer()} data-testid="drawer-close-button" />
<span>{intl.formatMessage(messages.headerSubtitle)}</span>
{ isContentDataLoaded
? <h3>{ contentName }</h3>
: (
<div className="d-flex justify-content-center align-items-center flex-column">
<Spinner
animation="border"
size="xl"
screenReaderText={intl.formatMessage(messages.loadingMessage)}
/>
</div>
)}
<hr />
{ 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}
/>
<hr />
</div>
))
: <Loading />}
</Container>
</div>
);
};
ContentTagsDrawer.propTypes = {
id: PropTypes.string,
onClose: PropTypes.func,
};
ContentTagsDrawer.defaultProps = {
id: undefined,
onClose: undefined,
};
export default ContentTagsDrawer;

View File

@@ -1,416 +0,0 @@
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 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();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: () => ({
contentId,
}),
}));
// FIXME: replace these mocks with API mocks
jest.mock('./data/apiHooks', () => ({
useContentTaxonomyTagsData: jest.fn(() => ({
isSuccess: false,
data: {},
})),
useContentData: jest.fn(() => ({
isSuccess: false,
data: {},
})),
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(),
}));
const queryClient = new QueryClient();
const RootWrapper = (params) => (
<IntlProvider locale="en" messages={{}}>
<QueryClientProvider client={queryClient}>
<ContentTagsDrawer {...params} />
</QueryClientProvider>
</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();
});
it('shows spinner before the content data query is complete', async () => {
await act(async () => {
const { getAllByRole } = render(<RootWrapper />);
const spinner = getAllByRole('status')[0];
expect(spinner.textContent).toEqual('Loading'); // Uses <Spinner />
});
});
it('shows spinner before the taxonomy tags query is complete', async () => {
await act(async () => {
const { getAllByRole } = render(<RootWrapper />);
const spinner = getAllByRole('status')[1];
expect(spinner.textContent).toEqual('Loading...'); // Uses <Loading />
});
});
it('shows the content display name after the query is complete', async () => {
useContentData.mockReturnValue({
isSuccess: true,
data: {
displayName: 'Unit 1',
},
});
await act(async () => {
const { getByText } = render(<RootWrapper />);
expect(getByText('Unit 1')).toBeInTheDocument();
});
});
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 () => {
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,
},
],
},
{
name: 'Taxonomy 2',
taxonomyId: 124,
canTagObject: true,
tags: [
{
value: 'Tag 3',
lineage: ['Tag 3'],
canDeleteObjecttag: true,
},
],
},
],
},
});
getTaxonomyListData.mockResolvedValue({
results: [{
id: 123,
name: 'Taxonomy 1',
description: 'This is a description 1',
canTagObject: false,
}, {
id: 124,
name: 'Taxonomy 2',
description: 'This is a description 2',
canTagObject: false,
}],
});
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');
expect(tagCountBadges[0].textContent).toBe('2');
expect(tagCountBadges[1].textContent).toBe('1');
});
});
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 () => {
const postMessageSpy = jest.spyOn(window.parent, 'postMessage');
const { getByTestId } = render(<RootWrapper />);
// Find the CloseButton element by its test ID and trigger a click event
const closeButton = getByTestId('drawer-close-button');
fireEvent.click(closeButton);
expect(postMessageSpy).toHaveBeenCalledWith('closeManageTagsDrawer', '*');
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', () => {
const postMessageSpy = jest.spyOn(window.parent, 'postMessage');
const { container } = render(<RootWrapper />);
fireEvent.keyDown(container, {
key: 'Escape',
});
expect(postMessageSpy).toHaveBeenCalledWith('closeManageTagsDrawer', '*');
postMessageSpy.mockRestore();
});
it('should not call closeManageTagsDrawer when Escape key is pressed and a selectable box is active', () => {
const postMessageSpy = jest.spyOn(window.parent, 'postMessage');
const { container } = render(<RootWrapper />);
// Simulate that the selectable box is open by adding an element with the data attribute
const selectableBox = document.createElement('div');
selectableBox.setAttribute('data-selectable-box', 'taxonomy-tags');
document.body.appendChild(selectableBox);
fireEvent.keyDown(container, {
key: 'Escape',
});
expect(postMessageSpy).not.toHaveBeenCalled();
// Remove the added element
document.body.removeChild(selectableBox);
postMessageSpy.mockRestore();
});
});

View File

@@ -1,358 +0,0 @@
// @ts-check
import React, { useEffect, useState, useCallback } from 'react';
import {
Icon,
Spinner,
Button,
} from '@openedx/paragon';
import { SelectableBox } from '@edx/frontend-lib-content-components';
import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
import { ArrowDropDown, ArrowDropUp, Add } from '@openedx/paragon/icons';
import PropTypes from 'prop-types';
import messages from './messages';
import { useTaxonomyTagsData } from './data/apiHooks';
const HighlightedText = ({ text, highlight }) => {
if (!highlight) {
return <span>{text}</span>;
}
const parts = text.split(new RegExp(`(${highlight})`, 'gi'));
return (
<span>
{parts.map((part, index) => (
// eslint-disable-next-line react/no-array-index-key -- using index because part is not unique
<React.Fragment key={index}>
{part.toLowerCase() === highlight.toLowerCase() ? <b>{part}</b> : part}
</React.Fragment>
))}
</span>
);
};
HighlightedText.propTypes = {
text: PropTypes.string.isRequired,
highlight: PropTypes.string,
};
HighlightedText.defaultProps = {
highlight: '',
};
const ContentTagsDropDownSelector = ({
taxonomyId, level, lineage, appliedContentTagsTree, stagedContentTagsTree, searchTerm,
}) => {
const intl = useIntl();
// This object represents the states of the dropdowns on this level
// The keys represent the index of the dropdown with
// the value true (open) false (closed)
const [dropdownStates, setDropdownStates] = useState(/** type Record<string, boolean> */ {});
const isOpen = (tagValue) => dropdownStates[tagValue];
const [numPages, setNumPages] = useState(1);
const parentTagValue = lineage.length ? decodeURIComponent(lineage[lineage.length - 1]) : null;
const { hasMorePages, tagPages } = useTaxonomyTagsData(taxonomyId, parentTagValue, numPages, searchTerm);
const [prevSearchTerm, setPrevSearchTerm] = useState(searchTerm);
// Reset the page and tags state when search term changes
// and store search term to compare
if (prevSearchTerm !== searchTerm) {
setPrevSearchTerm(searchTerm);
setNumPages(1);
}
useEffect(() => {
if (tagPages.isSuccess) {
if (searchTerm) {
const expandAll = tagPages.data.reduce(
(acc, tagData) => ({
...acc,
[tagData.value]: !!tagData.childCount,
}),
{},
);
setDropdownStates(expandAll);
} else {
setDropdownStates({});
}
}
}, [searchTerm, tagPages.isSuccess]);
const clickAndEnterHandler = (tagValue) => {
// This flips the state of the dropdown at index false (closed) -> true (open)
// and vice versa. Initially they are undefined which is falsy.
setDropdownStates({ ...dropdownStates, [tagValue]: !dropdownStates[tagValue] });
};
const isImplicit = (tag) => {
// Traverse the applied tags tree using the lineage
let appliedTraversal = appliedContentTagsTree;
lineage.forEach(t => {
appliedTraversal = appliedTraversal[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;
};
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 ? (
<div className="d-flex justify-content-center align-items-center flex-row">
<Spinner
animation="border"
size="xl"
screenReaderText={intl.formatMessage(messages.loadingTagsDropdownMessage)}
/>
</div>
) : 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">
<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) },
)
}
>
<SelectableBox
inputHidden={false}
type="checkbox"
className="d-flex align-items-center taxonomy-tags-selectable-box"
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"
>
<HighlightedText text={tagData.value} highlight={searchTerm} />
</SelectableBox>
{ tagData.childCount > 0
&& (
<div className="d-flex align-items-center taxonomy-tags-arrow-drop-down">
<Icon
src={isOpen(tagData.value) ? ArrowDropUp : ArrowDropDown}
onClick={() => clickAndEnterHandler(tagData.value)}
tabIndex="-1"
/>
</div>
)}
</div>
</div>
{ tagData.childCount > 0 && isOpen(tagData.value) && (
<ContentTagsDropDownSelector
taxonomyId={taxonomyId}
level={level + 1}
lineage={[...lineage, tagData.value]}
appliedContentTagsTree={appliedContentTagsTree}
stagedContentTagsTree={stagedContentTagsTree}
searchTerm={searchTerm}
/>
)}
</div>
))}
{ hasMorePages
? (
<div>
<Button
tabIndex="0"
variant="tertiary"
iconBefore={Add}
onClick={loadMoreTags}
className="mb-2 ml-1 taxonomy-tags-load-more-button px-0 text-info-500"
>
<FormattedMessage {...messages.loadMoreTagsButtonText} />
</Button>
</div>
)
: null}
{ tagPages.data.length === 0 && !tagPages.isLoading && (
<div className="d-flex justify-content-center muted-text">
<FormattedMessage {...messages.noTagsFoundMessage} values={{ searchTerm }} />
</div>
)}
</div>
);
};
ContentTagsDropDownSelector.defaultProps = {
lineage: [],
searchTerm: '',
};
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(
PropTypes.shape({
explicit: PropTypes.bool.isRequired,
children: PropTypes.shape({}).isRequired,
}).isRequired,
).isRequired,
searchTerm: PropTypes.string,
};
export default ContentTagsDropDownSelector;

View File

@@ -1,39 +0,0 @@
.taxonomy-tags-arrow-drop-down {
cursor: pointer;
}
.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,
.pgn__selectable_box.taxonomy-tags-selectable-box[disabled] {
opacity: 1 !important;
}
.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;
}

View File

@@ -1,285 +0,0 @@
import React from 'react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import {
act,
render,
waitFor,
fireEvent,
} from '@testing-library/react';
import ContentTagsDropDownSelector from './ContentTagsDropDownSelector';
import { useTaxonomyTagsData } from './data/apiHooks';
jest.mock('./data/apiHooks', () => ({
useTaxonomyTagsData: jest.fn(() => ({
hasMorePages: false,
tagPages: {
isLoading: true,
isError: false,
data: [],
},
})),
}));
const data = {
taxonomyId: 123,
level: 0,
tagsTree: {},
appliedContentTagsTree: {},
stagedContentTagsTree: {},
};
const ContentTagsDropDownSelectorComponent = ({
taxonomyId, level, lineage, tagsTree, searchTerm, appliedContentTagsTree, stagedContentTagsTree,
}) => (
<IntlProvider locale="en" messages={{}}>
<ContentTagsDropDownSelector
taxonomyId={taxonomyId}
level={level}
lineage={lineage}
tagsTree={tagsTree}
searchTerm={searchTerm}
appliedContentTagsTree={appliedContentTagsTree}
stagedContentTagsTree={stagedContentTagsTree}
/>
</IntlProvider>
);
ContentTagsDropDownSelectorComponent.defaultProps = {
lineage: [],
searchTerm: '',
};
ContentTagsDropDownSelectorComponent.propTypes = ContentTagsDropDownSelector.propTypes;
describe('<ContentTagsDropDownSelector />', () => {
afterEach(() => {
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 spinner = getByRole('status');
expect(spinner.textContent).toEqual('Loading tags'); // Uses <Spinner />
});
});
it('should render taxonomy tags drop down selector with no sub tags', async () => {
useTaxonomyTagsData.mockReturnValue({
hasMorePages: false,
tagPages: {
isLoading: false,
isError: false,
data: [{
value: 'Tag 1',
externalId: null,
childCount: 0,
depth: 0,
parentValue: null,
id: 12345,
subTagsUrl: null,
}],
},
});
await act(async () => {
const { container, getByText } = await getComponent();
await waitFor(() => {
expect(getByText('Tag 1')).toBeInTheDocument();
expect(container.getElementsByClassName('taxonomy-tags-arrow-drop-down').length).toBe(0);
});
});
});
it('should render taxonomy tags drop down selector with sub tags', async () => {
useTaxonomyTagsData.mockReturnValue({
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 { container, getByText } = await getComponent();
await waitFor(() => {
expect(getByText('Tag 2')).toBeInTheDocument();
expect(container.getElementsByClassName('taxonomy-tags-arrow-drop-down').length).toBe(1);
});
});
});
it('should expand on click 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 } = await getComponent(dataWithTagsTree);
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.click(expandToggle);
await waitFor(() => {
expect(getByText('Tag 3')).toBeInTheDocument();
});
});
});
it('should render taxonomy tags drop down selector and change search term', async () => {
useTaxonomyTagsData.mockReturnValueOnce({
hasMorePages: false,
tagPages: {
isLoading: false,
isError: false,
isSuccess: true,
data: [{
value: 'Tag 1',
externalId: null,
childCount: 0,
depth: 0,
parentValue: null,
id: 12345,
subTagsUrl: null,
}],
},
});
const initalSearchTerm = 'test 1';
await act(async () => {
const { rerender } = await getComponent({ ...data, searchTerm: initalSearchTerm });
await waitFor(() => {
expect(useTaxonomyTagsData).toBeCalledWith(data.taxonomyId, null, 1, initalSearchTerm);
});
const updatedSearchTerm = 'test 2';
rerender(<ContentTagsDropDownSelectorComponent
key={`selector-${data.taxonomyId}`}
taxonomyId={data.taxonomyId}
level={data.level}
tagsTree={data.tagsTree}
searchTerm={updatedSearchTerm}
appliedContentTagsTree={{}}
stagedContentTagsTree={{}}
/>);
await waitFor(() => {
expect(useTaxonomyTagsData).toBeCalledWith(data.taxonomyId, null, 1, updatedSearchTerm);
});
// Clean search term
const cleanSearchTerm = '';
rerender(<ContentTagsDropDownSelectorComponent
key={`selector-${data.taxonomyId}`}
taxonomyId={data.taxonomyId}
level={data.level}
tagsTree={data.tagsTree}
searchTerm={cleanSearchTerm}
appliedContentTagsTree={{}}
stagedContentTagsTree={{}}
/>);
await waitFor(() => {
expect(useTaxonomyTagsData).toBeCalledWith(data.taxonomyId, null, 1, cleanSearchTerm);
});
});
});
it('should render "noTag" message if search doesnt return taxonomies', async () => {
useTaxonomyTagsData.mockReturnValueOnce({
hasMorePages: false,
tagPages: {
isLoading: false,
isError: false,
isSuccess: true,
data: [],
},
});
const searchTerm = 'uncommon search term';
await act(async () => {
const { getByText } = await getComponent({ ...data, searchTerm });
await waitFor(() => {
expect(useTaxonomyTagsData).toBeCalledWith(data.taxonomyId, null, 1, searchTerm);
});
const message = `No tags found with the search term "${searchTerm}"`;
expect(getByText(message)).toBeInTheDocument();
});
});
});

View File

@@ -1,81 +0,0 @@
// @ts-check
import React from 'react';
import PropTypes from 'prop-types';
import TagBubble from './TagBubble';
/**
* Component that renders Tags under a Taxonomy in the nested tree format.
*
* Example:
*
* {
* "Science and Research": {
* explicit: false,
* children: {
* "Genetics Subcategory": {
* explicit: false,
* children: {
* "DNA Sequencing": {
* explicit: true,
* children: {}
* }
* }
* },
* "Molecular, Cellular, and Microbiology": {
* explicit: false,
* children: {
* "Virology": {
* explicit: true,
* children: {}
* }
* }
* }
* }
* }
* };
*
* @param {Object} props - The component props.
* @param {Object} props.tagsTree - Array of taxonomy tags that are applied to the content.
* @param {(
* tagSelectableBoxValue: string,
* checked: boolean
* ) => void} props.removeTagHandler - Function that is called when removing tags from the tree.
*/
const ContentTagsTree = ({ tagsTree, removeTagHandler }) => {
const renderTagsTree = (tag, level, lineage) => Object.keys(tag).map((key) => {
const updatedLineage = [...lineage, encodeURIComponent(key)];
if (tag[key] !== undefined) {
return (
<div key={`tag-${key}-level-${level}`}>
<TagBubble
key={`tag-${key}`}
value={key}
implicit={!tag[key].explicit}
level={level}
lineage={updatedLineage}
removeTagHandler={removeTagHandler}
canRemove={tag[key].canDeleteObjecttag}
/>
{ renderTagsTree(tag[key].children, level + 1, updatedLineage) }
</div>
);
}
return null;
});
return <>{renderTagsTree(tagsTree, 0, [])}</>;
};
ContentTagsTree.propTypes = {
tagsTree: PropTypes.objectOf(
PropTypes.shape({
explicit: PropTypes.bool.isRequired,
children: PropTypes.shape({}).isRequired,
canDeleteObjecttag: PropTypes.bool.isRequired,
}).isRequired,
).isRequired,
removeTagHandler: PropTypes.func.isRequired,
};
export default ContentTagsTree;

View File

@@ -1,57 +0,0 @@
import React from 'react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { act, render } from '@testing-library/react';
import ContentTagsTree from './ContentTagsTree';
const data = {
'Science and Research': {
explicit: false,
canDeleteObjecttag: false,
children: {
'Genetics Subcategory': {
explicit: false,
children: {
'DNA Sequencing': {
explicit: true,
children: {},
canDeleteObjecttag: true,
},
},
canDeleteObjecttag: false,
},
'Molecular, Cellular, and Microbiology': {
explicit: false,
children: {
Virology: {
explicit: true,
children: {},
canDeleteObjecttag: true,
},
},
canDeleteObjecttag: false,
},
},
},
};
const ContentTagsTreeComponent = ({ tagsTree, removeTagHandler }) => (
<IntlProvider locale="en" messages={{}}>
<ContentTagsTree tagsTree={tagsTree} removeTagHandler={removeTagHandler} />
</IntlProvider>
);
ContentTagsTreeComponent.propTypes = ContentTagsTree.propTypes;
describe('<ContentTagsTree />', () => {
it('should render taxonomy tags data along content tags number badge', async () => {
await act(async () => {
const { getByText } = render(<ContentTagsTreeComponent tagsTree={data} removeTagHandler={() => {}} />);
expect(getByText('Science and Research')).toBeInTheDocument();
expect(getByText('Genetics Subcategory')).toBeInTheDocument();
expect(getByText('Molecular, Cellular, and Microbiology')).toBeInTheDocument();
expect(getByText('DNA Sequencing')).toBeInTheDocument();
expect(getByText('Virology')).toBeInTheDocument();
});
});
});

View File

@@ -1,51 +0,0 @@
import React from 'react';
import {
Chip,
} from '@openedx/paragon';
import { Tag, Close } from '@openedx/paragon/icons';
import PropTypes from 'prop-types';
import TagOutlineIcon from './TagOutlineIcon';
const TagBubble = ({
value, implicit, level, lineage, removeTagHandler, canRemove,
}) => {
const className = `tag-bubble mb-2 border-light-300 ${implicit ? 'implicit' : ''}`;
const handleClick = React.useCallback(() => {
if (!implicit && canRemove) {
removeTagHandler(lineage.join(','));
}
}, [implicit, lineage, canRemove, removeTagHandler]);
return (
<div style={{ paddingLeft: `${level * 1}rem` }}>
<Chip
className={className}
variant="light"
iconBefore={!implicit ? Tag : TagOutlineIcon}
iconAfter={!implicit && canRemove ? Close : null}
onIconAfterClick={handleClick}
>
{value}
</Chip>
</div>
);
};
TagBubble.defaultProps = {
implicit: true,
level: 0,
canRemove: false,
};
TagBubble.propTypes = {
value: PropTypes.string.isRequired,
implicit: PropTypes.bool,
level: PropTypes.number,
lineage: PropTypes.arrayOf(PropTypes.string).isRequired,
removeTagHandler: PropTypes.func.isRequired,
canRemove: PropTypes.bool,
};
export default TagBubble;

View File

@@ -1,5 +0,0 @@
.tag-bubble.pgn__chip {
border-style: solid;
border-width: 2px;
background-color: transparent;
}

View File

@@ -1,109 +0,0 @@
import React from 'react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { render, fireEvent } from '@testing-library/react';
import TagBubble from './TagBubble';
const data = {
value: 'Tag 1',
lineage: [],
removeTagHandler: jest.fn(),
};
const TagBubbleComponent = ({
value, implicit, level, lineage, removeTagHandler, canRemove,
}) => (
<IntlProvider locale="en" messages={{}}>
<TagBubble
value={value}
implicit={implicit}
level={level}
lineage={lineage}
removeTagHandler={removeTagHandler}
canRemove={canRemove}
/>
</IntlProvider>
);
TagBubbleComponent.defaultProps = {
implicit: true,
level: 0,
canRemove: false,
};
TagBubbleComponent.propTypes = TagBubble.propTypes;
describe('<TagBubble />', () => {
it('should render implicit tag', () => {
const { container, getByText } = render(
<TagBubbleComponent
value={data.value}
lineage={data.lineage}
removeTagHandler={data.removeTagHandler}
/>,
);
expect(getByText(data.value)).toBeInTheDocument();
expect(container.getElementsByClassName('implicit').length).toBe(1);
expect(container.getElementsByClassName('pgn__chip__icon-after').length).toBe(0);
});
it('should render explicit tag', () => {
const tagBubbleData = {
implicit: false,
canRemove: true,
...data,
};
const { container, getByText } = render(
<TagBubbleComponent
value={tagBubbleData.value}
canRemove={tagBubbleData.canRemove}
lineage={data.lineage}
implicit={tagBubbleData.implicit}
removeTagHandler={tagBubbleData.removeTagHandler}
/>,
);
expect(getByText(`${tagBubbleData.value}`)).toBeInTheDocument();
expect(container.getElementsByClassName('implicit').length).toBe(0);
expect(container.getElementsByClassName('pgn__chip__icon-after').length).toBe(1);
});
it('should call removeTagHandler when "x" clicked on explicit tag', async () => {
const tagBubbleData = {
implicit: false,
canRemove: true,
...data,
};
const { container } = render(
<TagBubbleComponent
value={tagBubbleData.value}
canRemove={tagBubbleData.canRemove}
lineage={data.lineage}
implicit={tagBubbleData.implicit}
removeTagHandler={tagBubbleData.removeTagHandler}
/>,
);
const xButton = container.getElementsByClassName('pgn__chip__icon-after')[0];
fireEvent.click(xButton);
expect(data.removeTagHandler).toHaveBeenCalled();
});
it('should not show "x" when canRemove is not allowed', async () => {
const tagBubbleData = {
implicit: false,
canRemove: false,
...data,
};
const { container } = render(
<TagBubbleComponent
value={tagBubbleData.value}
canRemove={tagBubbleData.canRemove}
lineage={data.lineage}
implicit={tagBubbleData.implicit}
removeTagHandler={tagBubbleData.removeTagHandler}
/>,
);
expect(container.getElementsByClassName('pgn__chip__icon-after')[0]).toBeUndefined();
});
});

View File

@@ -1,20 +0,0 @@
const TagOutlineIcon = (props) => (
<svg
xmlns="http://www.w3.org/2000/svg"
height="24px"
viewBox="0 0 24 24"
width="24px"
fill="currentColor"
role="img"
focusable="false"
aria-hidden="true"
{...props}
>
<path
d="m21.41 11.58-9-9C12.05 2.22 11.55 2 11 2H4c-1.1 0-2 .9-2 2v7c0 .55.22 1.05.59 1.42l9 9c.36.36.86.58 1.41.58s1.05-.22 1.41-.59l7-7c.37-.36.59-.86.59-1.41s-.23-1.06-.59-1.42zM13 20.01 4 11V4h7v-.01l9 9-7 7.02z"
/>
<circle cx="6.5" cy="6.5" r="1.5" />
</svg>
);
export default TagOutlineIcon;

View File

@@ -1,63 +0,0 @@
module.exports = {
id: 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@7f47fe2dbcaf47c5a071671c741fe1ab',
displayName: 'Unit 1.1.2',
category: 'vertical',
hasChildren: true,
editedOn: 'Nov 12, 2023 at 09:53 UTC',
published: false,
publishedOn: null,
studioUrl: '/container/block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@7f47fe2dbcaf47c5a071671c741fe1ab',
releasedToStudents: false,
releaseDate: null,
visibilityState: 'needs_attention',
hasExplicitStaffLock: false,
start: '2030-01-01T00:00:00Z',
graded: false,
dueDate: '',
due: null,
relativeWeeksDue: null,
format: null,
courseGraders: [
'Homework',
'Lab',
'Midterm Exam',
'Final Exam',
],
hasChanges: true,
actions: {
deletable: true,
draggable: true,
childAddable: true,
duplicable: true,
},
explanatoryMessage: null,
groupAccess: {},
userPartitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
showCorrectness: 'always',
discussionEnabled: true,
ancestorHasStaffLock: false,
taxonomyTagsWidgetUrl: 'http://localhost:2001/tagging/components/widget/',
staffOnlyMessage: false,
enableCopyPasteUnits: true,
useTaggingTaxonomyListPage: true,
hasPartitionGroupComponents: false,
userPartitionInfo: {
selectablePartitions: [],
selectedPartitionIndex: -1,
selectedGroupsLabel: '',
},
};

View File

@@ -1,3 +0,0 @@
module.exports = {
'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b': 20,
};

View File

@@ -1,50 +0,0 @@
module.exports = {
'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b': {
taxonomies: [
{
name: 'FlatTaxonomy',
taxonomyId: 3,
canTagObject: true,
tags: [
{
value: 'flat taxonomy tag 3856',
lineage: [
'flat taxonomy tag 3856',
],
},
],
},
{
name: 'HierarchicalTaxonomy',
taxonomyId: 4,
canTagObject: true,
tags: [
{
value: 'hierarchical taxonomy tag 1.7.59',
lineage: [
'hierarchical taxonomy tag 1',
'hierarchical taxonomy tag 1.7',
'hierarchical taxonomy tag 1.7.59',
],
},
{
value: 'hierarchical taxonomy tag 2.13.46',
lineage: [
'hierarchical taxonomy tag 2',
'hierarchical taxonomy tag 2.13',
'hierarchical taxonomy tag 2.13.46',
],
},
{
value: 'hierarchical taxonomy tag 3.4.50',
lineage: [
'hierarchical taxonomy tag 3',
'hierarchical taxonomy tag 3.4',
'hierarchical taxonomy tag 3.4.50',
],
},
],
},
],
},
};

View File

@@ -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: {},
},
},
},
},
},
};

View File

@@ -1,6 +0,0 @@
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';

View File

@@ -1,46 +0,0 @@
module.exports = {
next: null,
previous: null,
count: 4,
numPages: 1,
currentPage: 1,
start: 0,
results: [
{
value: 'tag 1',
externalId: null,
childCount: 16,
depth: 0,
parentValue: null,
id: 635951,
subTagsUrl: 'http://localhost:18010/api/content_tagging/v1/taxonomies/4/tags/?parent_tag=tag%201',
},
{
value: 'tag 2',
externalId: null,
childCount: 16,
depth: 0,
parentValue: null,
id: 636992,
subTagsUrl: 'http://localhost:18010/api/content_tagging/v1/taxonomies/4/tags/?parent_tag=tag%202',
},
{
value: 'tag 3',
externalId: null,
childCount: 16,
depth: 0,
parentValue: null,
id: 638033,
subTagsUrl: 'http://localhost:18010/api/content_tagging/v1/taxonomies/4/tags/?parent_tag=tag%203',
},
{
value: 'tag 4',
externalId: null,
childCount: 16,
depth: 0,
parentValue: null,
id: 639074,
subTagsUrl: 'http://localhost:18010/api/content_tagging/v1/taxonomies/4/tags/?parent_tag=tag%204',
},
],
};

View File

@@ -1,25 +0,0 @@
module.exports = {
'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b': {
taxonomies: [
{
name: 'FlatTaxonomy',
taxonomyId: 3,
canTagObject: true,
tags: [
{
value: 'flat taxonomy tag 100',
lineage: [
'flat taxonomy tag 100',
],
},
{
value: 'flat taxonomy tag 3856',
lineage: [
'flat taxonomy tag 3856',
],
},
],
},
],
},
};

View File

@@ -1,102 +0,0 @@
// @ts-check
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
/**
* Get the URL used to fetch tags data from the "taxonomy tags" REST API
* @param {number} taxonomyId
* @param {{page?: number, searchTerm?: string, parentTag?: string}} options
* @returns {string} the URL
*/
export const getTaxonomyTagsApiUrl = (taxonomyId, options = {}) => {
const url = new URL(`api/content_tagging/v1/taxonomies/${taxonomyId}/tags/`, getApiBaseUrl());
if (options.parentTag) {
url.searchParams.append('parent_tag', options.parentTag);
}
if (options.page) {
url.searchParams.append('page', String(options.page));
}
if (options.searchTerm) {
url.searchParams.append('search_term', options.searchTerm);
}
// Load in the full tree if children at once, if we can:
// Note: do not combine this with page_size (we currently aren't using page_size)
url.searchParams.append('full_depth_threshold', '1000');
return url.href;
};
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.
* @param {number} taxonomyId The id of the taxonomy to fetch tags for
* @param {{page?: number, searchTerm?: string, parentTag?: string}} options
* @returns {Promise<import("../../taxonomy/tag-list/data/types.mjs").TagListData>}
*/
export async function getTaxonomyTagsData(taxonomyId, options = {}) {
const url = getTaxonomyTagsApiUrl(taxonomyId, options);
const { data } = await getAuthenticatedHttpClient().get(url);
return camelCaseObject(data);
}
/**
* Get the tags that are applied to the content object
* @param {string} contentId The id of the content object to fetch the applied tags for
* @returns {Promise<import("./types.mjs").ContentTaxonomyTagsData>}
*/
export async function getContentTaxonomyTagsData(contentId) {
const { data } = await getAuthenticatedHttpClient().get(getContentTaxonomyTagsApiUrl(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 { data } = await getAuthenticatedHttpClient().get(url);
return camelCaseObject(data);
}
/**
* Update content object's applied tags
* @param {string} contentId The id of the content object (unit/component)
* @param {number} taxonomyId The id of the taxonomy the tags belong to
* @param {string[]} tags The list of tags (values) to set on content object
* @returns {Promise<import("./types.mjs").ContentTaxonomyTagsData>}
*/
export async function updateContentTaxonomyTags(contentId, taxonomyId, tags) {
const url = getContentTaxonomyTagsApiUrl(contentId);
const params = { taxonomy: taxonomyId };
const { data } = await getAuthenticatedHttpClient().put(url, { tags }, { params });
return camelCaseObject(data[contentId]);
}

View File

@@ -1,140 +0,0 @@
// @ts-check
import MockAdapter from 'axios-mock-adapter';
import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import {
taxonomyTagsMock,
contentTaxonomyTagsMock,
contentTaxonomyTagsCountMock,
contentDataMock,
updateContentTaxonomyTagsMock,
} from '../__mocks__';
import {
getTaxonomyTagsApiUrl,
getContentTaxonomyTagsApiUrl,
getXBlockContentDataApiURL,
getLibraryContentDataApiUrl,
getTaxonomyTagsData,
getContentTaxonomyTagsData,
getContentData,
updateContentTaxonomyTags,
getContentTaxonomyTagsCountApiUrl,
getContentTaxonomyTagsCount,
} from './api';
let axiosMock;
describe('content tags drawer api calls', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
});
afterEach(() => {
jest.clearAllMocks();
});
it('should get taxonomy tags data', async () => {
const taxonomyId = 123;
axiosMock.onGet().reply(200, taxonomyTagsMock);
const result = await getTaxonomyTagsData(taxonomyId);
expect(axiosMock.history.get[0].url).toEqual(getTaxonomyTagsApiUrl(taxonomyId));
expect(result).toEqual(taxonomyTagsMock);
});
it('should get taxonomy tags data with parentTag', async () => {
const taxonomyId = 123;
const options = { parentTag: 'Sample Tag' };
axiosMock.onGet().reply(200, taxonomyTagsMock);
const result = await getTaxonomyTagsData(taxonomyId, options);
expect(axiosMock.history.get[0].url).toContain('parent_tag=Sample+Tag');
expect(result).toEqual(taxonomyTagsMock);
});
it('should get taxonomy tags data with page', async () => {
const taxonomyId = 123;
const options = { page: 2 };
axiosMock.onGet().reply(200, taxonomyTagsMock);
const result = await getTaxonomyTagsData(taxonomyId, options);
expect(axiosMock.history.get[0].url).toContain('page=2');
expect(result).toEqual(taxonomyTagsMock);
});
it('should get taxonomy tags data with searchTerm', async () => {
const taxonomyId = 123;
const options = { searchTerm: 'memo' };
axiosMock.onGet().reply(200, taxonomyTagsMock);
const result = await getTaxonomyTagsData(taxonomyId, options);
expect(axiosMock.history.get[0].url).toContain('search_term=memo');
expect(result).toEqual(taxonomyTagsMock);
});
it('should get content taxonomy tags data', async () => {
const contentId = 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b';
axiosMock.onGet(getContentTaxonomyTagsApiUrl(contentId)).reply(200, contentTaxonomyTagsMock);
const result = await getContentTaxonomyTagsData(contentId);
expect(axiosMock.history.get[0].url).toEqual(getContentTaxonomyTagsApiUrl(contentId));
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);
const result = await getContentData(contentId);
expect(axiosMock.history.get[0].url).toEqual(getXBlockContentDataApiURL(contentId));
expect(result).toEqual(contentDataMock);
});
it('should get content data for V2 library component', async () => {
const contentId = 'lb:SampleTaxonomyOrg1:NTL1:html:a3eded6b-2106-429a-98be-63533d563d79';
axiosMock.onGet(getLibraryContentDataApiUrl(contentId)).reply(200, contentDataMock);
const result = await getContentData(contentId);
expect(axiosMock.history.get[0].url).toEqual(getLibraryContentDataApiUrl(contentId));
expect(result).toEqual(contentDataMock);
});
it('should update content taxonomy tags', async () => {
const contentId = 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b';
const taxonomyId = 3;
const tags = ['flat taxonomy tag 100', 'flat taxonomy tag 3856'];
axiosMock.onPut(`${getContentTaxonomyTagsApiUrl(contentId)}`).reply(200, updateContentTaxonomyTagsMock);
const result = await updateContentTaxonomyTags(contentId, taxonomyId, tags);
expect(axiosMock.history.put[0].url).toEqual(`${getContentTaxonomyTagsApiUrl(contentId)}`);
expect(result).toEqual(updateContentTaxonomyTagsMock[contentId]);
});
});

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