Compare commits

...

67 Commits

Author SHA1 Message Date
Muhammad Abdullah Waheed
beb5f51e47 refactor: updated data dog config 2024-04-04 12:32:41 +05:00
Muhammad Abdullah Waheed
6a115797e6 refactor: testing datadog logging 2024-03-29 22:15:10 +05:00
Rômulo Penido
f57d40ea34 feat: tag sections, subsections, and the whole course (FC-0053) (#879)
* feat: tag sections, subsections, and the whole course
* docs: add comments to useContentTagsCount
2024-03-28 17:44:29 +05:30
Kristin Aoki
80bf86992d fix: transcript and thumbnail uploads (#914)
* fix: transcript and thumbnail uploads

* chore: add missing tests
2024-03-25 10:02:09 -04:00
Yusuf Musleh
1dde30a0a2 [FC-0036] feat: Make tags widget keyboard accessible (#900)
Adds the ability to navigate the new "Add Tags" widget using the keyboard, making it fully accessible through the keyboard.
2024-03-21 17:26:22 +05:30
Braden MacDonald
9a6e12bd3b Clean up Taxonomy API files/hooks/queries [FC-0036] (#850)
* chore: rename apiHooks.jsx to apihooks.js

* refactor: consolidate taxonomy API code

* fix: was not invalidating tags after import

* fix: UI was freezing while computing plan for large import files
2024-03-20 09:31:10 +05:30
Kristin Aoki
784a811ff8 Revert "feat: show alerts related files included via paste unit (#847)" (#909)
This reverts commit 071aee5b02.
2024-03-19 13:34:33 -04:00
Navin Karkera
071aee5b02 feat: show alerts related files included via paste unit (#847)
* fix: block highlight and status for unscheduled course

* feat: show alerts related to files after pasting unit

* refactor: rename paste notices and add view files option to alert

* refactor: remove additional visibility state check

* refactor: page alert message
2024-03-19 10:27:18 -04:00
Navin Karkera
da68fb8e9d feat: allow dragging blocks across parents in outline (#859) 2024-03-19 11:25:02 -03:00
Kristin Aoki
972a7f324c feat: upgrade frontend-lib-content-components (#906) 2024-03-18 16:44:07 -04:00
Kristin Aoki
c809dfb2e4 feat: add pr template (#905) 2024-03-18 15:02:08 -04:00
Samir Sabri
1b2c65fae6 feat!: remove Transifex calls for OEP-58 (#627) 2024-03-18 15:01:20 -04:00
connorhaugh
b09729b55e fix: add new videos to the front of the videos list (#904) 2024-03-18 12:58:59 -04:00
connorhaugh
60917c6ab5 Feat: refactor upload thunk, no progress bar pt 2 (#899)
Attempts to fix: https://2u-internal.atlassian.net/browse/TNL-1141
2024-03-18 11:07:13 -04:00
Chris Chávez
d57ecc6779 [FC-0036] Tags Sidebar (#852)
* refactor: Unit sidebar to create the TagsSidebar

* feat: Structure of TagsSidebar and TagsTree

* feat: Adding styles to the TagsTree

* feat: TagsSidebarHeader created

* feat: Add count on TagsSidebarHeader

* test: Tests for new components added

* style: Update tags count with opacity when the count is zero

* refactor: Extract tag count component as generic

* refactor: Transform Sidebar to a wrapper component

---------

Co-authored-by: Rômulo Penido <romulo@opencraft.com>
2024-03-15 21:59:28 +05:30
connorhaugh
6ae9cdac00 Revert "feat: refactor upload thunk, no progress bar (#894)" (#895)
This reverts commit a88a88e9af.
2024-03-13 14:08:18 -04:00
Kristin Aoki
9740974bbd feat: add duplicate file validation for asset upload (#885)
* feat: add duplicate file validation for asset upload

* fix: modal only appearing once

* feat: add tests for overwrite modal

* fix: input not allowing second upload of same file

* fix: default pageSize for asset details
2024-03-13 12:09:00 -04:00
connorhaugh
a88a88e9af feat: refactor upload thunk, no progress bar (#894)
* feat: add progress bar for video uploads and refactor

---------

Co-authored-by: Kristin Aoki <42981026+KristinAoki@users.noreply.github.com>
2024-03-13 11:50:23 -04:00
connorhaugh
6baec5b6a3 Revert "feat: add progress bar for video uploads and refactor (#860)" (#893)
This reverts commit d76aaa73a4.
2024-03-13 10:18:05 -04:00
Peter Kulko
8acd27d7bf fix: replaced the LMS endpoint for navigating the course unit page
fix: [AXIMST-424] Course unit - Fixed network connection behavior (#138)

* fix: [AXIMST-424] fixed network connetcion behavior

* fix: added placeholder for unsuccessful loading for the page

* refactor: code refactoring
2024-03-13 10:56:10 -03:00
connorhaugh
d76aaa73a4 feat: add progress bar for video uploads and refactor (#860)
* feat: add progress bar for video uploads and refactor

---------

Co-authored-by: Kristin Aoki <42981026+KristinAoki@users.noreply.github.com>
2024-03-13 09:37:55 -04:00
Yusuf Musleh
4e70813fa9 [FC-0036] feat: New "Add Tags" widget (#834)
* feat: Use react-select for tags selector

Replace existing component with react-select component, by passing in
our custom component.
This retained the existing search functionality.

* fix: Fix missing deps causing constant rerender

This bug appeared after removing the react-query call to the backend
when selecting/unselecting a tag in the dropdown. Since it no longer
gets the updated state from the backend, it doesnt mask the bug.

The bug is essentially the `ContentTagsCollapsibleHelper` rerendering
causing the states to reset overriding the selected (not commited) tags.
This is due to missing dependancies in the useCallback.

* feat: Add stagedContentTags state in react-select

This adds a state and callbacks in the toplevel component of the content
tags drawer to be able to add/remove staged content tags and have them
showup in the react-select as selected chips.

* feat: Split up applied & staged content tags trees

Now content tags have seperate tree states for applied ones and staged
ones. They are updated seperately and both are used when updating the
selectable box UI. This allows for more flexibility with actions that
can be performed on the staged content tags with impacting the applied
ones.

* feat: Change style of implicit checkbox to checks

This overrides the indeterminate input checkbox style to match the
checked checkbox style, using variables defined in paragon.

* feat: Add bottom buttons in tags dropdown selector

* refactor: Remove cloneDeep + simplify code

* feat: Update placeholder/button texts

* feat: Implement cancel button + add proptypes

* feat: Implement commit/cancel staged tags

This implements the commit functionality for staged tags, taking account
for implicit tags. This also handles the case for removing applied tags
by clicking on the "x" in the TagBubble.

* feat: Keep all staged tags only commit explicit

* feat: Change style of add/cancel/load more buttons

* feat: Add inline "Add" button to commit tags

In the react-select component, an inline "Add" button showsup when some
tags are staged, if they are clicked they are commited/applied.

* fix: Keep applied tag checked when only staged child unchecked

* feat: Style add tags widget + staged tags

Also clear search term whenever tags are staged/cancelled

* feat: Fixed some typing errors

* test: Update tests to fix existing broken cases

* test: Add new functionality tests

* chore: add types to ContentTagsCollapsible

* chore: add types for useContentTagsCollapsibleHelper

* fix: Small bug with useIntl

* chore: Fix more linter issues

* refactor: Separate stagedTags and stagedTagsTree state updates

This refactor removed the warning that was caused because the state of a
parent component (ContentTagsDrawer) was being updated in the middle of
a state update in (ContentTagsCollapsible). This seperated the two state
updates to avoid this issue.

* chore: Update package-lock.json

* fix: Reset applied tags in selectbox when fetching

Whenever we get new applied tags from the backend, we reset the applied
tags that are checked, and only check the explicit tags. This was
causing an issue of duplicate applied tags being added to the selectbox.

* chore: Update package.json

---------

Co-authored-by: Braden MacDonald <braden@opencraft.com>
2024-03-13 18:57:30 +05:30
Maria Grimaldi
7f5687f175 refactor: address PR reviews 2024-03-12 16:42:11 -03:00
Maria Grimaldi
c8434b87c0 fix: import grouptypes from correct file 2024-03-12 16:42:11 -03:00
Maria Grimaldi
9299f4cf93 fix: import grouptypes from correct file 2024-03-12 16:42:11 -03:00
Maria Grimaldi
59d2dcaacb refactor!: put open manage team behind a configuration flag 2024-03-12 16:42:11 -03:00
Cristhian Garcia
42f8c3d95f chore: fix typo 2024-03-12 16:42:11 -03:00
Cristhian Garcia
9ff77945e3 chore: update open managed description 2024-03-12 16:42:11 -03:00
Cristhian Garcia
584823b879 fix: reorder groups 2024-03-12 16:42:11 -03:00
Cristhian Garcia
6e83e90cf0 feat: add open managed group type 2024-03-12 16:42:11 -03:00
Jeremy Ristau
ad7ba2f302 chore: update CODEOWNERS with new team (#888) 2024-03-12 11:57:23 -04:00
Ihor Romaniuk
bec59e5bbe feat: [FC-0044] Unit page - display xblock components (#857) 2024-03-12 11:48:11 -04:00
Jesper Hodge
1fdddfb869 Fix replace broken selectable box component everywhere (#887)
The Configure Live modal in Pages & Resources page uses a selectable box to select the video conferencing tool. It seems broken as well (not selectable).

It looks like the bug with not working SelectableBox (see e.g. #886) affects pretty much any component that uses it.

Thus, this PR replaces every usage of the paragon component with our working copy from flcc.
2024-03-11 17:10:33 -04:00
Jesper Hodge
b5a287639d Fix SelectableBox problems (#886)
Due to a bug in the SelectableBox component, selecting values was not possible in different components throughout this MFE.

This fixes the Gallery and the Select Problem Types components by updating the FLCC version and replacing the SelectableBox copy with an import from FLCC.

For a full bug description see #880.
2024-03-11 17:09:58 -04:00
renovate[bot]
dad4bd5282 fix(deps): update dependency @edx/frontend-component-footer to v13.0.4 (#881)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-03-11 10:25:27 -04:00
Jesper Hodge
17b1360c07 Fix: SortAndFilter modal doesnt respond to click (#880)
Description
We are encountering a bug in our stage environment that is very hard to reproduce locally, but not impossible. This is the same bug dealt with in several previous PRs like for example #871 (here I'm working on another component that uses the same paragon component and therefore encounters the same bug).

Since I was able to reproduce it locally, it is definitely not just a bug affecting only 2U-specific things.

Expected behavior
open a course with files
select a different sorting order (for example oldest to newest)
you should be able to select the different option
you should be able to successfully apply it
Actual behavior on stage environment
you can't select different option
you can't apply different option
Previous steps
Previously, I reproduced this locally by just adding the latest version of SelectableBox as a copy into this repo and importing it from there. Then, under the mistaken assumption that there was a missing context provider, I added that and it got fixed locally. However it turned out to not work on stage.

Measures taken in this PR
I replaced the entire SelectableBox component with all subcomponents with a version from @edx/paragon@21.5.6, which was apparently the version in the commit that didn't have the error yet. In theory, this would just fix the problem, though I have my doubts. But it's worth a try. I only imported it in one place, in this SortAndFilter modal.
I added console logging for onChange events which seem to the problem, as they are not triggering a change in the value of the context on stage. I see little choice than to log them to get more info. This will only affect the component that's not working.
2024-03-08 17:44:03 -05:00
Chris Chávez
c39b52a6bf [UU-58] Implement tagging & taxonomy feature in outline (#855)
* feat: TagCount component

* feat: Update ContentTagsDrawer to use it in the MFE

* feat: Manage tags menu added on units

* feat: Tag count added on unit

* feat: Add button feat to Tag count

* test: Course Outline api tests

* test: Ignore lines that can not be tested

* style: Comment added on ContentTagsDrawer

* style: Nits on CardHeader
2024-03-08 10:00:51 -05:00
PKulkoRaccoonGang
642b4e4052 refactor: refactoring after review 2024-03-08 08:17:54 -03:00
PKulkoRaccoonGang
423a3f3f72 fix: [AXIMST-470] fixed sidebar status after deleting or duplicating xblock 2024-03-08 08:17:54 -03:00
PKulkoRaccoonGang
f1f036576e refactor: after rebase 2024-03-08 08:17:54 -03:00
PKulkoRaccoonGang
deb76a0609 fix: [AXIMST-473] fixed sidebar publish status 2024-03-08 08:17:54 -03:00
PKulkoRaccoonGang
912c42e802 fix: [AXIMST-472] fixed sidebar visibility notification 2024-03-08 08:17:54 -03:00
PKulkoRaccoonGang
bdd641225f fix: [AXIMST-427] fixed unpublished changes alert 2024-03-08 08:17:54 -03:00
Peter Kulko
9021fccdb7 feat: [AXIMST-25] Course unit - Alert notification about unpublished changes (#135)
* feat: [AXIMST-25] added alert notification about unpublished changes

* feat: added tests

* feat: added translations
2024-03-08 08:17:54 -03:00
Peter Kulko
e4d88fb1fa feat: [AXIMST-24] Course unit - Sidebar buttons functional (#134)
* feat: [AXIMST-24] sidebar buttons functional

* refactor: removed modal extra className

* refactor: refactoring after review
2024-03-08 08:17:54 -03:00
Peter Kulko
073e191273 feat: [AXIMST-23] Course unit - Sidebar with unit info (#117)
* feat: added Sidebar with unit info

* feat: added unit location

* refactor: added legacy behavior

* feat: added live variant

* refactor: code refactoring

* feat: added tests and translations

* feat: added new font size

* refactor: after review
2024-03-08 08:17:54 -03:00
Ihor Romaniuk
f8095e6670 feat: [FC-0044] Unit page - display xblock components 2024-03-08 08:17:54 -03:00
Kristin Aoki
e4c3997d17 fix: revert addition of course-ingestion-tool submodule (#878) 2024-03-07 16:42:46 -05:00
Kristin Aoki
da7fe95f24 fix: accessibility page config reference (#875) 2024-03-07 16:11:04 -05:00
Jesper Hodge
896969c7de Fix radio context provider missing (#874)
This is a temporary fix for a bug that stems from a paragon component, SelectableBox. So we're using our own copy from fronten-lib-content-components.
2024-03-07 15:55:30 -05:00
Kristin Aoki
8100281fb4 feat: add checklist page (#870)
* feat: add checklist page

* fix: failing tests

* fix: styling bugs

* fix: lint errors

* feat: add test fro CourseChecklist

* fix: lint errors

* feat: add ChecklistSection tests

* fix: lint error

* fix: missing api reply status
2024-03-07 15:20:33 -05:00
Jesper Hodge
f035391c2f fix: replace paragon radio select set with copy to debug (#871)
step 1 for trying fixes for the stage bug where the paragon radio select is not clickable. Here I just replace the paragon component with our identical copy to see what that changes. Followup steps are to change this component until hopefully the problem gets fixed.
2024-03-06 16:26:17 -05:00
Eugene Dyudyunov
3607e6423d fix: correct internal routing
The Content dropdown items have incorrect URLs for the
internal routing when MFEs are deployed using the common
domain and the PUBLIC_PATH.
2024-03-06 10:00:35 -03:00
Jesper Hodge
4395607074 chore: update frontend-lib-content-components to 2.1.0 to fix problem select (#867) 2024-03-05 13:03:07 -05:00
Kristin Aoki
f717cdac86 feat: add accessibility page (#861)
* feat: add accessibility page

* fix: lint errors

* feat: increase code coverage

* fix: lint errors
2024-03-05 12:15:58 -05:00
Jeremy Ristau
40c9d6ee0d chore: update tnl team name (#862) 2024-03-05 09:19:39 -05:00
Braden MacDonald
3c661e15cb Convert "Pages & Resources" page to a plugin system (#638)
* feat: Make "Pages & Resources" course apps into plugins

* feat: move ora_settings

* feat: move proctoring

* feat: move progress

* feat: move teams

* feat: move wiki

* feat: move Xpert settings

* fix: add webpack.prod.config.js

* fix: clean up unused parts of package.json files

* feat: Add an error message when displaying a Course App Plugin fails

* chore: fix various eslint warnings

* chore: fix jest tests

* fix: error preventing "npm ci" from working

* feat: better tests for <SettingsComponent>

* chore: move xpert_unit_summary into same dir as other plugins

* fix: eslint-import-resolver-webpack is a dev dependency

* chore: move learning_assistant to be a plugin too

* feat: for compatibility, install 2U plugins by default

* fix: bug with learning_assistant package.json
2024-02-28 11:50:54 -05:00
PKulkoRaccoonGang
49fce4622c refactor: tests refactoring 2024-02-27 14:50:06 -03:00
Chris Chávez
608b2f79f8 [FC-0036] Refined taxonomy details page (#833)
* UX refinements on tag list table
* Add page size to tag list table
* fix Datatable pagination
2024-02-27 21:02:06 +05:30
PKulkoRaccoonGang
6b57ce3e53 refactor: refactoring after review 2024-02-27 11:44:42 -03:00
Peter Kulko
6aff1c1168 feat: [AXIMST-19, 20, 22] Course unit - Modal windows for course unit page components (#118)
* feat: added modal windows for course unit page components

* refactor: code refactoring

* refactor: added translations

* refactor: refactoring after review

* refactor: after review
2024-02-27 11:44:42 -03:00
Ihor Romaniuk
2b11df9eb5 fix: [AXIMST-371] fix correct internal route on create new unit (#114) 2024-02-27 11:44:42 -03:00
Peter Kulko
7fcc501d2e feat: Unit creation button logic and refactoring 2024-02-27 11:44:42 -03:00
Jeremy Ristau
90fb3d8edc chore: add missing maintainership files (#840)
* chore: add catalog-info file for Open edX Backstage

* chore: Create CODEOWNERS
2024-02-27 06:41:21 -05:00
Rômulo Penido
0fc0ce0829 feat: add export course tags menu (#830)
This change adds an item in the Tools menu to export the course tags to a CSV file.
2024-02-21 12:50:40 +05:30
Braden MacDonald
16d2f38325 Display full descendant count on taxonomy tag list page [FC-0036] (#826) 2024-02-20 15:40:30 +05:30
Brian Smith
76bb8e88c1 chore(deps): update paragon and frontend-build to openedx scope 2024-02-16 13:40:03 -03:00
504 changed files with 19223 additions and 29723 deletions

2
.env
View File

@@ -32,6 +32,7 @@ ENABLE_PROGRESS_GRAPH_SETTINGS=false
ENABLE_TEAM_TYPE_SETTING=false
ENABLE_NEW_EDITOR_PAGES=true
ENABLE_UNIT_PAGE=false
ENABLE_ASSETS_PAGE=false
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=false
ENABLE_TAGGING_TAXONOMY_PAGES=false
BBB_LEARN_MORE_URL=''
@@ -40,3 +41,4 @@ HOTJAR_VERSION=6
HOTJAR_DEBUG=false
INVITE_STUDENTS_EMAIL_TO=''
AI_TRANSLATIONS_BASE_URL=''
ENABLE_CHECKLIST_QUALITY=''

View File

@@ -34,6 +34,7 @@ ENABLE_PROGRESS_GRAPH_SETTINGS=false
ENABLE_TEAM_TYPE_SETTING=false
ENABLE_NEW_EDITOR_PAGES=true
ENABLE_UNIT_PAGE=false
ENABLE_ASSETS_PAGE=false
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=false
ENABLE_TAGGING_TAXONOMY_PAGES=true
BBB_LEARN_MORE_URL=''
@@ -42,3 +43,4 @@ 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

@@ -30,7 +30,9 @@ ENABLE_PROGRESS_GRAPH_SETTINGS=false
ENABLE_TEAM_TYPE_SETTING=false
ENABLE_NEW_EDITOR_PAGES=true
ENABLE_UNIT_PAGE=true
ENABLE_ASSETS_PAGE=false
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=true
ENABLE_TAGGING_TAXONOMY_PAGES=true
BBB_LEARN_MORE_URL=''
INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
ENABLE_CHECKLIST_QUALITY=true

View File

@@ -1,5 +1,6 @@
const path = require('path');
// eslint-disable-next-line import/no-extraneous-dependencies
const { createConfig } = require('@edx/frontend-build');
const { createConfig } = require('@openedx/frontend-build');
module.exports = createConfig(
'eslint',
@@ -13,5 +14,21 @@ module.exports = createConfig(
indent: ['error', 2],
'no-restricted-exports': 'off',
},
settings: {
// Import URLs should be resolved using aliases
'import/resolver': {
webpack: {
config: path.resolve(__dirname, 'webpack.dev.config.js'),
},
},
},
overrides: [
{
files: ['plugins/**/*.test.jsx'],
rules: {
'import/no-extraneous-dependencies': 'off',
},
},
],
},
);

27
.github/pull_request_template.md vendored Normal file
View File

@@ -0,0 +1,27 @@
## Description
Describe what this pull request changes, and why. Include implications for people using this change.
Design decisions and their rationales should be documented in the repo (docstring / ADR), per
Useful information to include:
- Which edX user roles will this change impact? Common user roles are "Learner", "Course Author",
"Developer", and "Operator".
- Include screenshots for changes to the UI (ideally, both "before" and "after" screenshots, if applicable).
- Provide links to the description of corresponding configuration changes. Remember to correctly annotate these
changes.
## Supporting information
Link to other information about the change, such as Jira issues, GitHub issues, or Discourse discussions.
Be sure to check they are publicly readable, or if not, repeat the information here.
## Testing instructions
Please provide detailed step-by-step instructions for testing this change.
## Other information
Include anything else that will help reviewers and consumers understand the change.
- Does this change depend on other changes elsewhere?
- Any special concerns or limitations? For example: deprecations, migrations, security, or accessibility.

3
.gitignore vendored
View File

@@ -23,3 +23,6 @@ temp/babel-plugin-react-intl
# Local environment overrides
.env.private
# Messages .json files fetched by atlas
src/i18n/messages/

View File

@@ -1,9 +0,0 @@
[main]
host = https://www.transifex.com
[o:open-edx:p:edx-platform:r:frontend-app-course-authoring]
file_filter = src/i18n/messages/<lang>.json
source_file = src/i18n/transifex_input.json
source_lang = en
type = KEYVALUEJSON

2
CODEOWNERS Normal file
View File

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

View File

@@ -1,7 +1,3 @@
transifex_resource = frontend-app-course-authoring
export TRANSIFEX_RESOURCE = ${transifex_resource}
transifex_langs = "ar,de,de_DE,es_419,fa_IR,fr,fr_CA,hi,it,it_IT,pt,pt_PT,ru,uk,zh_CN"
intl_imports = ./node_modules/.bin/intl-imports.js
transifex_utils = ./node_modules/.bin/transifex-utils.js
i18n = ./src/i18n
@@ -33,23 +29,6 @@ detect_changed_source_translations:
# Checking for changed translations...
git diff --exit-code $(i18n)
# Pushes translations to Transifex. You must run make extract_translations first.
push_translations:
# Pushing strings to Transifex...
tx push -s
# Fetching hashes from Transifex...
./node_modules/@edx/reactifex/bash_scripts/get_hashed_strings_v3.sh
# Writing out comments to file...
$(transifex_utils) $(transifex_temp) --comments --v3-scripts-path
# Pushing comments to Transifex...
./node_modules/@edx/reactifex/bash_scripts/put_comments_v3.sh
ifeq ($(OPENEDX_ATLAS_PULL),)
# Pulls translations from Transifex.
pull_translations:
tx pull -t -f --mode reviewed --languages=$(transifex_langs)
else
# Pulls translations using atlas.
pull_translations:
rm -rf src/i18n/messages
mkdir src/i18n/messages
@@ -63,7 +42,6 @@ pull_translations:
translations/frontend-app-course-authoring/src/i18n/messages:frontend-app-course-authoring
$(intl_imports) frontend-component-ai-translations frontend-lib-content-components frontend-platform paragon frontend-component-footer frontend-app-course-authoring
endif
# This target is used by Travis.
validate-no-uncommitted-package-lock-changes:

18
catalog-info.yaml Normal file
View File

@@ -0,0 +1,18 @@
# This file records information about this repo. Its use is described in OEP-55:
# https://open-edx-proposals.readthedocs.io/en/latest/processes/oep-0055-proc-project-maintainers.html
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: 'frontend-app-course-authoring'
description: "The frontend (MFE) for Open edX Course Authoring (aka Studio)"
links:
- url: "https://github.com/openedx/frontend-app-course-authoring"
title: "Frontend app course authoring"
icon: "Web"
annotations:
openedx.org/arch-interest-groups: ""
spec:
owner: group:2u-tnl
type: 'website'
lifecycle: 'production'

View File

@@ -1,4 +1,4 @@
const { createConfig } = require('@edx/frontend-build');
const { createConfig } = require('@openedx/frontend-build');
module.exports = createConfig('jest', {
setupFilesAfterEnv: [
@@ -11,6 +11,7 @@ module.exports = createConfig('jest', {
],
moduleNameMapper: {
'^lodash-es$': 'lodash',
'^CourseAuthoring/(.*)$': '<rootDir>/src/$1',
},
modulePathIgnorePatterns: [
'/src/pages-and-resources/utils.test.jsx',

18189
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -36,21 +36,35 @@
"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": "^1.4.0",
"@edx/frontend-component-footer": "^12.3.0",
"@edx/frontend-component-header": "^4.7.0",
"@edx/frontend-component-ai-translations": "^2.0.0",
"@edx/frontend-component-footer": "^13.0.2",
"@edx/frontend-component-header": "^5.0.2",
"@edx/frontend-enterprise-hotjar": "^2.0.0",
"@edx/frontend-lib-content-components": "^1.178.2",
"@edx/frontend-platform": "5.6.1",
"@edx/frontend-lib-content-components": "^2.1.4",
"@edx/frontend-platform": "7.0.1",
"@edx/openedx-atlas": "^0.6.0",
"@edx/paragon": "^21.5.6",
"@fortawesome/fontawesome-svg-core": "1.2.36",
"@fortawesome/free-brands-svg-icons": "5.15.4",
"@fortawesome/free-regular-svg-icons": "5.15.4",
"@fortawesome/free-solid-svg-icons": "5.15.4",
"@fortawesome/react-fontawesome": "0.2.0",
"@openedx-plugins/course-app-calculator": "file:plugins/course-apps/calculator",
"@openedx-plugins/course-app-edxnotes": "file:plugins/course-apps/edxnotes",
"@openedx-plugins/course-app-learning_assistant": "file:plugins/course-apps/learning_assistant",
"@openedx-plugins/course-app-live": "file:plugins/course-apps/live",
"@openedx-plugins/course-app-ora_settings": "file:plugins/course-apps/ora_settings",
"@openedx-plugins/course-app-proctoring": "file:plugins/course-apps/proctoring",
"@openedx-plugins/course-app-progress": "file:plugins/course-apps/progress",
"@openedx-plugins/course-app-teams": "file:plugins/course-apps/teams",
"@openedx-plugins/course-app-wiki": "file:plugins/course-apps/wiki",
"@openedx-plugins/course-app-xpert_unit_summary": "file:plugins/course-apps/xpert_unit_summary",
"@openedx/paragon": "^21.5.7",
"@reduxjs/toolkit": "1.9.7",
"@tanstack/react-query": "4.36.1",
"broadcast-channel": "^7.0.0",
@@ -71,6 +85,7 @@
"react-responsive": "9.0.2",
"react-router": "6.16.0",
"react-router-dom": "6.16.0",
"react-select": "5.8.0",
"react-textarea-autosize": "^8.4.1",
"react-transition-group": "4.4.5",
"redux": "4.0.5",
@@ -81,16 +96,18 @@
},
"devDependencies": {
"@edx/browserslist-config": "1.2.0",
"@edx/frontend-build": "13.0.5",
"@edx/react-unit-test-utils": "^1.7.0",
"@edx/react-unit-test-utils": "^2.0.0",
"@edx/reactifex": "^1.0.3",
"@edx/stylelint-config-edx": "^2.3.0",
"@edx/stylelint-config-edx": "2.3.0",
"@edx/typescript-config": "^1.0.1",
"@openedx/frontend-build": "13.0.27",
"@testing-library/jest-dom": "5.17.0",
"@testing-library/react": "12.1.5",
"@testing-library/react-hooks": "^8.0.1",
"@testing-library/user-event": "^13.2.1",
"axios": "^0.27.2",
"axios-mock-adapter": "1.22.0",
"eslint-import-resolver-webpack": "^0.13.8",
"glob": "7.2.3",
"husky": "7.0.4",
"jest-canvas-mock": "^2.5.2",

View File

@@ -0,0 +1,31 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
import messages from './messages';
/**
* Settings widget for the "calculator" Course App.
* @param {{onClose: () => void}} props
*/
const CalculatorSettings = ({ onClose }) => {
const intl = useIntl();
return (
<AppSettingsModal
appId="calculator"
title={intl.formatMessage(messages.heading)}
enableAppHelp={intl.formatMessage(messages.enableCalculatorHelp)}
enableAppLabel={intl.formatMessage(messages.enableCalculatorLabel)}
learnMoreText={intl.formatMessage(messages.enableCalculatorLink)}
onClose={onClose}
/>
);
};
CalculatorSettings.propTypes = {
onClose: PropTypes.func.isRequired,
};
export default CalculatorSettings;

View File

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

View File

@@ -0,0 +1,31 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
import messages from './messages';
/**
* Settings widget for the "edxnotes" Course App.
* @param {{onClose: () => void}} props
*/
const NotesSettings = ({ onClose }) => {
const intl = useIntl();
return (
<AppSettingsModal
appId="edxnotes"
title={intl.formatMessage(messages.heading)}
enableAppHelp={intl.formatMessage(messages.enableNotesHelp)}
enableAppLabel={intl.formatMessage(messages.enableNotesLabel)}
learnMoreText={intl.formatMessage(messages.enableNotesLink)}
onClose={onClose}
/>
);
};
NotesSettings.propTypes = {
onClose: PropTypes.func.isRequired,
};
export default NotesSettings;

View File

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

View File

@@ -1,16 +1,18 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Hyperlink } from '@edx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Hyperlink } from '@openedx/paragon';
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
import { useModel } from 'CourseAuthoring/generic/model-store';
import AppSettingsModal from '../app-settings-modal/AppSettingsModal';
import messages from './messages';
import { useModel } from '../../generic/model-store';
const LearningAssistantSettings = ({ intl, onClose }) => {
const LearningAssistantSettings = ({ onClose }) => {
const appId = 'learning_assistant';
const appInfo = useModel('courseApps', appId);
const intl = useIntl();
// We need to render more than one link, so we use the bodyChildren prop.
const bodyChildren = (
@@ -55,8 +57,7 @@ const LearningAssistantSettings = ({ intl, onClose }) => {
};
LearningAssistantSettings.propTypes = {
intl: intlShape.isRequired,
onClose: PropTypes.func.isRequired,
};
export default injectIntl(LearningAssistantSettings);
export default LearningAssistantSettings;

View File

@@ -1,9 +1,9 @@
import React from 'react';
import { screen, waitFor } from '@testing-library/react';
import { RequestStatus } from 'CourseAuthoring/data/constants';
import { render } from 'CourseAuthoring/pages-and-resources/utils.test';
import LearningAssistantSettings from './Settings';
import { render } from '../utils.test';
import { RequestStatus } from '../../data/constants';
const onClose = () => { };

View File

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

View File

@@ -1,13 +1,14 @@
import React, { useEffect, useState } from 'react';
import { getConfig } from '@edx/frontend-platform';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Form, Hyperlink } from '@edx/paragon';
import { Form, Hyperlink } from '@openedx/paragon';
import PropTypes from 'prop-types';
import messages from './messages';
import AppConfigFormDivider from 'CourseAuthoring/pages-and-resources/discussions/app-config-form/apps/shared/AppConfigFormDivider';
import { useModel } from 'CourseAuthoring/generic/model-store';
import { providerNames, bbbPlanTypes } from './constants';
import AppConfigFormDivider from '../discussions/app-config-form/apps/shared/AppConfigFormDivider';
import LiveCommonFields from './LiveCommonFields';
import { useModel } from '../../generic/model-store';
import messages from './messages';
const BbbSettings = ({
intl,

View File

@@ -15,8 +15,10 @@ import { AppProvider, PageWrap } from '@edx/frontend-platform/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import userEvent from '@testing-library/user-event';
import initializeStore from '../../store';
import { executeThunk } from '../../utils';
import initializeStore from 'CourseAuthoring/store';
import { executeThunk } from 'CourseAuthoring/utils';
import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
import LiveSettings from './Settings';
import {
generateLiveConfigurationApiResponse,
@@ -24,11 +26,9 @@ import {
initialState,
configurationProviders,
} from './factories/mockApiResponses';
import { fetchLiveConfiguration, fetchLiveProviders } from './data/thunks';
import { providerConfigurationApiUrl, providersApiUrl } from './data/api';
import messages from './messages';
import PagesAndResourcesProvider from '../PagesAndResourcesProvider';
let axiosMock;
let container;

View File

@@ -1,8 +1,9 @@
import React from 'react';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import PropTypes from 'prop-types';
import FormikControl from 'CourseAuthoring/generic/FormikControl';
import messages from './messages';
import FormikControl from '../../generic/FormikControl';
const LiveCommonFields = ({
intl,

View File

@@ -1,18 +1,20 @@
import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { camelCase } from 'lodash';
import { SelectableBox, Icon } from '@edx/paragon';
import { Icon } from '@openedx/paragon';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { SelectableBox } from '@edx/frontend-lib-content-components';
import PropTypes from 'prop-types';
import * as Yup from 'yup';
import { useNavigate } from 'react-router-dom';
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
import { useModel } from 'CourseAuthoring/generic/model-store';
import Loading from 'CourseAuthoring/generic/Loading';
import { RequestStatus } from 'CourseAuthoring/data/constants';
import { fetchLiveData, saveLiveConfiguration, saveLiveConfigurationAsDraft } from './data/thunks';
import { selectApp } from './data/slice';
import AppSettingsModal from '../app-settings-modal/AppSettingsModal';
import { useModel } from '../../generic/model-store';
import Loading from '../../generic/Loading';
import { iconsSrc, bbbPlanTypes } from './constants';
import { RequestStatus } from '../../data/constants';
import messages from './messages';
import ZoomSettings from './ZoomSettings';
import BBBSettings from './BBBSettings';

View File

@@ -18,8 +18,10 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider, PageWrap } from '@edx/frontend-platform/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import initializeStore from '../../store';
import { executeThunk } from '../../utils';
import initializeStore from 'CourseAuthoring/store';
import { executeThunk } from 'CourseAuthoring/utils';
import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
import LiveSettings from './Settings';
import {
generateLiveConfigurationApiResponse,
@@ -31,7 +33,6 @@ import {
import { fetchLiveConfiguration, fetchLiveProviders } from './data/thunks';
import { providerConfigurationApiUrl, providersApiUrl } from './data/api';
import messages from './messages';
import PagesAndResourcesProvider from '../PagesAndResourcesProvider';
let axiosMock;
let container;

View File

@@ -1,10 +1,11 @@
import React from 'react';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import PropTypes from 'prop-types';
import FormikControl from 'CourseAuthoring/generic/FormikControl';
import messages from './messages';
import { providerNames } from './constants';
import LiveCommonFields from './LiveCommonFields';
import FormikControl from '../../generic/FormikControl';
const ZoomSettings = ({
intl,

View File

@@ -13,8 +13,9 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider, PageWrap } from '@edx/frontend-platform/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import initializeStore from '../../store';
import { executeThunk } from '../../utils';
import initializeStore from 'CourseAuthoring/store';
import { executeThunk } from 'CourseAuthoring/utils';
import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
import LiveSettings from './Settings';
import {
generateLiveConfigurationApiResponse,
@@ -26,7 +27,6 @@ import {
import { fetchLiveConfiguration, fetchLiveProviders } from './data/thunks';
import { providerConfigurationApiUrl, providersApiUrl } from './data/api';
import messages from './messages';
import PagesAndResourcesProvider from '../PagesAndResourcesProvider';
let axiosMock;
let container;

View File

@@ -1,6 +1,6 @@
import {
GoogleMeet, MicrosoftTeams, Zoom, Bbb,
} from '@edx/paragon/icons';
} from '@openedx/paragon/icons';
export const iconsSrc = {
googleMeet: GoogleMeet,

View File

@@ -1,6 +1,6 @@
/* eslint-disable no-param-reassign */
import { createSlice } from '@reduxjs/toolkit';
import { RequestStatus } from '../../../data/constants';
import { RequestStatus } from 'CourseAuthoring/data/constants';
const slice = createSlice({
name: 'live',

View File

@@ -1,4 +1,6 @@
import { addModel, addModels, updateModel } from '../../../generic/model-store';
import { addModel, addModels, updateModel } from 'CourseAuthoring/generic/model-store';
import { RequestStatus } from 'CourseAuthoring/data/constants';
import {
getLiveConfiguration,
getLiveProviders,
@@ -7,7 +9,6 @@ import {
deNormalizeSettings,
} from './api';
import { loadApps, updateStatus, updateSaveStatus } from './slice';
import { RequestStatus } from '../../../data/constants';
function updateLiveSettingsState({
appConfig,

View File

@@ -0,0 +1,23 @@
{
"name": "@openedx-plugins/course-app-live",
"version": "0.1.0",
"description": "Live course configuration for courses using it",
"peerDependencies": {
"@edx/frontend-app-course-authoring": "*",
"@edx/frontend-lib-content-components": "*",
"@edx/frontend-platform": "*",
"@openedx/paragon": "*",
"@reduxjs/toolkit": "*",
"lodash": "*",
"prop-types": "*",
"react": "*",
"react-redux": "*",
"react-router-dom": "*",
"yup": "*"
},
"peerDependenciesMeta": {
"@edx/frontend-app-course-authoring": {
"optional": true
}
}
}

View File

@@ -4,12 +4,12 @@ import * as Yup from 'yup';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Hyperlink } from '@edx/paragon';
import { useModel } from '../../generic/model-store';
import { Hyperlink } from '@openedx/paragon';
import { useModel } from 'CourseAuthoring/generic/model-store';
import FormSwitchGroup from '../../generic/FormSwitchGroup';
import { useAppSetting } from '../../utils';
import AppSettingsModal from '../app-settings-modal/AppSettingsModal';
import FormSwitchGroup from 'CourseAuthoring/generic/FormSwitchGroup';
import { useAppSetting } from 'CourseAuthoring/utils';
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
import messages from './messages';
const ORASettings = ({ intl, onClose }) => {

View File

@@ -9,14 +9,14 @@ jest.mock('@edx/frontend-platform/i18n', () => ({
jest.mock('yup', () => ({
boolean: jest.fn().mockReturnValue('Yub.boolean'),
}));
jest.mock('../../generic/model-store', () => ({
jest.mock('CourseAuthoring/generic/model-store', () => ({
useModel: jest.fn().mockReturnValue({ documentationLinks: { learnMoreConfiguration: 'https://learnmore.test' } }),
}));
jest.mock('../../generic/FormSwitchGroup', () => 'FormSwitchGroup');
jest.mock('../../utils', () => ({
jest.mock('CourseAuthoring/generic/FormSwitchGroup', () => 'FormSwitchGroup');
jest.mock('CourseAuthoring/utils', () => ({
useAppSetting: jest.fn().mockReturnValue(['abitrary value', jest.fn().mockName('saveSetting')]),
}));
jest.mock('../app-settings-modal/AppSettingsModal', () => 'AppSettingsModal');
jest.mock('CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal', () => 'AppSettingsModal');
const props = {
onClose: jest.fn().mockName('onClose'),

View File

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

View File

@@ -11,17 +11,18 @@ import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
import {
ActionRow, Alert, Badge, Form, Hyperlink, ModalDialog, StatefulButton,
} from '@edx/paragon';
} from '@openedx/paragon';
import ExamsApiService from 'CourseAuthoring/data/services/ExamsApiService';
import StudioApiService from 'CourseAuthoring/data/services/StudioApiService';
import Loading from 'CourseAuthoring/generic/Loading';
import ConnectionErrorAlert from 'CourseAuthoring/generic/ConnectionErrorAlert';
import FormSwitchGroup from 'CourseAuthoring/generic/FormSwitchGroup';
import { useModel } from 'CourseAuthoring/generic/model-store';
import PermissionDeniedAlert from 'CourseAuthoring/generic/PermissionDeniedAlert';
import { useIsMobile } from 'CourseAuthoring/utils';
import { PagesAndResourcesContext } from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
import ExamsApiService from '../../data/services/ExamsApiService';
import StudioApiService from '../../data/services/StudioApiService';
import Loading from '../../generic/Loading';
import ConnectionErrorAlert from '../../generic/ConnectionErrorAlert';
import FormSwitchGroup from '../../generic/FormSwitchGroup';
import { useModel } from '../../generic/model-store';
import PermissionDeniedAlert from '../../generic/PermissionDeniedAlert';
import { useIsMobile } from '../../utils';
import { PagesAndResourcesContext } from '../PagesAndResourcesProvider';
import messages from './messages';
const ProctoringSettings = ({ intl, onClose }) => {

View File

@@ -9,10 +9,10 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
import { AppProvider } from '@edx/frontend-platform/react';
import StudioApiService from '../../data/services/StudioApiService';
import ExamsApiService from '../../data/services/ExamsApiService';
import initializeStore from '../../store';
import PagesAndResourcesProvider from '../PagesAndResourcesProvider';
import StudioApiService from 'CourseAuthoring/data/services/StudioApiService';
import ExamsApiService from 'CourseAuthoring/data/services/ExamsApiService';
import initializeStore from 'CourseAuthoring/store';
import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
import ProctoredExamSettings from './Settings';
const defaultProps = {

View File

@@ -0,0 +1,20 @@
{
"name": "@openedx-plugins/course-app-proctoring",
"version": "0.1.0",
"description": "Proctoring configuration for courses using it",
"peerDependencies": {
"@edx/frontend-app-course-authoring": "*",
"@edx/frontend-platform": "*",
"@openedx/paragon": "*",
"classnames": "*",
"email-validator": "*",
"react": "*",
"prop-types": "*",
"moment": "*"
},
"peerDependenciesMeta": {
"@edx/frontend-app-course-authoring": {
"optional": true
}
}
}

View File

@@ -3,9 +3,9 @@ import PropTypes from 'prop-types';
import React from 'react';
import * as Yup from 'yup';
import { getConfig } from '@edx/frontend-platform';
import FormSwitchGroup from '../../generic/FormSwitchGroup';
import { useAppSetting } from '../../utils';
import AppSettingsModal from '../app-settings-modal/AppSettingsModal';
import FormSwitchGroup from 'CourseAuthoring/generic/FormSwitchGroup';
import { useAppSetting } from 'CourseAuthoring/utils';
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
import messages from './messages';
const ProgressSettings = ({ intl, onClose }) => {

View File

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

View File

@@ -1,12 +1,13 @@
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button, Form, TransitionReplace } from '@edx/paragon';
import { Button, Form, TransitionReplace } from '@openedx/paragon';
import PropTypes from 'prop-types';
import React, { useState } from 'react';
import { GroupTypes, TeamSizes } from '../../data/constants';
import { GroupTypes, TeamSizes } from 'CourseAuthoring/data/constants';
import CollapsableEditor from '../../generic/CollapsableEditor';
import FormikControl from '../../generic/FormikControl';
import CollapsableEditor from 'CourseAuthoring/generic/CollapsableEditor';
import FormikControl from 'CourseAuthoring/generic/FormikControl';
import messages from './messages';
import { isGroupTypeEnabled } from './utils';
// Maps a team type to its corresponding intl message
const TeamTypeNameMessage = {
@@ -14,6 +15,10 @@ const TeamTypeNameMessage = {
label: messages.groupTypeOpen,
description: messages.groupTypeOpenDescription,
},
[GroupTypes.OPEN_MANAGED]: {
label: messages.groupTypeOpenManaged,
description: messages.groupTypeOpenManagedDescription,
},
[GroupTypes.PUBLIC_MANAGED]: {
label: messages.groupTypePublicManaged,
description: messages.groupTypePublicManagedDescription,
@@ -105,7 +110,7 @@ const GroupEditor = ({
onChange={onChange}
onBlur={onBlur}
>
{Object.values(GroupTypes).map(groupType => (
{Object.values(GroupTypes).map(groupType => isGroupTypeEnabled(groupType) && (
<Form.Radio
key={groupType}
value={groupType}

View File

@@ -0,0 +1,102 @@
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { useFormikContext } from 'formik';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import GroupEditor from './GroupEditor';
import messages from './messages';
jest.mock('formik', () => ({
...jest.requireActual('formik'),
useFormikContext: jest.fn(),
}));
describe('GroupEditor', () => {
const mockIntl = { formatMessage: jest.fn() };
const mockGroup = {
id: '1',
name: 'Test Group',
description: 'Test Group Description',
type: 'open',
maxTeamSize: 5,
};
const mockProps = {
intl: mockIntl,
fieldNameCommonBase: 'test',
group: mockGroup,
onDelete: jest.fn(),
onChange: jest.fn(),
onBlur: jest.fn(),
errors: {},
};
const renderComponent = (overrideProps = {}) => render(
<IntlProvider locale="en" messages={{}}>
<GroupEditor {...mockProps} {...overrideProps} />
</IntlProvider>,
);
beforeEach(() => {
useFormikContext.mockReturnValue({
touched: {},
errors: {},
handleChange: jest.fn(),
handleBlur: jest.fn(),
setFieldError: jest.fn(),
});
jest.clearAllMocks();
});
test('renders without errors', () => {
renderComponent();
});
test('renders the group name and description', () => {
const { getByText } = renderComponent();
expect(getByText('Test Group')).toBeInTheDocument();
expect(getByText('Test Group Description')).toBeInTheDocument();
});
describe('group types messages', () => {
test('group type open message', () => {
const { getByLabelText, getByText } = renderComponent();
const expandButton = getByLabelText('Expand group editor');
expect(expandButton).toBeInTheDocument();
fireEvent.click(expandButton);
expect(getByText(messages.groupTypeOpenDescription.defaultMessage)).toBeInTheDocument();
});
test('group type public_managed message', () => {
const publicManagedGroupMock = {
id: '2',
name: 'Test Group',
description: 'Test Group Description',
type: 'public_managed',
maxTeamSize: 5,
};
const { getByLabelText, getByText } = renderComponent({ group: publicManagedGroupMock });
const expandButton = getByLabelText('Expand group editor');
expect(expandButton).toBeInTheDocument();
fireEvent.click(expandButton);
expect(getByText(messages.groupTypePublicManagedDescription.defaultMessage)).toBeInTheDocument();
});
test('group type private_managed message', () => {
const privateManagedGroupMock = {
id: '3',
name: 'Test Group',
description: 'Test Group Description',
type: 'private_managed',
maxTeamSize: 5,
};
const { getByLabelText, getByText } = renderComponent({ group: privateManagedGroupMock });
const expandButton = getByLabelText('Expand group editor');
expect(expandButton).toBeInTheDocument();
fireEvent.click(expandButton);
expect(getByText(messages.groupTypePrivateManagedDescription.defaultMessage)).toBeInTheDocument();
});
});
});

View File

@@ -1,16 +1,16 @@
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button, Form } from '@edx/paragon';
import { Add } from '@edx/paragon/icons';
import { Button, Form } from '@openedx/paragon';
import { Add } from '@openedx/paragon/icons';
import { FieldArray } from 'formik';
import PropTypes from 'prop-types';
import React from 'react';
import { v4 as uuid } from 'uuid';
import * as Yup from 'yup';
import { GroupTypes, TeamSizes } from '../../data/constants';
import FormikControl from '../../generic/FormikControl';
import { setupYupExtensions, useAppSetting } from '../../utils';
import AppSettingsModal from '../app-settings-modal/AppSettingsModal';
import { GroupTypes, TeamSizes } from 'CourseAuthoring/data/constants';
import FormikControl from 'CourseAuthoring/generic/FormikControl';
import { setupYupExtensions, useAppSetting } from 'CourseAuthoring/utils';
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
import GroupEditor from './GroupEditor';
import messages from './messages';

View File

@@ -93,6 +93,14 @@ const messages = defineMessages({
id: 'authoring.pagesAndResources.teams.group.types.open',
defaultMessage: 'Open',
},
groupTypeOpenManaged: {
id: 'authoring.pagesAndResources.teams.group.types.open_managed',
defaultMessage: 'Open managed',
},
groupTypeOpenManagedDescription: {
id: 'authoring.pagesAndResources.teams.group.types.open_managed.description',
defaultMessage: 'Only course staff can create teams. Learners can see, join and leave teams.',
},
groupTypeOpenDescription: {
id: 'authoring.pagesAndResources.teams.group.types.open.description',
defaultMessage: 'Learners can create, join, leave, and see other teams',

View File

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

View File

@@ -0,0 +1,23 @@
/* eslint-disable import/prefer-default-export */
import { getConfig } from '@edx/frontend-platform';
import { GroupTypes } from 'CourseAuthoring/data/constants';
/**
* Check if a group type is enabled by the current configuration.
* This is a temporary workaround to disable the OPEN MANAGED team type until it is fully adopted.
* For more information, see: https://openedx.atlassian.net/wiki/spaces/COMM/pages/3885760525/Open+Managed+Group+Type
* @param {string} groupType - the group type to check
* @returns {boolean} - true if the group type is enabled
*/
export const isGroupTypeEnabled = (groupType) => {
const enabledTypesByDefault = [
GroupTypes.OPEN,
GroupTypes.PUBLIC_MANAGED,
GroupTypes.PRIVATE_MANAGED,
];
const enabledTypesByConfig = {
[GroupTypes.OPEN_MANAGED]: getConfig().ENABLE_OPEN_MANAGED_TEAM_TYPE,
};
return enabledTypesByDefault.includes(groupType) || enabledTypesByConfig[groupType] || false;
};

View File

@@ -0,0 +1,39 @@
import { getConfig } from '@edx/frontend-platform';
import { GroupTypes } from 'CourseAuthoring/data/constants';
import { isGroupTypeEnabled } from './utils';
jest.mock('@edx/frontend-platform', () => ({ getConfig: jest.fn() }));
describe('teams utils', () => {
describe('isGroupTypeEnabled', () => {
beforeEach(() => {
jest.clearAllMocks();
});
test('returns true if the group type is enabled', () => {
getConfig.mockReturnValue({ ENABLE_OPEN_MANAGED_TEAM_TYPE: false });
expect(isGroupTypeEnabled(GroupTypes.OPEN)).toBe(true);
expect(isGroupTypeEnabled(GroupTypes.PUBLIC_MANAGED)).toBe(true);
expect(isGroupTypeEnabled(GroupTypes.PRIVATE_MANAGED)).toBe(true);
});
test('returns false if the OPEN_MANAGED group is not enabled', () => {
getConfig.mockReturnValue({ ENABLE_OPEN_MANAGED_TEAM_TYPE: false });
expect(isGroupTypeEnabled(GroupTypes.OPEN_MANAGED)).toBe(false);
});
test('returns true if the OPEN_MANAGED group is enabled', () => {
getConfig.mockReturnValue({ ENABLE_OPEN_MANAGED_TEAM_TYPE: true });
expect(isGroupTypeEnabled(GroupTypes.OPEN_MANAGED)).toBe(true);
});
test('returns false if the group is invalid', () => {
getConfig.mockReturnValue({ ENABLE_OPEN_MANAGED_TEAM_TYPE: true });
expect(isGroupTypeEnabled('FOO')).toBe(false);
});
test('returns false if the group is null', () => {
getConfig.mockReturnValue({ ENABLE_OPEN_MANAGED_TEAM_TYPE: true });
expect(isGroupTypeEnabled(null)).toBe(false);
});
});
});

View File

@@ -3,9 +3,9 @@ import PropTypes from 'prop-types';
import React from 'react';
import * as Yup from 'yup';
import FormSwitchGroup from '../../generic/FormSwitchGroup';
import { useAppSetting } from '../../utils';
import AppSettingsModal from '../app-settings-modal/AppSettingsModal';
import FormSwitchGroup from 'CourseAuthoring/generic/FormSwitchGroup';
import { useAppSetting } from 'CourseAuthoring/utils';
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
import messages from './messages';
const WikiSettings = ({ intl, onClose }) => {

View File

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

View File

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

View File

@@ -2,8 +2,8 @@ import React, { useCallback, useContext, useEffect } from 'react';
import { useDispatch } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { PagesAndResourcesContext } from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
import { useNavigate } from 'react-router-dom';
import { PagesAndResourcesContext } from '../PagesAndResourcesProvider';
import SettingsModal from './settings-modal/SettingsModal';
import messages from './messages';

View File

@@ -10,12 +10,13 @@ import {
queryByTestId, render, waitFor, getByText, fireEvent,
} from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import PagesAndResourcesProvider from '../PagesAndResourcesProvider';
import { XpertUnitSummarySettings } from './index';
import initializeStore from '../../store';
import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
import initializeStore from 'CourseAuthoring/store';
import { executeThunk } from 'CourseAuthoring/utils';
import XpertUnitSummarySettings from './Settings';
import * as API from './data/api';
import * as Thunks from './data/thunks';
import { executeThunk } from '../../utils';
const courseId = 'course-v1:edX+TestX+Test_Course';
let axiosMock;

View File

@@ -1,12 +1,11 @@
import { updateSavingStatus, updateLoadingStatus, updateResetStatus } from 'CourseAuthoring/pages-and-resources/data/slice';
import { RequestStatus } from 'CourseAuthoring/data/constants';
import { addModel, updateModel } from 'CourseAuthoring/generic/model-store';
import {
getXpertSettings, postXpertSettings, getXpertPluginConfigurable, deleteXpertSettings,
} from './api';
import { updateSavingStatus, updateLoadingStatus, updateResetStatus } from '../../data/slice';
import { RequestStatus } from '../../../data/constants';
import { addModel, updateModel } from '../../../generic/model-store';
export function updateXpertSettings(courseId, state) {
return async (dispatch) => {
dispatch(updateSavingStatus({ status: RequestStatus.IN_PROGRESS }));

View File

@@ -0,0 +1,21 @@
{
"name": "@openedx-plugins/course-app-xpert_unit_summary",
"version": "0.1.0",
"description": "Xpert Unit Summaries configuration for courses using it",
"peerDependencies": {
"@edx/frontend-app-course-authoring": "*",
"@edx/frontend-platform": "*",
"@openedx/paragon": "*",
"formik": "*",
"prop-types": "*",
"yup": "*",
"react": "*",
"react-redux": "*",
"react-router-dom": "*"
},
"peerDependenciesMeta": {
"@edx/frontend-app-course-authoring": {
"optional": true
}
}
}

View File

@@ -11,10 +11,10 @@ import {
Tooltip,
TransitionReplace,
Hyperlink,
} from '@edx/paragon';
} from '@openedx/paragon';
import {
Info, CheckCircleOutline, SpinnerSimple,
} from '@edx/paragon/icons';
} from '@openedx/paragon/icons';
import { Formik } from 'formik';
import PropTypes from 'prop-types';
@@ -24,22 +24,25 @@ import React, {
import { useDispatch, useSelector } from 'react-redux';
import * as Yup from 'yup';
import { RequestStatus } from '../../../data/constants';
import ConnectionErrorAlert from '../../../generic/ConnectionErrorAlert';
import FormSwitchGroup from '../../../generic/FormSwitchGroup';
import Loading from '../../../generic/Loading';
import { useModel } from '../../../generic/model-store';
import PermissionDeniedAlert from '../../../generic/PermissionDeniedAlert';
import { useIsMobile } from '../../../utils';
import { getLoadingStatus, getSavingStatus, getResetStatus } from '../../data/selectors';
import { updateSavingStatus, updateResetStatus } from '../../data/slice';
import { RequestStatus } from 'CourseAuthoring/data/constants';
import ConnectionErrorAlert from 'CourseAuthoring/generic/ConnectionErrorAlert';
import FormSwitchGroup from 'CourseAuthoring/generic/FormSwitchGroup';
import Loading from 'CourseAuthoring/generic/Loading';
import { useModel } from 'CourseAuthoring/generic/model-store';
import PermissionDeniedAlert from 'CourseAuthoring/generic/PermissionDeniedAlert';
import { useIsMobile } from 'CourseAuthoring/utils';
import { getLoadingStatus, getSavingStatus, getResetStatus } from 'CourseAuthoring/pages-and-resources/data/selectors';
import { updateSavingStatus, updateResetStatus } from 'CourseAuthoring/pages-and-resources/data/slice';
import AppConfigFormDivider from 'CourseAuthoring/pages-and-resources/discussions/app-config-form/apps/shared/AppConfigFormDivider';
import { PagesAndResourcesContext } from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
import { updateXpertSettings, resetXpertSettings, removeXpertSettings } from '../data/thunks';
import AppConfigFormDivider from '../../discussions/app-config-form/apps/shared/AppConfigFormDivider';
import { PagesAndResourcesContext } from '../../PagesAndResourcesProvider';
import messages from './messages';
import appInfo from '../appInfo';
import ResetIcon from './ResetIcon';
import './SettingsModal.scss';
const AppSettingsForm = ({
formikProps, children, showForm,
}) => children && (

View File

@@ -1,3 +1,6 @@
@import "~@edx/brand/paragon/variables";
@import "~@openedx/paragon/scss/core/utilities-only";
.summary-radio {
display: flex;
align-items: center;

View File

@@ -20,6 +20,7 @@ import { CourseUnit } from './course-unit';
import CourseExportPage from './export-page/CourseExportPage';
import CourseImportPage from './import-page/CourseImportPage';
import { DECODED_ROUTES } from './constants';
import CourseChecklist from './course-checklist';
/**
* As of this writing, these routes are mounted at a path prefixed with the following:
@@ -73,6 +74,7 @@ const CourseAuthoringRoutes = () => {
/>
{DECODED_ROUTES.COURSE_UNIT.map((path) => (
<Route
key={path}
path={path}
element={<PageWrap><CourseUnit courseId={courseId} /></PageWrap>}
/>
@@ -109,6 +111,10 @@ const CourseAuthoringRoutes = () => {
path="export"
element={<PageWrap><CourseExportPage courseId={courseId} /></PageWrap>}
/>
<Route
path="checklists"
element={<PageWrap><CourseChecklist courseId={courseId} /></PageWrap>}
/>
</Routes>
</CourseAuthoringPage>
);

View File

@@ -0,0 +1,98 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
import { Hyperlink, MailtoLink, Stack } from '@openedx/paragon';
import messages from './messages';
const AccessibilityBody = ({
communityAccessibilityLink,
email,
}) => (
<div className="mt-5">
<header>
<h2 className="mb-4 pb-1">
<FormattedMessage {...messages.a11yBodyPageHeader} />
</h2>
</header>
<Stack gap={2.5}>
<div className="small">
<FormattedMessage
{...messages.a11yBodyIntroGraph}
values={{
communityAccessibilityLink: (
<Hyperlink
destination={communityAccessibilityLink}
data-testid="accessibility-page-link"
>
Website Accessibility Policy
</Hyperlink>
),
}}
/>
</div>
<div className="small">
<FormattedMessage {...messages.a11yBodyStepsHeader} />
</div>
<ol className="small m-0">
<li>
<FormattedMessage
{...messages.a11yBodyEmailHeading}
values={{
emailElement: (
<MailtoLink
to={email}
data-testid="email-element"
>
{email}
</MailtoLink>
),
}}
/>
<ul>
<li>
<FormattedMessage {...messages.a11yBodyNameEmail} />
</li>
<li>
<FormattedMessage {...messages.a11yBodyInstitution} />
</li>
<li>
<FormattedMessage {...messages.a11yBodyBarrier} />
</li>
<li>
<FormattedMessage {...messages.a11yBodyTimeConstraints} />
</li>
</ul>
</li>
<li>
<FormattedMessage {...messages.a11yBodyReceipt} />
</li>
<li>
<FormattedMessage {...messages.a11yBodyExtraInfo} />
</li>
</ol>
<div className="small">
<FormattedMessage
{...messages.a11yBodyA11yFeedback}
values={{
emailElement: (
<MailtoLink
to={email}
data-testid="email-element"
>
{email}
</MailtoLink>
),
}}
/>
</div>
</Stack>
</div>
);
AccessibilityBody.propTypes = {
communityAccessibilityLink: PropTypes.string.isRequired,
email: PropTypes.string.isRequired,
};
export default injectIntl(AccessibilityBody);

View File

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

View File

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

View File

@@ -0,0 +1,111 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
a11yBodyPolicyLink: {
id: 'a11yBodyPolicyLink',
defaultMessage: 'Website Accessibility Policy',
description: 'Title for link to full accessibility policy.',
},
a11yBodyPageHeader: {
id: 'a11yBodyPageHeader',
defaultMessage: 'Individualized Accessibility Process for Course Creators',
description: 'Heading for studio\'s accessibility policy page.',
},
a11yBodyIntroGraph: {
id: 'a11yBodyIntroGraph',
defaultMessage: `At edX, we seek to understand and respect the unique needs and perspectives of the edX global community.
We value every course team and are committed to expanding access to all, including course team creators and authors with
disabilities. To that end, we have adopted a {communityAccessibilityLink} and this process to allow course team creators
and authors to request assistance if they are unable to develop and post content on our platform via Studio because of their
disabilities.`,
description: 'Introductory paragraph outlining why we care about accessibility, and what we\'re doing about it.',
},
a11yBodyStepsHeader: {
id: 'a11yBodyStepsHeader',
defaultMessage: 'Course team creators and authors needing such assistance should take the following steps:',
description: 'Heading for list of steps authors can take for accessibility requests.',
},
a11yBodyEdxResponse: {
id: 'a11yBodyEdxResponse',
defaultMessage: `'We will communicate with you about your preferences and needs in determining the appropriate solution, although
the ultimate decision will be ours, provided that the solution is effective and timely. The factors we will consider in choosing
an accessibility solution are: effectiveness; timeliness (relative to your deadlines); ease of implementation; and ease of use for
you. We will notify you of the decision and explain the basis for our decision within 10 business days of discussing with you.`,
description: 'Paragraph outlining how we will select an accessibility solution.',
},
a11yBodyEdxFollowUp: {
id: 'a11yBodyEdxFollowUp',
defaultMessage: `Thereafter, we will communicate with you on a weekly basis regarding our evaluation, decision, and progress in
implementing the accessibility solution. We will notify you when implementation of your accessibility solution is complete and
will follow-up with you as may be necessary to see if the solution was effective.`,
description: 'Paragraph outlining how we will follow-up with you during and after implementing an accessibility solution.',
},
a11yBodyOngoingSupport: {
id: 'a11yBodyOngoingSupport',
defaultMessage: 'EdX will provide ongoing technical support as needed and will address any additional issues that arise after the initial course creation.',
description: 'A statement of ongoing support.',
},
a11yBodyA11yFeedback: {
id: 'a11yBodyA11yFeedback',
defaultMessage: 'Please direct any questions or suggestions on how to improve the accessibility of Studio to {emailElement} or use the form below. We welcome your feedback.',
description: 'Contact information heading for those with accessibility issues or suggestions.',
},
a11yBodyEmailHeading: {
id: 'a11yBodyEmailHeading',
defaultMessage: 'Send an email to {emailElement} with the following information:',
description: 'Heading for list of information required when you email us.',
},
a11yBodyNameEmail: {
id: 'a11yBodyNameEmail',
defaultMessage: 'your name and email address;',
description: 'Your contact information.',
},
a11yBodyInstitution: {
id: 'a11yBodyInstitution',
defaultMessage: 'the edX member institution that you are affiliated with;',
description: 'edX affiliate information.',
},
a11yBodyBarrier: {
id: 'a11yBodyBarrier',
defaultMessage: 'a brief description of the challenge or barrier to access that you are experiencing; and',
description: 'Accessibility problem information.',
},
a11yBodyTimeConstraints: {
id: 'a11yBodyTimeConstraints',
defaultMessage: 'how soon you need access and for how long (e.g., a planned course start date or in connection with a course-related deadline such as a final essay).',
description: 'Time contstraint information.',
},
a11yBodyReceipt: {
id: 'a11yBodyReceipt',
defaultMessage: 'The edX Support Team will respond to confirm receipt and forward your request to the edX Partner Manager for your institution and the edX Website Accessibility Specialist.',
description: 'Paragraph outlining what steps edX will take immediately.',
},
a11yBodyExtraInfo: {
id: 'a11yBodyExtraInfo',
defaultMessage: `With guidance from the Website Accessibility Specialist, edX will contact you to discuss your request and gather
additional information from you about your preferences and needs, to determine if there's a workable solution that edX is able to support.`,
description: 'Paragraph outlining how and when edX will reach out to you.',
},
a11yBodyFixesListHeader: {
id: 'a11yBodyFixesListHeader',
defaultMessage: 'EdX will assist you promptly and thoroughly so that you are able to create content on the CMS within your time constraints. Such efforts may include, but are not limited to:',
description: 'Heading for list of ways we might be able to assist.',
},
a11yBodyThirdParty: {
id: 'a11yBodyThirdParty',
defaultMessage: 'Purchasing a third-party tool or software for use on an individual basis to assist your use of Studio;',
description: 'Buy third-party software.',
},
a11yBodyContractor: {
id: 'a11yBodyContractor',
defaultMessage: 'Engaging a trained independent contractor to provide real-time visual, verbal and physical assistance; or',
description: 'Hire a contractor.',
},
a11yBodyCodeFix: {
id: 'a11yBodyCodeFix',
defaultMessage: 'Developing new code to implement a technical fix.',
description: 'Make a technical fix.',
},
});
export default messages;

View File

@@ -0,0 +1,146 @@
import React from 'react';
import PropTypes from 'prop-types';
import {
injectIntl, FormattedMessage, intlShape, FormattedDate, FormattedTime,
} from '@edx/frontend-platform/i18n';
import {
ActionRow, Alert, Form, Stack, StatefulButton,
} from '@openedx/paragon';
import { RequestStatus } from '../../data/constants';
import { STATEFUL_BUTTON_STATES } from '../../constants';
import submitAccessibilityForm from '../data/thunks';
import useAccessibility from './hooks';
import messages from './messages';
const AccessibilityForm = ({
accessibilityEmail,
// injected
intl,
}) => {
const {
errors,
values,
isFormFilled,
dispatch,
handleBlur,
handleChange,
hasErrorField,
savingStatus,
} = useAccessibility({ name: '', email: '', message: '' }, intl);
const formFields = [
{
label: intl.formatMessage(messages.accessibilityPolicyFormEmailLabel),
name: 'email',
value: values.email,
},
{
label: intl.formatMessage(messages.accessibilityPolicyFormNameLabel),
name: 'name',
value: values.name,
},
{
label: intl.formatMessage(messages.accessibilityPolicyFormMessageLabel),
name: 'message',
value: values.message,
},
];
const createButtonState = {
labels: {
default: intl.formatMessage(messages.accessibilityPolicyFormSubmitLabel),
pending: intl.formatMessage(messages.accessibilityPolicyFormSubmittingFeedbackLabel),
},
disabledStates: [STATEFUL_BUTTON_STATES.pending],
};
const handleSubmit = () => {
dispatch(submitAccessibilityForm(values));
};
const start = new Date('Mon Jan 29 2018 13:00:00 GMT (UTC)');
const end = new Date('Fri Feb 2 2018 21:00:00 GMT (UTC)');
return (
<>
<h2 className="my-4">
<FormattedMessage {...messages.accessibilityPolicyFormHeader} />
</h2>
{savingStatus === RequestStatus.SUCCESSFUL && (
<Alert variant="success">
<Stack gap={2}>
<div className="mb-2">
<FormattedMessage {...messages.accessibilityPolicyFormSuccess} />
</div>
<div>
<FormattedMessage
{...messages.accessibilityPolicyFormSuccessDetails}
values={{
day_start: (<FormattedDate value={start} weekday="long" />),
time_start: (<FormattedTime value={start} timeZoneName="short" />),
day_end: (<FormattedDate value={end} weekday="long" />),
time_end: (<FormattedTime value={end} timeZoneName="short" />),
}}
/>
</div>
</Stack>
</Alert>
)}
{savingStatus === RequestStatus.FAILED && (
<Alert variant="danger">
<div data-testid="rate-limit-alert">
<FormattedMessage
{...messages.accessibilityPolicyFormErrorHighVolume}
values={{
emailLink: <a href={`mailto:${accessibilityEmail}`}>{accessibilityEmail}</a>,
}}
/>
</div>
</Alert>
)}
<Form>
{formFields.map((field) => (
<Form.Group size="sm" key={field.label}>
<Form.Control
value={field.value}
name={field.name}
isInvalid={hasErrorField(field.name)}
type={field.name === 'email' ? 'email' : null}
as={field.name === 'message' ? 'textarea' : 'input'}
onChange={handleChange}
onBlur={handleBlur}
floatingLabel={field.label}
/>
{hasErrorField(field.name) && (
<Form.Control.Feedback type="invalid" data-testid={`error-feedback-${field.name}`}>
{errors[field.name]}
</Form.Control.Feedback>
)}
</Form.Group>
))}
</Form>
<ActionRow>
<StatefulButton
key="save-button"
onClick={handleSubmit}
disabled={!isFormFilled}
state={
savingStatus === RequestStatus.IN_PROGRESS
? STATEFUL_BUTTON_STATES.pending
: STATEFUL_BUTTON_STATES.default
}
{...createButtonState}
/>
</ActionRow>
</>
);
};
AccessibilityForm.propTypes = {
accessibilityEmail: PropTypes.string.isRequired,
// injected
intl: intlShape.isRequired,
};
export default injectIntl(AccessibilityForm);

View File

@@ -0,0 +1,164 @@
import {
render,
act,
screen,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { initializeMockApp } from '@edx/frontend-platform';
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import initializeStore from '../../store';
import { RequestStatus } from '../../data/constants';
import AccessibilityForm from './index';
import { getZendeskrUrl } from '../data/api';
import messages from './messages';
let axiosMock;
let store;
const defaultProps = {
accessibilityEmail: 'accessibilityTest@test.com',
};
const initialState = {
accessibilityPage: {
savingStatus: '',
},
};
const renderComponent = () => {
render(
<IntlProvider locale="en">
<AppProvider store={store}>
<AccessibilityForm {...defaultProps} />
</AppProvider>
</IntlProvider>,
);
};
describe('<AccessibilityPolicyForm />', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: false,
roles: [],
},
});
store = initializeStore(initialState);
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
});
describe('renders', () => {
beforeEach(() => {
renderComponent();
});
it('correct number of form fields', () => {
const formSections = screen.getAllByRole('textbox');
const formButton = screen.getByText(messages.accessibilityPolicyFormSubmitLabel.defaultMessage);
expect(formSections).toHaveLength(3);
expect(formButton).toBeVisible();
});
it('hides StatusAlert on initial load', () => {
expect(screen.queryAllByRole('alert')).toHaveLength(0);
});
});
describe('statusAlert', () => {
let formSections;
let submitButton;
beforeEach(async () => {
renderComponent();
formSections = screen.getAllByRole('textbox');
await act(async () => {
userEvent.type(formSections[0], 'email@email.com');
userEvent.type(formSections[1], 'test name');
userEvent.type(formSections[2], 'feedback message');
});
submitButton = screen.getByText(messages.accessibilityPolicyFormSubmitLabel.defaultMessage);
});
it('shows correct success message', async () => {
axiosMock.onPost(getZendeskrUrl()).reply(200);
await act(async () => {
userEvent.click(submitButton);
});
const { savingStatus } = store.getState().accessibilityPage;
expect(savingStatus).toEqual(RequestStatus.SUCCESSFUL);
expect(screen.getAllByRole('alert')).toHaveLength(1);
expect(screen.getByText(messages.accessibilityPolicyFormSuccess.defaultMessage)).toBeVisible();
formSections.forEach(input => {
expect(input.value).toBe('');
});
});
it('shows correct rate limiting message', async () => {
axiosMock.onPost(getZendeskrUrl()).reply(429);
await act(async () => {
userEvent.click(submitButton);
});
const { savingStatus } = store.getState().accessibilityPage;
expect(savingStatus).toEqual(RequestStatus.FAILED);
expect(screen.getAllByRole('alert')).toHaveLength(1);
expect(screen.getByTestId('rate-limit-alert')).toBeVisible();
formSections.forEach(input => {
expect(input.value).not.toBe('');
});
});
});
describe('input validation', () => {
let formSections;
let submitButton;
beforeEach(async () => {
renderComponent();
formSections = screen.getAllByRole('textbox');
await act(async () => {
userEvent.type(formSections[0], 'email@email.com');
userEvent.type(formSections[1], 'test name');
userEvent.type(formSections[2], 'feedback message');
});
submitButton = screen.getByText(messages.accessibilityPolicyFormSubmitLabel.defaultMessage);
});
it('adds validation checking on each input field', async () => {
await act(async () => {
userEvent.clear(formSections[0]);
userEvent.clear(formSections[1]);
userEvent.clear(formSections[2]);
});
const emailError = screen.getByTestId('error-feedback-email');
expect(emailError).toBeVisible();
const fullNameError = screen.getByTestId('error-feedback-email');
expect(fullNameError).toBeVisible();
const messageError = screen.getByTestId('error-feedback-message');
expect(messageError).toBeVisible();
});
it('sumbit button is disabled when trying to submit with all empty fields', async () => {
await act(async () => {
userEvent.clear(formSections[0]);
userEvent.clear(formSections[1]);
userEvent.clear(formSections[2]);
userEvent.click(submitButton);
});
expect(submitButton.closest('button')).toBeDisabled();
});
});
});

View File

@@ -0,0 +1,58 @@
import { useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import { RequestStatus } from '../../data/constants';
import messages from './messages';
const useAccessibility = (initialValues, intl) => {
const dispatch = useDispatch();
const savingStatus = useSelector(state => state.accessibilityPage.savingStatus);
const [isFormFilled, setFormFilled] = useState(false);
const validationSchema = Yup.object().shape({
name: Yup.string().required(
intl.formatMessage(messages.accessibilityPolicyFormValidName),
),
email: Yup.string()
.email(intl.formatMessage(messages.accessibilityPolicyFormValidEmail))
.required(intl.formatMessage(messages.accessibilityPolicyFormValidEmail)),
message: Yup.string().required(
intl.formatMessage(messages.accessibilityPolicyFormValidMessage),
),
});
const {
values, errors, touched, handleChange, handleBlur, handleReset,
} = useFormik({
initialValues,
enableReinitialize: true,
validateOnBlur: false,
validationSchema,
});
useEffect(() => {
setFormFilled(Object.values(values).every((i) => i));
}, [values]);
useEffect(() => {
if (savingStatus === RequestStatus.SUCCESSFUL) {
handleReset();
}
}, [savingStatus]);
const hasErrorField = (fieldName) => !!errors[fieldName] && !!touched[fieldName];
return {
errors,
values,
isFormFilled,
dispatch,
handleBlur,
handleChange,
hasErrorField,
savingStatus,
};
};
export default useAccessibility;

View File

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

View File

@@ -0,0 +1,76 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
accessibilityPolicyFormEmailLabel: {
id: 'accessibilityPolicyFormEmailLabel',
defaultMessage: 'Email Address',
description: 'Label for the email form field',
},
accessibilityPolicyFormErrorHighVolume: {
id: 'accessibilityPolicyFormErrorHighVolume',
defaultMessage: 'We are currently experiencing high volume. Try again later today or send an email message to {emailLink}.',
description: 'Error message when site is experiencing high volume that will include an email link',
},
accessibilityPolicyFormErrorMissingFields: {
id: 'accessibilityPolicyFormErrorMissingFields',
defaultMessage: 'Make sure to fill in all fields.',
description: 'Error message to instruct user to fill in all fields',
},
accessibilityPolicyFormHeader: {
id: 'accessibilityPolicyFormHeader',
defaultMessage: 'Studio Accessibility Feedback',
description: 'The heading for the form',
},
accessibilityPolicyFormMessageLabel: {
id: 'accessibilityPolicyFormMessageLabel',
defaultMessage: 'Message',
description: 'Label for the message form field',
},
accessibilityPolicyFormNameLabel: {
id: 'accessibilityPolicyFormNameLabel',
defaultMessage: 'Name',
description: 'Label for the name form field',
},
accessibilityPolicyFormSubmitAria: {
id: 'accessibilityPolicyFormSubmitAria',
defaultMessage: 'Submit Accessibility Feedback Form',
description: 'Detailed aria-label for the submit button',
},
accessibilityPolicyFormSubmitLabel: {
id: 'accessibilityPolicyFormSubmitLabel',
defaultMessage: 'Submit',
description: 'General label for the submit button',
},
accessibilityPolicyFormSubmittingFeedbackLabel: {
id: 'accessibilityPolicyFormSubmittingFeedbackLabel',
defaultMessage: 'Submitting',
description: 'Loading message while form feedback is being submitted',
},
accessibilityPolicyFormSuccess: {
id: 'accessibilityPolicyFormSuccess',
defaultMessage: 'Thank you for contacting edX!',
description: 'Simple thank you message when form submission is successful',
},
accessibilityPolicyFormSuccessDetails: {
id: 'accessibilityPolicyFormSuccessDetails',
defaultMessage: 'Thank you for your feedback regarding the accessibility of Studio. We typically respond within one business day ({day_start} to {day_end}, {time_start} to {time_end}).',
description: 'Detailed thank you message when form submission is successful',
},
accessibilityPolicyFormValidEmail: {
id: 'accessibilityPolicyFormValidEmail',
defaultMessage: 'Enter a valid email address.',
description: 'Error message for when an invalid email is entered into the form',
},
accessibilityPolicyFormValidMessage: {
id: 'accessibilityPolicyFormValidMessage',
defaultMessage: 'Enter a message.',
description: 'Error message an invalid message is entered into the form',
},
accessibilityPolicyFormValidName: {
id: 'accessibilityPolicyFormValidName',
defaultMessage: 'Enter a name.',
description: 'Error message an invalid name is entered into the form',
},
});
export default messages;

View File

@@ -0,0 +1,42 @@
import React from 'react';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Helmet } from 'react-helmet';
import { Container } from '@openedx/paragon';
import { 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

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

View File

@@ -0,0 +1,28 @@
import { ensureConfig, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
ensureConfig([
'STUDIO_BASE_URL',
], 'Course Apps API service');
export const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
export const getZendeskrUrl = () => `${getApiBaseUrl()}/zendesk_proxy/v0`;
/**
* Posts the form data to zendesk endpoint
* @param {string} courseId
* @returns {Promise<[{}]>}
*/
export async function postAccessibilityForm({ name, email, message }) {
const data = {
name,
tags: ['studio_a11y'],
email: {
from: email,
subject: 'Studio Accessibility Request',
message,
},
};
await getAuthenticatedHttpClient().post(getZendeskrUrl(), data);
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,10 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
pageTitle: {
id: 'course-authoring.import.page.title',
defaultMessage: 'Studio Accessibility Policy| {siteName}',
},
});
export default messages;

View File

@@ -3,8 +3,8 @@ import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import {
Container, Button, Layout, StatefulButton, TransitionReplace,
} from '@edx/paragon';
import { CheckCircle, Info, Warning } from '@edx/paragon/icons';
} from '@openedx/paragon';
import { CheckCircle, Info, Warning } from '@openedx/paragon/icons';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import Placeholder from '@edx/frontend-lib-content-components';

View File

@@ -1,6 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import { ActionRow, AlertModal, Button } from '@edx/paragon';
import { ActionRow, AlertModal, Button } from '@openedx/paragon';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import ModalErrorListItem from './ModalErrorListItem';

View File

@@ -1,7 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Alert, Icon } from '@edx/paragon';
import { Error } from '@edx/paragon/icons';
import { Alert, Icon } from '@openedx/paragon';
import { Error } from '@openedx/paragon/icons';
import { capitalize } from 'lodash';
import { transformKeysToCamelCase } from '../../utils';

View File

@@ -7,8 +7,8 @@ import {
IconButton,
ModalPopup,
useToggle,
} from '@edx/paragon';
import { InfoOutline, Warning } from '@edx/paragon/icons';
} from '@openedx/paragon';
import { InfoOutline, Warning } from '@openedx/paragon/icons';
import PropTypes from 'prop-types';
import { capitalize } from 'lodash';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';

View File

@@ -26,6 +26,10 @@ export const NOTIFICATION_MESSAGES = {
deleting: 'Deleting',
copying: 'Copying',
pasting: 'Pasting',
discardChanges: 'Discarding changes',
publishing: 'Publishing',
hidingFromStudents: 'Hiding from students',
makingVisibleToStudents: 'Making visible to students',
empty: '',
};

View File

@@ -0,0 +1,43 @@
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,20 +1,20 @@
// @ts-check
// disable prop-types since we're using TypeScript to define the prop types,
// but the linter can't detect that in a .jsx file.
/* eslint-disable react/prop-types */
import React from 'react';
import Select, { components } from 'react-select';
import {
Badge,
Collapsible,
SelectableBox,
Button,
ModalPopup,
useToggle,
SearchField,
} from '@edx/paragon';
import PropTypes from 'prop-types';
Spinner,
} from '@openedx/paragon';
import classNames from 'classnames';
import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
import { SelectableBox } from '@edx/frontend-lib-content-components';
import { useIntl } from '@edx/frontend-platform/i18n';
import { debounce } from 'lodash';
import messages from './messages';
import './ContentTagsCollapsible.scss';
import ContentTagsDropDownSelector from './ContentTagsDropDownSelector';
@@ -22,9 +22,134 @@ 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
@@ -98,99 +223,200 @@ import useContentTagsCollapsibleHelper from './ContentTagsCollapsibleHelper';
*
* @param {Object} props - The component props.
* @param {string} props.contentId - Id of the content object
* @param {{value: string, label: string}[]} props.stagedContentTags
* - Array of staged tags represented as objects with value/label
* @param {(taxonomyId: number, tag: {value: string, label: string}) => void} props.addStagedContentTag
* - Callback function to add a staged tag for a taxonomy
* @param {(taxonomyId: number, tagValue: string) => void} props.removeStagedContentTag
* - Callback function to remove a staged tag from a taxonomy
* @param {Function} props.setStagedTags - Callback function to set staged tags for a taxonomy to provided tags list
* @param {TaxonomyData & {contentTags: ContentTagData[]}} props.taxonomyAndTagsData - Taxonomy metadata & applied tags
*/
const ContentTagsCollapsible = ({ contentId, taxonomyAndTagsData }) => {
const ContentTagsCollapsible = ({
contentId, taxonomyAndTagsData, stagedContentTags, addStagedContentTag, removeStagedContentTag, setStagedTags,
}) => {
const intl = useIntl();
const { id, name, canTagObject } = taxonomyAndTagsData;
const { id: taxonomyId, name, canTagObject } = taxonomyAndTagsData;
const 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, tagsTree, contentTagsCount, checkedTags,
} = useContentTagsCollapsibleHelper(contentId, taxonomyAndTagsData);
const [isOpen, open, close] = useToggle(false);
const [addTagsButtonRef, setAddTagsButtonRef] = React.useState(null);
tagChangeHandler,
removeAppliedTagHandler,
appliedContentTagsTree,
stagedContentTagsTree,
contentTagsCount,
checkedTags,
commitStagedTags,
updateTags,
} = useContentTagsCollapsibleHelper(
contentId,
taxonomyAndTagsData,
addStagedContentTag,
removeStagedContentTag,
stagedContentTags,
);
const [searchTerm, setSearchTerm] = React.useState('');
const handleSelectableBoxChange = React.useCallback((e) => {
tagChangeHandler(e.target.value, e.target.checked);
}, []);
}, [tagChangeHandler]);
const handleSearch = debounce((term) => {
setSearchTerm(term.trim());
}, 500); // Perform search after 500ms
const handleSearchChange = React.useCallback((value) => {
if (value === '') {
// No need to debounce when search term cleared. Clear debounce function
handleSearch.cancel();
setSearchTerm('');
} else {
handleSearch(value);
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]);
const modalPopupOnCloseHandler = React.useCallback((event) => {
close(event);
// Clear search term
// onChange handler for react-select component, currently only called when
// staged tags in the react-select input are removed or fully cleared.
// The remaining staged tags are passed in as the parameter, so we set the state
// to the passed in tags
const handleStagedTagsMenuChange = React.useCallback((stagedTags) => {
// Get tags that were unstaged to remove them from checkbox selector
const unstagedTags = stagedContentTags.filter(
t1 => !stagedTags.some(t2 => t1.value === t2.value),
);
// Call the `tagChangeHandler` with the unstaged tags to unselect them from the selectbox
// and update the staged content tags tree. Since the `handleStagedTagsMenuChange` function is={}
// only called when a change occurs in the react-select menu component we know that tags can only be
// removed from there, hence the tagChangeHandler is always called with `checked=false`.
unstagedTags.forEach(unstagedTag => tagChangeHandler(unstagedTag.value, false));
setStagedTags(taxonomyId, stagedTags);
}, [taxonomyId, setStagedTags, stagedContentTags, tagChangeHandler]);
const handleCommitStagedTags = React.useCallback(() => {
commitStagedTags();
handleStagedTagsMenuChange([]);
selectRef.current?.blur();
setSearchTerm('');
}, []);
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={id}>
<ContentTagsTree tagsTree={tagsTree} removeTagHandler={tagChangeHandler} />
<div key={taxonomyId}>
<ContentTagsTree tagsTree={appliedContentTagsTree} removeTagHandler={removeAppliedTagHandler} />
</div>
<div className="d-flex taxonomy-tags-selector-menu">
{canTagObject && (
<Button
ref={setAddTagsButtonRef}
variant="outline-primary"
onClick={open}
>
<FormattedMessage {...messages.addTagsButtonText} />
</Button>
<Select
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>
<ModalPopup
hasArrow
placement="bottom"
positionRef={addTagsButtonRef}
isOpen={isOpen}
onClose={modalPopupOnCloseHandler}
>
<div className="bg-white p-3 shadow">
<SelectableBox.Set
type="checkbox"
name="tags"
columns={1}
ariaLabel={intl.formatMessage(messages.taxonomyTagsAriaLabel)}
className="taxonomy-tags-selectable-box-set"
onChange={handleSelectableBoxChange}
value={checkedTags}
>
<SearchField
onSubmit={() => {}}
onChange={handleSearchChange}
className="mb-2"
/>
<ContentTagsDropDownSelector
key={`selector-${id}`}
taxonomyId={id}
level={0}
tagsTree={tagsTree}
searchTerm={searchTerm}
/>
</SelectableBox.Set>
</div>
</ModalPopup>
</Collapsible>
<div className="d-flex">
<Badge
@@ -207,17 +433,4 @@ const ContentTagsCollapsible = ({ contentId, taxonomyAndTagsData }) => {
);
};
ContentTagsCollapsible.propTypes = {
contentId: PropTypes.string.isRequired,
taxonomyAndTagsData: PropTypes.shape({
id: PropTypes.number,
name: PropTypes.string,
contentTags: PropTypes.arrayOf(PropTypes.shape({
value: PropTypes.string,
lineage: PropTypes.arrayOf(PropTypes.string),
})),
canTagObject: PropTypes.bool.isRequired,
}).isRequired,
};
export default ContentTagsCollapsible;

View File

@@ -27,3 +27,33 @@
.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

@@ -9,22 +9,89 @@ import userEvent from '@testing-library/user-event';
import ContentTagsCollapsible from './ContentTagsCollapsible';
import messages from './messages';
import { useTaxonomyTagsData } from './data/apiHooks';
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(() => ({
hasMorePages: false,
tagPages: {
isLoading: true,
isError: false,
canAddTag: false,
data: [],
},
})),
useTaxonomyTagsData: jest.fn((_, parentTagValue) => {
// To mock nested call of useTaxonomyData in subtags dropdown
if (parentTagValue === 'Tag 1') {
return nestedTaxonomyMockData;
}
return taxonomyMockData;
}),
}));
const data = {
@@ -51,11 +118,29 @@ const data = {
},
],
},
stagedContentTags: [],
addStagedContentTag: jest.fn(),
removeStagedContentTag: jest.fn(),
setStagedTags: jest.fn(),
};
const ContentTagsCollapsibleComponent = ({ contentId, taxonomyAndTagsData }) => (
const ContentTagsCollapsibleComponent = ({
contentId,
taxonomyAndTagsData,
stagedContentTags,
addStagedContentTag,
removeStagedContentTag,
setStagedTags,
}) => (
<IntlProvider locale="en" messages={{}}>
<ContentTagsCollapsible contentId={contentId} taxonomyAndTagsData={taxonomyAndTagsData} />
<ContentTagsCollapsible
contentId={contentId}
taxonomyAndTagsData={taxonomyAndTagsData}
stagedContentTags={stagedContentTags}
addStagedContentTag={addStagedContentTag}
removeStagedContentTag={removeStagedContentTag}
setStagedTags={setStagedTags}
/>
</IntlProvider>
);
@@ -70,6 +155,10 @@ describe('<ContentTagsCollapsible />', () => {
jest.useRealTimers(); // Restore real timers after the tests
});
afterEach(() => {
jest.clearAllMocks(); // Reset all mock function call counts after each test case
});
async function getComponent(updatedData) {
const componentData = (!updatedData ? data : updatedData);
@@ -77,52 +166,14 @@ describe('<ContentTagsCollapsible />', () => {
<ContentTagsCollapsibleComponent
contentId={componentData.contentId}
taxonomyAndTagsData={componentData.taxonomyAndTagsData}
stagedContentTags={componentData.stagedContentTags}
addStagedContentTag={componentData.addStagedContentTag}
removeStagedContentTag={componentData.removeStagedContentTag}
setStagedTags={componentData.setStagedTags}
/>,
);
}
function setupTaxonomyMock() {
useTaxonomyTagsData.mockReturnValue({
hasMorePages: false,
canAddTag: false,
tagPages: {
isLoading: false,
isError: false,
data: [{
value: 'Tag 1',
externalId: null,
childCount: 0,
depth: 0,
parentValue: null,
id: 12345,
subTagsUrl: null,
canChangeTag: false,
canDeleteTag: false,
}, {
value: 'Tag 2',
externalId: null,
childCount: 0,
depth: 0,
parentValue: null,
id: 12346,
subTagsUrl: null,
canChangeTag: false,
canDeleteTag: false,
}, {
value: 'Tag 3',
externalId: null,
childCount: 0,
depth: 0,
parentValue: null,
id: 12347,
subTagsUrl: null,
canChangeTag: false,
canDeleteTag: false,
}],
},
});
}
it('should render taxonomy tags data along content tags number badge', async () => {
const { container, getByText } = await getComponent();
expect(getByText('Taxonomy 1')).toBeInTheDocument();
@@ -130,59 +181,153 @@ describe('<ContentTagsCollapsible />', () => {
expect(getByText('3')).toBeInTheDocument();
});
it('should render new tags as they are checked in the dropdown', async () => {
setupTaxonomyMock();
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 tags" button
const expandToggle = container.getElementsByClassName('collapsible-trigger')[0];
fireEvent.click(expandToggle);
// Click on "Add tags" button to open dropdown to select new tags
const addTagsButton = getByText(messages.addTagsButtonText.defaultMessage);
fireEvent.click(addTagsButton);
// Wait for the dropdown selector for tags to open,
// Tag 3 should only appear there
expect(getByText('Tag 3')).toBeInTheDocument();
expect(getAllByText('Tag 3').length === 1);
const tag3 = getByText('Tag 3');
fireEvent.click(tag3);
// After clicking on Tag 3, it should also appear in amongst
// the tag bubbles in the tree
expect(getAllByText('Tag 3').length === 2);
});
it('should remove tag when they are unchecked in the dropdown', async () => {
setupTaxonomyMock();
const { container, getByText, getAllByText } = await getComponent();
// Expand the Taxonomy to view applied tags and "Add tags" button
// Expand the Taxonomy to view applied tags and "Add a tag" button
const expandToggle = container.getElementsByClassName('collapsible-trigger')[0];
fireEvent.click(expandToggle);
// Check that Tag 2 appears in tag bubbles
expect(getByText('Tag 2')).toBeInTheDocument();
// Click on "Add tags" button to open dropdown to select new tags
const addTagsButton = getByText(messages.addTagsButtonText.defaultMessage);
fireEvent.click(addTagsButton);
// 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(getByText('Tag 3')).toBeInTheDocument();
expect(getAllByText('Tag 3').length).toBe(1);
// Get the Tag 2 checkbox and click on it
const tag2 = getAllByText('Tag 2')[1];
fireEvent.click(tag2);
// Click to check Tag 3 and check the `addStagedContentTag` was called with the correct params
const tag3 = getByText('Tag 3');
fireEvent.click(tag3);
// After clicking on Tag 2, it should be removed from
// the tag bubbles in so only the one in the dropdown appears
expect(getAllByText('Tag 2').length === 1);
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 () => {
@@ -190,16 +335,17 @@ describe('<ContentTagsCollapsible />', () => {
container, getByText, getByRole, getByDisplayValue,
} = await getComponent();
// Expand the Taxonomy to view applied tags and "Add tags" button
// 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 tags" button to open dropdown
const addTagsButton = getByText(messages.addTagsButtonText.defaultMessage);
fireEvent.click(addTagsButton);
// 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('searchbox');
const searchField = getByRole('combobox');
const searchTerm = 'memo';
@@ -223,17 +369,17 @@ describe('<ContentTagsCollapsible />', () => {
});
it('should close dropdown selector when clicking away', async () => {
setupTaxonomyMock();
const { container, getByText, queryByText } = await getComponent();
// Expand the Taxonomy to view applied tags and "Add tags" button
// 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 tags" button to open dropdown
const addTagsButton = getByText(messages.addTagsButtonText.defaultMessage);
fireEvent.click(addTagsButton);
// 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
@@ -250,6 +396,156 @@ describe('<ContentTagsCollapsible />', () => {
expect(queryByText('Tag 3')).not.toBeInTheDocument();
});
it('should test keyboard navigation of add tags widget', async () => {
const {
container,
getByText,
queryByText,
queryAllByText,
} = await getComponent();
// Expand the Taxonomy to view applied tags and "Add a tag" button
const expandToggle = container.getElementsByClassName('collapsible-trigger')[0];
fireEvent.click(expandToggle);
// Click on "Add a tag" button to open dropdown
const addTagsButton = getByText(messages.collapsibleAddTagsPlaceholderText.defaultMessage);
// Use `mouseDown` instead of `click` since the react-select didn't respond to `click`
fireEvent.mouseDown(addTagsButton);
// Wait for the dropdown selector for tags to open, Tag 3 should appear
// since it is not applied
expect(queryByText('Tag 3')).toBeInTheDocument();
/*
The dropdown data looks like the following:
│Tag 1
│ │
│ ├─ Tag 1.1
│ │
│ │
│ └─ Tag 1.2
│Tag 2
│Tag 3
*/
// Press tab to focus on first element in dropdown, Tag 1 should be focused
userEvent.tab();
const dropdownTag1Div = queryAllByText('Tag 1')[1].closest('.dropdown-selector-tag-actions');
expect(dropdownTag1Div).toHaveFocus();
// Press right arrow to expand Tag 1, Tag 1.1 & Tag 1.2 should now be visible
userEvent.keyboard('{arrowright}');
expect(queryAllByText('Tag 1.1').length).toBe(2);
expect(queryByText('Tag 1.2')).toBeInTheDocument();
// Press left arrow to collapse Tag 1, Tag 1.1 & Tag 1.2 should not be visible
userEvent.keyboard('{arrowleft}');
expect(queryAllByText('Tag 1.1').length).toBe(1);
expect(queryByText('Tag 1.2')).not.toBeInTheDocument();
// Press enter key to expand Tag 1, Tag 1.1 & Tag 1.2 should now be visible
userEvent.keyboard('{enter}');
expect(queryAllByText('Tag 1.1').length).toBe(2);
expect(queryByText('Tag 1.2')).toBeInTheDocument();
// Press down arrow to navigate to Tag 1.1, it should be focused
userEvent.keyboard('{arrowdown}');
const dropdownTag1pt1Div = queryAllByText('Tag 1.1')[1].closest('.dropdown-selector-tag-actions');
expect(dropdownTag1pt1Div).toHaveFocus();
// Press down arrow again to navigate to Tag 1.2, it should be fouced
userEvent.keyboard('{arrowdown}');
const dropdownTag1pt2Div = queryAllByText('Tag 1.2')[0].closest('.dropdown-selector-tag-actions');
expect(dropdownTag1pt2Div).toHaveFocus();
// Press down arrow again to navigate to Tag 2, it should be fouced
userEvent.keyboard('{arrowdown}');
const dropdownTag2Div = queryAllByText('Tag 2')[1].closest('.dropdown-selector-tag-actions');
expect(dropdownTag2Div).toHaveFocus();
// Press up arrow to navigate back to Tag 1.2, it should be focused
userEvent.keyboard('{arrowup}');
expect(dropdownTag1pt2Div).toHaveFocus();
// Press up arrow to navigate back to Tag 1.1, it should be focused
userEvent.keyboard('{arrowup}');
expect(dropdownTag1pt1Div).toHaveFocus();
// Press up arrow again to navigate to Tag 1, it should be focused
userEvent.keyboard('{arrowup}');
expect(dropdownTag1Div).toHaveFocus();
// Press down arrow twice to navigate to Tag 1.2, it should be focsed
userEvent.keyboard('{arrowdown}');
userEvent.keyboard('{arrowdown}');
expect(dropdownTag1pt2Div).toHaveFocus();
// Press space key to check Tag 1.2, it should be staged
userEvent.keyboard('{space}');
const taxonomyId = 123;
const addedStagedTag = {
value: 'Tag%201,Tag%201.2',
label: 'Tag 1.2',
};
expect(data.addStagedContentTag).toHaveBeenCalledWith(taxonomyId, addedStagedTag);
// Press enter key again to uncheck Tag 1.2 (since it's a leaf), it should be unstaged
userEvent.keyboard('{enter}');
const tagValue = 'Tag%201,Tag%201.2';
expect(data.removeStagedContentTag).toHaveBeenCalledWith(taxonomyId, tagValue);
// Press left arrow to navigate back to Tag 1, it should be focused
userEvent.keyboard('{arrowleft}');
expect(dropdownTag1Div).toHaveFocus();
// Press tab key it should jump to cancel button, it should be focused
userEvent.tab();
const dropdownCancel = getByText(messages.collapsibleCancelStagedTagsButtonText.defaultMessage);
expect(dropdownCancel).toHaveFocus();
// Press tab again, it should exit and close the select menu, since there are not staged tags
userEvent.tab();
expect(queryByText('Tag 3')).not.toBeInTheDocument();
// Press shift tab, focus back on select menu input, it should open the menu
userEvent.tab({ shift: true });
expect(queryByText('Tag 3')).toBeInTheDocument();
// Press shift tab again, it should focus out and close the select menu
userEvent.tab({ shift: true });
expect(queryByText('Tag 3')).not.toBeInTheDocument();
// Press tab again, the select menu should open, then press escape, it should close
userEvent.tab();
expect(queryByText('Tag 3')).toBeInTheDocument();
userEvent.keyboard('{escape}');
expect(queryByText('Tag 3')).not.toBeInTheDocument();
});
it('should remove applied tags when clicking on `x` of tag bubble', async () => {
const { container, getByText } = await getComponent();
// Expand the Taxonomy to view applied tags
const expandToggle = container.getElementsByClassName('collapsible-trigger')[0];
fireEvent.click(expandToggle);
// Click on 'x' of applied tag to remove it
const appliedTag = getByText('Tag 2');
const xButtonAppliedTag = appliedTag.nextSibling;
xButtonAppliedTag.click();
// Check that the applied tag has been removed
expect(appliedTag).not.toBeInTheDocument();
});
it('should render taxonomy tags data without tags number badge', async () => {
const updatedData = { ...data };
updatedData.taxonomyAndTagsData = { ...updatedData.taxonomyAndTagsData };

View File

@@ -1,84 +1,89 @@
// @ts-check
import React from 'react';
import { useCheckboxSetValues } from '@edx/paragon';
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 consolidates two tag trees into one, sorting the keys in
* alphabetical order.
* Util function that sorts the keys of a tree in alphabetical order.
*
* @param {object} tree1 - first tag tree
* @param {object} tree2 - second tag tree
* @returns {object} merged tree containing both tree1 and tree2
* @param {object} tree - tree that needs it's keys sorted
* @returns {object} sorted tree
*/
const mergeTrees = (tree1, tree2) => {
const mergedTree = cloneDeep(tree1);
const sortKeysAlphabetically = (obj) => {
const sortedObj = {};
Object.keys(obj)
.sort()
.forEach((key) => {
sortedObj[key] = obj[key];
if (obj[key] && typeof obj[key] === 'object') {
sortedObj[key].children = sortKeysAlphabetically(obj[key].children);
}
});
return sortedObj;
};
const mergeRecursively = (destination, source) => {
Object.entries(source).forEach(([key, sourceValue]) => {
const destinationValue = destination[key];
if (destinationValue && sourceValue && typeof destinationValue === 'object' && typeof sourceValue === 'object') {
mergeRecursively(destinationValue, sourceValue);
} else {
// eslint-disable-next-line no-param-reassign
destination[key] = cloneDeep(sourceValue);
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);
}
});
};
mergeRecursively(mergedTree, tree2);
return sortKeysAlphabetically(mergedTree);
return sortedObj;
};
/**
* Util function that removes the tag along with its ancestors if it was
* the only explicit child tag.
* 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 - tag tree to remove the tag from
* @param {string[]} tagsToRemove - full lineage of tag to remove.
* eg: ['grand parent', 'parent', 'tag']
* @param {object} tree - tree to extract the leaf tags from
* @returns {Array<string>} array of leaf (explicit) tags of provided tree
*/
const removeTags = (tree, tagsToRemove) => {
if (!tree || !tagsToRemove.length) {
return;
}
const key = tagsToRemove[0];
if (tree[key]) {
removeTags(tree[key].children, tagsToRemove.slice(1));
const getLeafTags = (tree) => {
const leafKeys = [];
if (Object.keys(tree[key].children).length === 0 && (tree[key].explicit === false || tagsToRemove.length === 1)) {
// eslint-disable-next-line no-param-reassign
delete tree[key];
}
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) => {
const useContentTagsCollapsibleHelper = (
contentId,
taxonomyAndTagsData,
addStagedContentTag,
removeStagedContentTag,
stagedContentTags,
) => {
const {
id, contentTags, canTagObject,
} = taxonomyAndTagsData;
// State to determine whether the tags are being updating so we can make a call
// State to determine whether an applied tag was removed so we make a call
// to the update endpoint to the reflect those changes
const [updatingTags, setUpdatingTags] = React.useState(false);
const [removingAppliedTag, setRemoveAppliedTag] = React.useState(false);
const updateTags = useContentTaxonomyTagsUpdater(contentId, id);
// Keeps track of the content objects tags count (both implicit and explicit)
@@ -86,32 +91,55 @@ const useContentTagsCollapsibleHelper = (contentId, taxonomyAndTagsData) => {
// Keeps track of the tree structure for tags that are add by selecting/unselecting
// tags in the dropdowns.
const [addedContentTags, setAddedContentTags] = React.useState({});
const [stagedContentTagsTree, setStagedContentTagsTree] = React.useState({});
// To handle checking/unchecking tags in the SelectableBox
const [checkedTags, { add, remove, clear }] = useCheckboxSetValues();
const [checkedTags, { add, remove }] = useCheckboxSetValues();
// Handles making requests to the update endpoint whenever the checked tags change
// 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 is updating the tags.
if (updatingTags) {
setUpdatingTags(false);
// 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)));
updateTags.mutate({ tags });
const staged = stagedContentTags.map(t => t.label);
const remainingAppliedTags = tags.filter(t => !staged.includes(t));
updateTags.mutate({ tags: remainingAppliedTags });
}
}, [contentId, id, canTagObject, checkedTags]);
}, [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 appliedContentTags = React.useMemo(() => {
const appliedContentTagsTree = React.useMemo(() => {
let contentTagsCounter = 0;
// Clear all the tags that have not been commited and the checked boxes when
// fresh contentTags passed in so the latest state from the backend is rendered
setAddedContentTags({});
clear();
// When an error occurs while updating, the contentTags query is invalidated,
// hence they will be recalculated, and the updateTags mutation should be reset.
if (updateTags.isError) {
@@ -134,8 +162,12 @@ const useContentTagsCollapsibleHelper = (contentId, taxonomyAndTagsData) => {
// Populating the SelectableBox with "selected" (explicit) tags
const value = item.lineage.map(l => encodeURIComponent(l)).join(',');
// eslint-disable-next-line no-unused-expressions
isExplicit ? add(value) : remove(value);
// Clear all the existing applied tags
remove(value);
// Add only the explicitly applied tags
if (isExplicit) {
add(value);
}
contentTagsCounter += 1;
}
@@ -147,13 +179,53 @@ const useContentTagsCollapsibleHelper = (contentId, taxonomyAndTagsData) => {
return resultTree;
}, [contentTags, updateTags.isError]);
// This is the source of truth that represents the current state of tags in
// this Taxonomy as a tree. Whenever either the `appliedContentTags` (i.e. tags passed in
// the prop from the backed) change, or when the `addedContentTags` (i.e. tags added by
// selecting/unselecting them in the dropdown) change, the tree is recomputed.
const tagsTree = React.useMemo(() => (
mergeTrees(appliedContentTags, addedContentTags)
), [appliedContentTags, addedContentTags]);
/**
* 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
@@ -163,6 +235,10 @@ const useContentTagsCollapsibleHelper = (contentId, taxonomyAndTagsData) => {
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,
@@ -174,12 +250,8 @@ const useContentTagsCollapsibleHelper = (contentId, taxonomyAndTagsData) => {
traversal[tag].explicit = isExplicit;
}
// Clear out the ancestor tags leading to newly selected tag
// as they automatically become implicit
value.push(encodeURIComponent(tag));
// eslint-disable-next-line no-unused-expressions
isExplicit ? add(value.join(',')) : remove(value.join(','));
traversal = traversal[tag].children;
});
};
@@ -188,26 +260,62 @@ const useContentTagsCollapsibleHelper = (contentId, taxonomyAndTagsData) => {
const tagLineage = tagSelectableBoxValue.split(',').map(t => decodeURIComponent(t));
const selectedTag = tagLineage.slice(-1)[0];
const addedTree = { ...addedContentTags };
if (checked) {
const stagedTree = cloneDeep(stagedContentTagsTree);
// We "add" the tag to the SelectableBox.Set inside the addTags method
addTags(addedTree, tagLineage, selectedTag);
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);
// We remove them from both incase we are unselecting from an
// existing applied Tag or a newly added one
removeTags(addedTree, tagLineage);
removeTags(appliedContentTags, tagLineage);
// 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,
]);
setAddedContentTags(addedTree);
setUpdatingTags(true);
}, []);
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, tagsTree, contentTagsCount, checkedTags,
tagChangeHandler,
removeAppliedTagHandler,
appliedContentTagsTree: sortKeysAlphabetically(appliedContentTagsTree),
stagedContentTagsTree: sortKeysAlphabetically(stagedContentTagsTree),
contentTagsCount,
checkedTags,
commitStagedTags,
updateTags,
};
};

View File

@@ -1,10 +1,16 @@
// @ts-check
import React, { useMemo, useEffect } from 'react';
import React, {
useMemo,
useEffect,
useState,
useCallback,
} from 'react';
import PropTypes from 'prop-types';
import {
Container,
CloseButton,
Spinner,
} from '@edx/paragon';
} from '@openedx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useParams } from 'react-router-dom';
import messages from './messages';
@@ -14,42 +20,85 @@ import {
useContentTaxonomyTagsData,
useContentData,
} from './data/apiHooks';
import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded } from '../taxonomy/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 */
const ContentTagsDrawer = () => {
/**
* 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();
const { contentId } = /** @type {{contentId: string}} */(useParams());
// 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 useTaxonomyListData = () => {
const taxonomyListData = useTaxonomyListDataResponse(org);
const isTaxonomyListLoaded = useIsTaxonomyListDataLoaded(org);
return { taxonomyListData, isTaxonomyListLoaded };
};
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 { taxonomyListData, isTaxonomyListLoaded } = useTaxonomyListData();
const { data: taxonomyListData, isSuccess: isTaxonomyListLoaded } = useTaxonomyList(org);
const closeContentTagsDrawer = () => {
// "*" allows communication with any origin
window.parent.postMessage('closeManageTagsDrawer', '*');
};
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) {
closeContentTagsDrawer();
onCloseDrawer();
}
};
document.addEventListener('keydown', handleEsc);
@@ -86,10 +135,10 @@ const ContentTagsDrawer = () => {
<div className="mt-1">
<Container size="xl">
<CloseButton onClick={() => closeContentTagsDrawer()} data-testid="drawer-close-button" />
<CloseButton onClick={() => onCloseDrawer()} data-testid="drawer-close-button" />
<span>{intl.formatMessage(messages.headerSubtitle)}</span>
{ isContentDataLoaded
? <h3>{ contentData.displayName }</h3>
? <h3>{ contentName }</h3>
: (
<div className="d-flex justify-content-center align-items-center flex-column">
<Spinner
@@ -105,7 +154,14 @@ const ContentTagsDrawer = () => {
{ isTaxonomyListLoaded && isContentTaxonomyTagsLoaded
? taxonomies.map((data) => (
<div key={`taxonomy-tags-collapsible-${data.id}`}>
<ContentTagsCollapsible contentId={contentId} taxonomyAndTagsData={data} />
<ContentTagsCollapsible
contentId={contentId}
taxonomyAndTagsData={data}
stagedContentTags={stagedContentTags[data.id] || []}
addStagedContentTag={addStagedContentTag}
removeStagedContentTag={removeStagedContentTag}
setStagedTags={setStagedTags}
/>
<hr />
</div>
))
@@ -116,4 +172,14 @@ const ContentTagsDrawer = () => {
);
};
ContentTagsDrawer.propTypes = {
id: PropTypes.string,
onClose: PropTypes.func,
};
ContentTagsDrawer.defaultProps = {
id: undefined,
onClose: undefined,
};
export default ContentTagsDrawer;

View File

@@ -1,21 +1,34 @@
import React from 'react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { act, render, fireEvent } from '@testing-library/react';
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 { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded } from '../taxonomy/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: 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@7f47fe2dbcaf47c5a071671c741fe1ab',
contentId,
}),
}));
// FIXME: replace these mocks with API mocks
jest.mock('./data/apiHooks', () => ({
useContentTaxonomyTagsData: jest.fn(() => ({
isSuccess: false,
@@ -28,20 +41,115 @@ jest.mock('./data/apiHooks', () => ({
useContentTaxonomyTagsUpdater: jest.fn(() => ({
isError: false,
})),
useTaxonomyTagsData: jest.fn(() => ({
hasMorePages: false,
tagPages: {
isLoading: true,
isError: false,
canAddTag: false,
data: [],
},
})),
}));
jest.mock('../taxonomy/data/apiHooks', () => ({
useTaxonomyListDataResponse: jest.fn(),
useIsTaxonomyListDataLoaded: jest.fn(),
jest.mock('../taxonomy/data/api', () => ({
// By default, the mock taxonomy list will never load (promise never resolves):
getTaxonomyListData: jest.fn(),
}));
const RootWrapper = () => (
const queryClient = new QueryClient();
const RootWrapper = (params) => (
<IntlProvider locale="en" messages={{}}>
<ContentTagsDrawer />
<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();
@@ -56,7 +164,6 @@ describe('<ContentTagsDrawer />', () => {
});
it('shows spinner before the taxonomy tags query is complete', async () => {
useIsTaxonomyListDataLoaded.mockReturnValue(false);
await act(async () => {
const { getAllByRole } = render(<RootWrapper />);
const spinner = getAllByRole('status')[1];
@@ -77,8 +184,18 @@ describe('<ContentTagsDrawer />', () => {
});
});
it('shows content using params', async () => {
useContentData.mockReturnValue({
isSuccess: true,
data: {
displayName: 'Unit 1',
},
});
render(<RootWrapper id={contentId} />);
expect(screen.getByText('Unit 1')).toBeInTheDocument();
});
it('shows the taxonomies data including tag numbers after the query is complete', async () => {
useIsTaxonomyListDataLoaded.mockReturnValue(true);
useContentTaxonomyTagsData.mockReturnValue({
isSuccess: true,
data: {
@@ -115,7 +232,7 @@ describe('<ContentTagsDrawer />', () => {
],
},
});
useTaxonomyListDataResponse.mockReturnValue({
getTaxonomyListData.mockResolvedValue({
results: [{
id: 123,
name: 'Taxonomy 1',
@@ -130,6 +247,7 @@ describe('<ContentTagsDrawer />', () => {
});
await act(async () => {
const { container, getByText } = render(<RootWrapper />);
await waitFor(() => { expect(getByText('Taxonomy 1')).toBeInTheDocument(); });
expect(getByText('Taxonomy 1')).toBeInTheDocument();
expect(getByText('Taxonomy 2')).toBeInTheDocument();
const tagCountBadges = container.getElementsByClassName('badge');
@@ -138,7 +256,105 @@ describe('<ContentTagsDrawer />', () => {
});
});
it('should call closeContentTagsDrawer when CloseButton is clicked', async () => {
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 />);
@@ -152,7 +368,17 @@ describe('<ContentTagsDrawer />', () => {
postMessageSpy.mockRestore();
});
it('should call closeContentTagsDrawer when Escape key is pressed and no selectable box is active', () => {
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 />);
@@ -166,7 +392,7 @@ describe('<ContentTagsDrawer />', () => {
postMessageSpy.mockRestore();
});
it('should not call closeContentTagsDrawer when Escape key is pressed and a selectable box is active', () => {
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 />);

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