Compare commits

..

8 Commits

Author SHA1 Message Date
Sagirov Eugeniy
0f7921ad1e chore: update frontend-platform version to v4.2.0 2023-05-09 18:34:53 -03:00
AsadAzam
8a6d067481 Merge pull request #455 from raccoongang/olive/fix-post-sharing-link
fix: post sharing URL
2023-03-28 15:58:09 +05:00
Eugene Dyudyunov
947dd1f1a6 fix: my posts tab search (#467)
This is a backport from the master branch:
https://github.com/openedx/frontend-app-discussions/pull/419
2023-03-15 00:50:02 +05:00
AsadAzam
e158964c41 Merge pull request #449 from raccoongang/lunyachek/fix/discussion-code-block-formatting-olive
fix: Fix for code block formatting on the post preview and published post view
2023-03-10 21:07:19 +05:00
Eugene Dyudyunov
7dfbd44552 fix: post sharing URL
When MFE uses path-based deployment (common domain for all MFEs
divided by path) - the copied link leads to 404 error.

The fix:
- construct a Post URL using MFE's `PUBLIC_PATH` env var
- works for both subdomain and subdirectory-based deployments

Note:

LMS recently started to use the discussions sidebar which has the same
button "Copy Link", but it uses its own env variable
`DISCUSSIONS_MFE_BASE_URL`
2023-02-24 16:52:10 +02:00
lunyachek
d9e60ddd92 fix: Fix for code block formatting on the post preview and published post view 2023-02-21 17:43:03 +02:00
Adolfo R. Brandes
8d35a729d2 feat: Support runtime configuration
frontend-platform supports runtime configuration since 2.5.0 (see the PR
that introduced it[1], but it requires MFE cooperation.  This implements
just that: by avoiding making configuration values constant, it should
now be possible to change them after initialization.

Almost all changes here relate to the `LMS_BASE_URL` setting, which in
most places was treated as a constant.

[1] https://github.com/openedx/frontend-platform/pull/335

This is a backport to Olive of
https://github.com/openedx/frontend-app-discussions/pull/377
2022-12-09 10:40:46 +00:00
Mehak Nasir
e89792b8d8 fix: handled thread not found result on frontend 2022-12-08 10:44:41 +00:00
120 changed files with 3481 additions and 4695 deletions

View File

@@ -34,4 +34,4 @@ jobs:
- name: i18n_extract
run: npm run i18n_extract
- name: Coverage
uses: codecov/codecov-action@v3
uses: codecov/codecov-action@v2

View File

@@ -1,15 +1,16 @@
|Build Status| |Codecov| |license|
frontend-app-discussions
========================
|Build Status| |Codecov| |license|
Please tag **@edx/fedx-team** on any PRs or issues. Thanks.
Purpose
-------
Introduction
------------
This repository is a React-based micro frontend for the Open edX discussion forums.
Getting Started
---------------
**Installation and Startup**
1. Clone your new repo:
@@ -25,39 +26,6 @@ Getting Started
The dev server is running at `http://localhost:2002 <http://localhost:2002>`_.
Getting Help
------------
Please tag **@edx/fedx-team** on any PRs or issues. Thanks.
If you're having trouble, we have discussion forums at https://discuss.openedx.org where you can connect with others in the community.
For anything non-trivial, the best path is to open an issue in this repository with as many details about the issue you are facing as you can provide.
https://github.com/openedx/frontend-app-discussions/issues
For more information about these options, see the `Getting Help`_ page.
.. _Getting Help: https://openedx.org/getting-help
How to Contribute
-----------------
Details about how to become a contributor to the Open edX project may be found in the wiki at `How to contribute`_
.. _How to contribute: https://openedx.org/r/how-to-contribute
The Open edX Code of Conduct
----------------------------
All community members should familarize themselves with the `Open edX Code of Conduct`_.
.. _Open edX Code of Conduct: https://openedx.org/code-of-conduct/
People
------
The assigned maintainers for this component and other project details may be found in Backstage or groked from inspecting catalog-info.yaml.
Reporting Security Issues
-------------------------
Please do not report security issues in public. Please email security@edx.org.
Project Structure
-----------------
@@ -80,4 +48,4 @@ Please see `edx/frontend-platform's i18n module <https://edx.github.io/frontend-
.. |Codecov| image:: https://codecov.io/gh/edx/frontend-app-discussions/branch/master/graph/badge.svg
:target: https://codecov.io/gh/edx/frontend-app-discussions
.. |license| image:: https://img.shields.io/npm/l/@edx/frontend-app-discussions.svg
:target: @edx/frontend-app-discussions
:target: @edx/frontend-app-discussions

View File

@@ -1,38 +0,0 @@
# This file records information about this repo. Its use is described in OEP-55:
# https://open-edx-proposals.readthedocs.io/en/latest/processes/oep-0055-proc-project-maintainers.html
apiVersion: backstage.io/v1alpha1
# (Required) Acceptable Values: Component, Resource, System
# A repo will almost certainly be a Component.
kind: Component
metadata:
name: 'frontend-app-discussions'
description: "The discussion forum for openEdx discussions"
links:
- url: "https://github.com/openedx/frontend-app-discussions"
title: "Frontend app discussions"
# Backstage uses the MaterialUI Icon Set.
# https://mui.com/material-ui/material-icons/
# The value will be the name of the icon.
icon: "Web"
annotations:
# (Optional) Annotation keys and values can be whatever you want.
# We use it in Open edX repos to have a comma-separated list of GitHub user
# names that might be interested in changes to the architecture of this
# component.
openedx.org/arch-interest-groups: ""
spec:
# (Required) This can be a group (`group:<github_group_name>`) or a user (`user:<github_username>`).
# Don't forget the "user:" or "group:" prefix. Groups must be GitHub team
# names in the openedx GitHub organization: https://github.com/orgs/openedx/teams
#
# If you need a new team created, create an issue with tCRIL engineering:
# https://github.com/openedx/tcril-engineering/issues/new/choose
owner: group:infinity
# (Required) Acceptable Type Values: service, website, library
type: 'website'
# (Required) Acceptable Lifecycle Values: experimental, production, deprecated
lifecycle: 'production'

2016
package-lock.json generated
View File

@@ -10,16 +10,15 @@
"license": "AGPL-3.0",
"dependencies": {
"@edx/brand": "npm:@edx/brand-openedx@1.1.0",
"@edx/frontend-component-footer": "11.2.0",
"@edx/frontend-component-header": "3.2.0",
"@edx/frontend-platform": "2.6.1",
"@edx/frontend-component-footer": "12.0.0",
"@edx/frontend-component-header": "4.0.0",
"@edx/frontend-platform": "4.2.0",
"@edx/paragon": "20.15.0",
"@reduxjs/toolkit": "1.8.0",
"@tinymce/tinymce-react": "3.13.1",
"babel-polyfill": "6.26.0",
"classnames": "2.3.1",
"core-js": "3.21.1",
"dompurify": "^2.4.3",
"formik": "2.2.9",
"lodash.snakecase": "4.1.1",
"prop-types": "15.8.1",
@@ -45,6 +44,8 @@
"@testing-library/user-event": "13.5.0",
"axios-mock-adapter": "1.20.0",
"babel-plugin-react-intl": "8.2.25",
"codecov": "3.8.3",
"es-check": "6.2.1",
"eslint-plugin-simple-import-sort": "7.0.0",
"glob": "7.2.0",
"husky": "7.0.4",
@@ -1829,6 +1830,109 @@
"integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==",
"dev": true
},
"node_modules/@caporal/core": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@caporal/core/-/core-2.0.2.tgz",
"integrity": "sha512-o3J5aZINFWkkL+sL0DUs1dPHJjaetAAdwMRLbJ4U8aJW3K81E323IFMkFNYcOwTiPVhNzllC3USxZbU6xWFjFg==",
"dev": true,
"dependencies": {
"@types/glob": "^7.1.1",
"@types/lodash": "4.14.149",
"@types/node": "13.9.3",
"@types/table": "5.0.0",
"@types/tabtab": "^3.0.1",
"@types/wrap-ansi": "^3.0.0",
"chalk": "3.0.0",
"glob": "^7.1.6",
"lodash": "4.17.15",
"table": "5.4.6",
"tabtab": "^3.0.2",
"winston": "3.2.1",
"wrap-ansi": "^6.2.0"
},
"engines": {
"node": ">= 10"
}
},
"node_modules/@caporal/core/node_modules/@types/node": {
"version": "13.9.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-13.9.3.tgz",
"integrity": "sha512-01s+ac4qerwd6RHD+mVbOEsraDHSgUaefQlEdBbUolnQFjKwCr7luvAlEwW1RFojh67u0z4OUTjPn9LEl4zIkA==",
"dev": true
},
"node_modules/@caporal/core/node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/@caporal/core/node_modules/chalk": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz",
"integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==",
"dev": true,
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/@caporal/core/node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/@caporal/core/node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true
},
"node_modules/@caporal/core/node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"engines": {
"node": ">=8"
}
},
"node_modules/@caporal/core/node_modules/lodash": {
"version": "4.17.15",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz",
"integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==",
"dev": true
},
"node_modules/@caporal/core/node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/@cnakazawa/watch": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@cnakazawa/watch/-/watch-1.0.4.tgz",
@@ -1845,6 +1949,15 @@
"node": ">=0.1.95"
}
},
"node_modules/@colors/colors": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz",
"integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==",
"dev": true,
"engines": {
"node": ">=0.1.90"
}
},
"node_modules/@cospired/i18n-iso-languages": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@cospired/i18n-iso-languages/-/i18n-iso-languages-2.2.0.tgz",
@@ -3282,21 +3395,42 @@
}
},
"node_modules/@edx/frontend-component-footer": {
"version": "11.2.0",
"resolved": "https://registry.npmjs.org/@edx/frontend-component-footer/-/frontend-component-footer-11.2.0.tgz",
"integrity": "sha512-prN6SeoWenbNq6jCqlpmYx57OhNbVGw1wjQFVH+aA5JMjjLUf6PmjPG87fuD9udQyFteb2lvbCyongPEZOozrg==",
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/@edx/frontend-component-footer/-/frontend-component-footer-12.0.0.tgz",
"integrity": "sha512-m8Rx6ZPWzIN5XLrz6Ft3aTuFo0rty0jECd79CBYWdm0D9KD1WxoYEG+fElluyOQp/t42T5jLImHTSWjFURx5kw==",
"dependencies": {
"@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/fontawesome-svg-core": "6.4.0",
"@fortawesome/free-brands-svg-icons": "6.4.0",
"@fortawesome/free-regular-svg-icons": "6.4.0",
"@fortawesome/free-solid-svg-icons": "6.4.0",
"@fortawesome/react-fontawesome": "0.2.0"
},
"peerDependencies": {
"@edx/frontend-platform": "^2.3.0",
"@edx/frontend-platform": "^4.0.0",
"prop-types": "^15.5.10",
"react": "^16.9.0",
"react-dom": "^16.9.0"
"react": "^16.9.0 || ^17.0.0",
"react-dom": "^16.9.0 || ^17.0.0"
}
},
"node_modules/@edx/frontend-component-footer/node_modules/@fortawesome/fontawesome-common-types": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.4.0.tgz",
"integrity": "sha512-HNii132xfomg5QVZw0HwXXpN22s7VBHQBv9CeOu9tfJnhsWQNd2lmTNi8CSrnw5B+5YOmzu1UoPAyxaXsJ6RgQ==",
"hasInstallScript": true,
"engines": {
"node": ">=6"
}
},
"node_modules/@edx/frontend-component-footer/node_modules/@fortawesome/fontawesome-svg-core": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.4.0.tgz",
"integrity": "sha512-Bertv8xOiVELz5raB2FlXDPKt+m94MQ3JgDfsVbrqNpLU9+UE2E18GKjLKw+d3XbeYPqg1pzyQKGsrzbw+pPaw==",
"hasInstallScript": true,
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.4.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@edx/frontend-component-footer/node_modules/@fortawesome/react-fontawesome": {
@@ -3312,25 +3446,110 @@
}
},
"node_modules/@edx/frontend-component-header": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@edx/frontend-component-header/-/frontend-component-header-3.2.0.tgz",
"integrity": "sha512-ep9yOwe9CG03lSf/QBsBOZO3S/Ksrttgi4MkjpUe/6Sm/KdAnMCOhx2Gbj9jBcFrHqCkpeef26zGjjhpHGDwkQ==",
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@edx/frontend-component-header/-/frontend-component-header-4.0.0.tgz",
"integrity": "sha512-r/L3p2ZSI1DitjxVKAor18GmgJllafYslrdpzGI0vcX/gTemH13jf2Xr9iQqrT921DP2nzZ5GOwGJNptTSjiaA==",
"dependencies": {
"@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",
"@edx/paragon": "20.30.1",
"@fortawesome/fontawesome-svg-core": "6.3.0",
"@fortawesome/free-brands-svg-icons": "6.3.0",
"@fortawesome/free-regular-svg-icons": "6.3.0",
"@fortawesome/free-solid-svg-icons": "6.3.0",
"@fortawesome/react-fontawesome": "^0.2.0",
"babel-polyfill": "6.26.0",
"react-responsive": "8.2.0",
"react-transition-group": "4.4.5"
},
"peerDependencies": {
"@edx/frontend-platform": "^2.0.0",
"@edx/paragon": ">= 7.0.0 < 21.0.0",
"@edx/frontend-platform": "^4.0.0",
"prop-types": "^15.5.10",
"react": "^16.9.0",
"react-dom": "^16.9.0"
"react": "^16.9.0 || ^17.0.0",
"react-dom": "^16.9.0 || ^17.0.0"
}
},
"node_modules/@edx/frontend-component-header/node_modules/@edx/paragon": {
"version": "20.30.1",
"resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-20.30.1.tgz",
"integrity": "sha512-v3Ek8deZWqVKi3IWP08Mj4egrvbmbqQEyRA6+qazHZdgHJA4qOP1SST42UKd9XxPeRbLWUgaJWd0iBAOAna/gw==",
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.1.1",
"@fortawesome/react-fontawesome": "^0.1.18",
"@popperjs/core": "^2.11.4",
"bootstrap": "^4.6.2",
"classnames": "^2.3.1",
"email-prop-type": "^3.0.0",
"file-selector": "^0.6.0",
"font-awesome": "^4.7.0",
"glob": "^8.0.3",
"lodash.uniqby": "^4.7.0",
"mailto-link": "^2.0.0",
"prop-types": "^15.8.1",
"react-bootstrap": "^1.6.5",
"react-colorful": "^5.6.1",
"react-dropzone": "^14.2.1",
"react-focus-on": "^3.5.4",
"react-loading-skeleton": "^3.1.0",
"react-popper": "^2.2.5",
"react-proptype-conditional-require": "^1.0.4",
"react-responsive": "^8.2.0",
"react-table": "^7.7.0",
"react-transition-group": "^4.4.2",
"tabbable": "^5.3.3",
"uncontrollable": "^7.2.1",
"uuid": "^9.0.0"
},
"peerDependencies": {
"react": "^16.8.6 || ^17.0.0",
"react-dom": "^16.8.6 || ^17.0.0",
"react-intl": "^5.25.1"
}
},
"node_modules/@edx/frontend-component-header/node_modules/@edx/paragon/node_modules/@fortawesome/react-fontawesome": {
"version": "0.1.19",
"resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-0.1.19.tgz",
"integrity": "sha512-Hyb+lB8T18cvLNX0S3llz7PcSOAJMLwiVKBuuzwM/nI5uoBw+gQjnf9il0fR1C3DKOI5Kc79pkJ4/xB0Uw9aFQ==",
"dependencies": {
"prop-types": "^15.8.1"
},
"peerDependencies": {
"@fortawesome/fontawesome-svg-core": "~1 || ~6",
"react": ">=16.x"
}
},
"node_modules/@edx/frontend-component-header/node_modules/@fortawesome/free-brands-svg-icons": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-6.3.0.tgz",
"integrity": "sha512-xI0c+a8xnKItAXCN8rZgCNCJQiVAd2Y7p9e2ND6zN3J3ekneu96qrePieJ7yA7073C1JxxoM3vH1RU7rYsaj8w==",
"hasInstallScript": true,
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.3.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@edx/frontend-component-header/node_modules/@fortawesome/free-regular-svg-icons": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-6.3.0.tgz",
"integrity": "sha512-cZnwiVHZ51SVzWHOaNCIA+u9wevZjCuAGSvSYpNlm6A4H4Vhwh8481Bf/5rwheIC3fFKlgXxLKaw8Xeroz8Ntg==",
"hasInstallScript": true,
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.3.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@edx/frontend-component-header/node_modules/@fortawesome/free-solid-svg-icons": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.3.0.tgz",
"integrity": "sha512-x5tMwzF2lTH8pyv8yeZRodItP2IVlzzmBuD1M7BjawWgg9XAvktqJJ91Qjgoaf8qJpHQ8FEU9VxRfOkLhh86QA==",
"hasInstallScript": true,
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.3.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@edx/frontend-component-header/node_modules/@fortawesome/react-fontawesome": {
@@ -3345,18 +3564,63 @@
"react": ">=16.3"
}
},
"node_modules/@edx/frontend-component-header/node_modules/brace-expansion": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/@edx/frontend-component-header/node_modules/glob": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz",
"integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^5.0.1",
"once": "^1.3.0"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@edx/frontend-component-header/node_modules/minimatch": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@edx/frontend-component-header/node_modules/uuid": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz",
"integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/@edx/frontend-platform": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/@edx/frontend-platform/-/frontend-platform-2.6.1.tgz",
"integrity": "sha512-5ZcHBvwmJRYPPKEqv+H4DbsfW9atr5V2+uY/zX58F/Tqh0T5X8MmgppVjplWFbmon5QV4aNw5C1ViQnirNqUiA==",
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@edx/frontend-platform/-/frontend-platform-4.2.0.tgz",
"integrity": "sha512-iDoFeccENQKBjqUgdjl5KSwBrjNEj8YW6Ual+6twcHHJUBg3yRoBEphwHIoRREcMgQjhdKVAdWj8eleh4JsEKA==",
"dependencies": {
"@cospired/i18n-iso-languages": "2.2.0",
"@formatjs/intl-pluralrules": "4.3.3",
"@formatjs/intl-relativetimeformat": "10.0.1",
"axios": "0.26.1",
"axios-cache-adapter": "2.7.3",
"axios": "0.27.2",
"axios-cache-interceptor": "0.10.7",
"form-urlencoded": "4.1.4",
"glob": "7.2.0",
"glob": "7.2.3",
"history": "4.10.1",
"i18n-iso-countries": "4.3.1",
"jwt-decode": "3.1.2",
@@ -3371,24 +3635,58 @@
"universal-cookie": "4.0.4"
},
"bin": {
"intl-imports.js": "i18n/scripts/intl-imports.js",
"transifex-utils.js": "i18n/scripts/transifex-utils.js"
},
"peerDependencies": {
"@edx/paragon": ">= 10.0.0 < 21.0.0",
"prop-types": "^15.7.2",
"react": "^16.9.0",
"react-dom": "^16.9.0",
"react": "^16.9.0 || ^17.0.0",
"react-dom": "^16.9.0 || ^17.0.0",
"react-redux": "^7.1.1",
"react-router-dom": "^5.0.1",
"redux": "^4.0.4"
}
},
"node_modules/@edx/frontend-platform/node_modules/axios": {
"version": "0.26.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.26.1.tgz",
"integrity": "sha512-fPwcX4EvnSHuInCMItEhAGnaSEXRBjtzh9fOtsE6E1G6p7vl7edEeZe11QHf18+6+9gR5PbKV/sGKNaD8YaMeA==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.27.2.tgz",
"integrity": "sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==",
"dependencies": {
"follow-redirects": "^1.14.8"
"follow-redirects": "^1.14.9",
"form-data": "^4.0.0"
}
},
"node_modules/@edx/frontend-platform/node_modules/form-data": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/@edx/frontend-platform/node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@edx/new-relic-source-map-webpack-plugin": {
@@ -3435,27 +3733,6 @@
"react-intl": "^5.25.1"
}
},
"node_modules/@edx/paragon/node_modules/@fortawesome/fontawesome-common-types": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.2.0.tgz",
"integrity": "sha512-rBevIsj2nclStJ7AxTdfsa3ovHb1H+qApwrxcTVo+NNdeJiB9V75hsKfrkG5AwNcRUNxrPPiScGYCNmLMoh8pg==",
"hasInstallScript": true,
"engines": {
"node": ">=6"
}
},
"node_modules/@edx/paragon/node_modules/@fortawesome/fontawesome-svg-core": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.2.0.tgz",
"integrity": "sha512-Cf2mAAeMWFMzpLC7Y9H1I4o3wEU+XovVJhTiNG8ZNgSQj53yl7OCJaS80K4YjrABWZzbAHVaoHE1dVJ27AAYXw==",
"hasInstallScript": true,
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@edx/paragon/node_modules/brace-expansion": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
@@ -3779,62 +4056,89 @@
"integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ=="
},
"node_modules/@fortawesome/fontawesome-common-types": {
"version": "0.2.36",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-0.2.36.tgz",
"integrity": "sha512-a/7BiSgobHAgBWeN7N0w+lAhInrGxksn13uK7231n2m8EDPE3BMCl9NZLTGrj9ZXfCmC6LM0QLqXidIizVQ6yg==",
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.3.0.tgz",
"integrity": "sha512-4BC1NMoacEBzSXRwKjZ/X/gmnbp/HU5Qqat7E8xqorUtBFZS+bwfGH5/wqOC2K6GV0rgEobp3OjGRMa5fK9pFg==",
"hasInstallScript": true,
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/fontawesome-svg-core": {
"version": "1.2.36",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-1.2.36.tgz",
"integrity": "sha512-YUcsLQKYb6DmaJjIHdDWpBIGCcyE/W+p/LMGvjQem55Mm2XWVAP5kWTMKWLv9lwpCVjpLxPyOMOyUocP1GxrtA==",
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.3.0.tgz",
"integrity": "sha512-uz9YifyKlixV6AcKlOX8WNdtF7l6nakGyLYxYaCa823bEBqyj/U2ssqtctO38itNEwXb8/lMzjdoJ+aaJuOdrw==",
"hasInstallScript": true,
"dependencies": {
"@fortawesome/fontawesome-common-types": "^0.2.36"
"@fortawesome/fontawesome-common-types": "6.3.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-brands-svg-icons": {
"version": "5.15.4",
"resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-5.15.4.tgz",
"integrity": "sha512-f1witbwycL9cTENJegcmcZRYyawAFbm8+c6IirLmwbbpqz46wyjbQYLuxOc7weXFXfB7QR8/Vd2u5R3q6JYD9g==",
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-6.4.0.tgz",
"integrity": "sha512-qvxTCo0FQ5k2N+VCXb/PZQ+QMhqRVM4OORiO6MXdG6bKolIojGU/srQ1ptvKk0JTbRgaJOfL2qMqGvBEZG7Z6g==",
"hasInstallScript": true,
"dependencies": {
"@fortawesome/fontawesome-common-types": "^0.2.36"
"@fortawesome/fontawesome-common-types": "6.4.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-brands-svg-icons/node_modules/@fortawesome/fontawesome-common-types": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.4.0.tgz",
"integrity": "sha512-HNii132xfomg5QVZw0HwXXpN22s7VBHQBv9CeOu9tfJnhsWQNd2lmTNi8CSrnw5B+5YOmzu1UoPAyxaXsJ6RgQ==",
"hasInstallScript": true,
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-regular-svg-icons": {
"version": "5.15.4",
"resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-5.15.4.tgz",
"integrity": "sha512-9VNNnU3CXHy9XednJ3wzQp6SwNwT3XaM26oS4Rp391GsxVYA+0oDR2J194YCIWf7jNRCYKjUCOduxdceLrx+xw==",
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-6.4.0.tgz",
"integrity": "sha512-ZfycI7D0KWPZtf7wtMFnQxs8qjBXArRzczABuMQqecA/nXohquJ5J/RCR77PmY5qGWkxAZDxpnUFVXKwtY/jPw==",
"hasInstallScript": true,
"dependencies": {
"@fortawesome/fontawesome-common-types": "^0.2.36"
"@fortawesome/fontawesome-common-types": "6.4.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-regular-svg-icons/node_modules/@fortawesome/fontawesome-common-types": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.4.0.tgz",
"integrity": "sha512-HNii132xfomg5QVZw0HwXXpN22s7VBHQBv9CeOu9tfJnhsWQNd2lmTNi8CSrnw5B+5YOmzu1UoPAyxaXsJ6RgQ==",
"hasInstallScript": true,
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-solid-svg-icons": {
"version": "5.15.4",
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-5.15.4.tgz",
"integrity": "sha512-JLmQfz6tdtwxoihXLg6lT78BorrFyCf59SAwBM6qV/0zXyVeDygJVb3fk+j5Qat+Yvcxp1buLTY5iDh1ZSAQ8w==",
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.4.0.tgz",
"integrity": "sha512-kutPeRGWm8V5dltFP1zGjQOEAzaLZj4StdQhWVZnfGFCvAPVvHh8qk5bRrU4KXnRRRNni5tKQI9PBAdI6MP8nQ==",
"hasInstallScript": true,
"dependencies": {
"@fortawesome/fontawesome-common-types": "^0.2.36"
"@fortawesome/fontawesome-common-types": "6.4.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-solid-svg-icons/node_modules/@fortawesome/fontawesome-common-types": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.4.0.tgz",
"integrity": "sha512-HNii132xfomg5QVZw0HwXXpN22s7VBHQBv9CeOu9tfJnhsWQNd2lmTNi8CSrnw5B+5YOmzu1UoPAyxaXsJ6RgQ==",
"hasInstallScript": true,
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/react-fontawesome": {
"version": "0.1.18",
"resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-0.1.18.tgz",
@@ -6436,6 +6740,12 @@
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
"dev": true
},
"node_modules/@types/lodash": {
"version": "4.14.149",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.149.tgz",
"integrity": "sha512-ijGqzZt/b7BfzcK9vTrS6MFljQRPn5BFWOx8oE0GYxribu6uV+aA9zZuXI1zc/etK9E8nrgdoF2+LgUw7+9tJQ==",
"dev": true
},
"node_modules/@types/mime": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz",
@@ -6587,6 +6897,21 @@
"integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==",
"dev": true
},
"node_modules/@types/table": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@types/table/-/table-5.0.0.tgz",
"integrity": "sha512-fQLtGLZXor264zUPWI95WNDsZ3QV43/c0lJpR/h1hhLJumXRmHNsrvBfEzW2YMhb0EWCsn4U6h82IgwsajAuTA==",
"dev": true
},
"node_modules/@types/tabtab": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/tabtab/-/tabtab-3.0.2.tgz",
"integrity": "sha512-d8aOSJPS3SEGZevyr7vbAVUNPWGFmdFlk13vbPPK87vz+gYGM57L8T11k4wK2mOgQYZjEVYQEqmCTvupPoQBWw==",
"dev": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/tapable": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/tapable/-/tapable-1.0.8.tgz",
@@ -6668,6 +6993,12 @@
"node": ">=0.10.0"
}
},
"node_modules/@types/wrap-ansi": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@types/wrap-ansi/-/wrap-ansi-3.0.0.tgz",
"integrity": "sha512-ltIpx+kM7g/MLRZfkbL7EsCEjfzCcScLpkg37eXEtx5kmrAKBkTJwd1GIAjDSL8wTpM6Hzn5YO4pSb91BEwu1g==",
"dev": true
},
"node_modules/@types/ws": {
"version": "8.5.3",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.3.tgz",
@@ -7170,6 +7501,15 @@
"sprintf-js": "~1.0.2"
}
},
"node_modules/argv": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/argv/-/argv-0.0.2.tgz",
"integrity": "sha512-dEamhpPEwRUBpLNHeuCm/v+g0anFByHahxodVO/BbAarHVBBg2MccCwf9K+o1Pof+2btdnkJelYVUWjW/VrATw==",
"dev": true,
"engines": {
"node": ">=0.6.10"
}
},
"node_modules/aria-hidden": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.1.3.tgz",
@@ -7337,8 +7677,7 @@
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"dev": true
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
},
"node_modules/at-least-node": {
"version": "1.0.0",
@@ -7400,20 +7739,22 @@
"version": "0.21.4",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz",
"integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==",
"dev": true,
"dependencies": {
"follow-redirects": "^1.14.0"
}
},
"node_modules/axios-cache-adapter": {
"version": "2.7.3",
"resolved": "https://registry.npmjs.org/axios-cache-adapter/-/axios-cache-adapter-2.7.3.tgz",
"integrity": "sha512-A+ZKJ9lhpjthOEp4Z3QR/a9xC4du1ALaAsejgRGrH9ef6kSDxdFrhRpulqsh9khsEnwXxGfgpUuDp1YXMNMEiQ==",
"node_modules/axios-cache-interceptor": {
"version": "0.10.7",
"resolved": "https://registry.npmjs.org/axios-cache-interceptor/-/axios-cache-interceptor-0.10.7.tgz",
"integrity": "sha512-UjpxChG5DpF6Kf1IPGMLOzRDNL8ZNS6TOn1jTaVvCE7cWFU904jJwi0T1s+IbijpnLEjK2iq5uLIuR8Sj+RsFQ==",
"dependencies": {
"cache-control-esm": "1.0.0",
"md5": "^2.2.1"
"cache-parser": "^1.2.4",
"fast-defer": "^1.1.7",
"object-code": "^1.2.4"
},
"peerDependencies": {
"axios": "~0.21.1"
"funding": {
"url": "https://github.com/ArthurFiorette/axios-cache-interceptor?sponsor=1"
}
},
"node_modules/axios-mock-adapter": {
@@ -8569,10 +8910,10 @@
"node": ">=0.10.0"
}
},
"node_modules/cache-control-esm": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/cache-control-esm/-/cache-control-esm-1.0.0.tgz",
"integrity": "sha512-Fa3UV4+eIk4EOih8FTV6EEsVKO0W5XWtNs6FC3InTfVz+EjurjPfDXY5wZDo/lxjDxg5RjNcurLyxEJBcEUx9g=="
"node_modules/cache-parser": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/cache-parser/-/cache-parser-1.2.4.tgz",
"integrity": "sha512-O0KwuHuJnbHUrghHi2kGp0SxnWSIBXTYt7M8WVhW0kbPRUNUKoE/Of6e1rRD6AAxmfxFunKnt90yEK09D+sc5g=="
},
"node_modules/cacheable-request": {
"version": "2.1.4",
@@ -8805,14 +9146,6 @@
"integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==",
"dev": true
},
"node_modules/charenc": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz",
"integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==",
"engines": {
"node": "*"
}
},
"node_modules/check-types": {
"version": "8.0.3",
"resolved": "https://registry.npmjs.org/check-types/-/check-types-8.0.3.tgz",
@@ -9149,6 +9482,26 @@
"node": ">= 0.12.0"
}
},
"node_modules/codecov": {
"version": "3.8.3",
"resolved": "https://registry.npmjs.org/codecov/-/codecov-3.8.3.tgz",
"integrity": "sha512-Y8Hw+V3HgR7V71xWH2vQ9lyS358CbGCldWlJFR0JirqoGtOoas3R3/OclRTvgUYFK29mmJICDPauVKmpqbwhOA==",
"deprecated": "https://about.codecov.io/blog/codecov-uploader-deprecation-plan/",
"dev": true,
"dependencies": {
"argv": "0.0.2",
"ignore-walk": "3.0.4",
"js-yaml": "3.14.1",
"teeny-request": "7.1.1",
"urlgrey": "1.0.0"
},
"bin": {
"codecov": "bin/codecov"
},
"engines": {
"node": ">=4.0"
}
},
"node_modules/collect-v8-coverage": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.1.tgz",
@@ -9168,6 +9521,16 @@
"node": ">=0.10.0"
}
},
"node_modules/color": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/color/-/color-3.2.1.tgz",
"integrity": "sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==",
"dev": true,
"dependencies": {
"color-convert": "^1.9.3",
"color-string": "^1.6.0"
}
},
"node_modules/color-convert": {
"version": "1.9.3",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
@@ -9183,6 +9546,16 @@
"integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==",
"dev": true
},
"node_modules/color-string": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
"integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
"dev": true,
"dependencies": {
"color-name": "^1.0.0",
"simple-swizzle": "^0.2.2"
}
},
"node_modules/colord": {
"version": "2.9.2",
"resolved": "https://registry.npmjs.org/colord/-/colord-2.9.2.tgz",
@@ -9195,11 +9568,26 @@
"integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==",
"dev": true
},
"node_modules/colornames": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/colornames/-/colornames-1.1.1.tgz",
"integrity": "sha512-/pyV40IrsdulWv+wFPmERh9k/mjsPZ64yUMDmWrtj/k1nmgrzzIENWKdaVKyBbvFdQWqkcaRxr+polCo3VMe7A==",
"dev": true
},
"node_modules/colorspace": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/colorspace/-/colorspace-1.1.4.tgz",
"integrity": "sha512-BgvKJiuVu1igBUF2kEjRCZXol6wiiGbY5ipL/oVPwm0BL9sIpMIzM8IK7vwuxIIzOXMV3Ey5w+vxhm0rR/TN8w==",
"dev": true,
"dependencies": {
"color": "^3.1.3",
"text-hex": "1.0.x"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dev": true,
"dependencies": {
"delayed-stream": "~1.0.0"
},
@@ -9498,14 +9886,6 @@
"semver": "bin/semver"
}
},
"node_modules/crypt": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz",
"integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==",
"engines": {
"node": "*"
}
},
"node_modules/css": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/css/-/css-3.0.0.tgz",
@@ -10301,7 +10681,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"dev": true,
"engines": {
"node": ">=0.4.0"
}
@@ -10390,6 +10769,17 @@
"resolved": "https://registry.npmjs.org/diacritics/-/diacritics-1.3.0.tgz",
"integrity": "sha512-wlwEkqcsaxvPJML+rDh/2iS824jbREk6DUMUKkEaSlxdYHeS43cClJtsWglvw2RfeXGm6ohKDqsXteJ5sP5enA=="
},
"node_modules/diagnostics": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/diagnostics/-/diagnostics-1.1.1.tgz",
"integrity": "sha512-8wn1PmdunLJ9Tqbx+Fx/ZEuHfJf4NKSN2ZBj7SJC/OWRWha843+WsTjqMe1B5E3p28jqBlp+mJ2fPVxPyNgYKQ==",
"dev": true,
"dependencies": {
"colorspace": "1.1.x",
"enabled": "1.0.x",
"kuler": "1.0.x"
}
},
"node_modules/diff-sequences": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-27.5.1.tgz",
@@ -10544,9 +10934,9 @@
}
},
"node_modules/dompurify": {
"version": "2.4.3",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.4.3.tgz",
"integrity": "sha512-q6QaLcakcRjebxjg8/+NP+h0rPfatOgOzc46Fst9VAA3jF2ApfKBNKMzdP4DYTqtUMXSCd5pRS/8Po/OmoCHZQ=="
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.3.8.tgz",
"integrity": "sha512-eVhaWoVibIzqdGYjwsBWodIQIaXFSB+cKDf4cfxLMsK0xiud6SE+/WCVx/Xw/UwQsa4cS3T2eITcdtmTg2UKcw=="
},
"node_modules/domutils": {
"version": "2.8.0",
@@ -10753,6 +11143,15 @@
"node": ">= 4"
}
},
"node_modules/enabled": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/enabled/-/enabled-1.0.2.tgz",
"integrity": "sha512-nnzgVSpB35qKrUN8358SjO1bYAmxoThECTWw9s3J0x5G8A9hokKHVDFzBjVpCoSryo6MhN8woVyascN5jheaNA==",
"dev": true,
"dependencies": {
"env-variable": "0.0.x"
}
},
"node_modules/encodeurl": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
@@ -10795,6 +11194,12 @@
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/env-variable": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/env-variable/-/env-variable-0.0.6.tgz",
"integrity": "sha512-bHz59NlBbtS0NhftmR8+ExBEekE7br0e01jw+kk0NDro7TtZzBYZ5ScGPs3OmwnpyfHTHOtr1Y6uedCdrIldtg==",
"dev": true
},
"node_modules/envinfo": {
"version": "7.8.1",
"resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.8.1.tgz",
@@ -10862,6 +11267,23 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/es-check": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/es-check/-/es-check-6.2.1.tgz",
"integrity": "sha512-IPiRXUlwSTd2yMklIf9yEGe6GK5wCS8Sz1aTNHm1QSiYzI4aiq19giYbLi95tb+e0JJVKmcU0iQXQWW60a8V9A==",
"dev": true,
"dependencies": {
"@caporal/core": "^2.0.2",
"acorn": "^8.7.0",
"fast-glob": "^3.2.11"
},
"bin": {
"es-check": "index.js"
},
"engines": {
"node": ">= 4"
}
},
"node_modules/es-module-lexer": {
"version": "0.9.3",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-0.9.3.tgz",
@@ -10894,6 +11316,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/es6-promisify": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/es6-promisify/-/es6-promisify-6.1.1.tgz",
"integrity": "sha512-HBL8I3mIki5C1Cc9QjKUenHtnG0A5/xA8Q/AllRcfiwl2CZFXGK7ddBiCoRwAix4i2KxcQfjtIVcrVbB3vbmwg==",
"dev": true
},
"node_modules/escalade": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz",
@@ -12095,6 +12523,11 @@
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
},
"node_modules/fast-defer": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/fast-defer/-/fast-defer-1.1.7.tgz",
"integrity": "sha512-tJ01ulDWT2WhqxMKS20nXX6wyX2iInBYpbN3GO7yjKwXMY4qvkdBRxak9IFwBLlFDESox+SwSvqMCZDfe1tqeg=="
},
"node_modules/fast-glob": {
"version": "3.2.11",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.11.tgz",
@@ -12122,6 +12555,21 @@
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
"dev": true
},
"node_modules/fast-url-parser": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/fast-url-parser/-/fast-url-parser-1.1.3.tgz",
"integrity": "sha512-5jOCVXADYNuRkKFzNJ0dCCewsZiYo0dz8QNYljkOpFC6r2U4OBmKtvm/Tsuh4w1YYdDqDb31a8TVhBJ2OJKdqQ==",
"dev": true,
"dependencies": {
"punycode": "^1.3.2"
}
},
"node_modules/fast-url-parser/node_modules/punycode": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz",
"integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==",
"dev": true
},
"node_modules/fast-xml-parser": {
"version": "3.21.1",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-3.21.1.tgz",
@@ -12185,6 +12633,12 @@
"pend": "~1.2.0"
}
},
"node_modules/fecha": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz",
"integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==",
"dev": true
},
"node_modules/figures": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz",
@@ -13142,6 +13596,7 @@
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz",
"integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==",
"dev": true,
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
@@ -13850,6 +14305,15 @@
"node": ">= 4"
}
},
"node_modules/ignore-walk": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-3.0.4.tgz",
"integrity": "sha512-PY6Ii8o1jMRA1z4F2hRkH/xN59ox43DavKvD3oDpfurRlOJyAHpifIwpbdv1n4jt4ov0jSpw3kQ4GhJnpBL6WQ==",
"dev": true,
"dependencies": {
"minimatch": "^3.0.4"
}
},
"node_modules/image-webpack-loader": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/image-webpack-loader/-/image-webpack-loader-8.1.0.tgz",
@@ -19078,6 +19542,15 @@
"node": ">= 8"
}
},
"node_modules/kuler": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/kuler/-/kuler-1.0.1.tgz",
"integrity": "sha512-J9nVUucG1p/skKul6DU3PUZrhs0LPulNaeUOox0IyXDi8S4CztTHs1gQphhuZmzXG7VOQSf6NJfKuzteQLv9gQ==",
"dev": true,
"dependencies": {
"colornames": "^1.1.1"
}
},
"node_modules/leven": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
@@ -19267,6 +19740,19 @@
"resolved": "https://registry.npmjs.org/lodash.uniqby/-/lodash.uniqby-4.7.0.tgz",
"integrity": "sha512-e/zcLx6CSbmaEgFHCA7BnoQKyCtKMxnuWrJygbwPs/AIn+IMKl66L8/s+wBUn5LRw2pZx3bUHibiV1b6aTWIww=="
},
"node_modules/logform": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/logform/-/logform-2.4.0.tgz",
"integrity": "sha512-CPSJw4ftjf517EhXZGGvTHHkYobo7ZCc0kvwUoOYcjfR2UVrI66RHj8MCrfAdEitdmFqbu2BYdYs8FHHZSb6iw==",
"dev": true,
"dependencies": {
"@colors/colors": "1.5.0",
"fecha": "^4.2.0",
"ms": "^2.1.1",
"safe-stable-stringify": "^2.3.1",
"triple-beam": "^1.3.0"
}
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -19397,21 +19883,6 @@
"css-mediaquery": "^0.1.2"
}
},
"node_modules/md5": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz",
"integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==",
"dependencies": {
"charenc": "0.0.2",
"crypt": "0.0.2",
"is-buffer": "~1.1.6"
}
},
"node_modules/md5/node_modules/is-buffer": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
"integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w=="
},
"node_modules/mdn-data": {
"version": "2.0.14",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz",
@@ -19747,6 +20218,48 @@
"integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==",
"dev": true
},
"node_modules/node-fetch": {
"version": "2.6.7",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz",
"integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==",
"dev": true,
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/node-fetch/node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"dev": true
},
"node_modules/node-fetch/node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"dev": true
},
"node_modules/node-fetch/node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"dev": true,
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/node-forge": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz",
@@ -19947,6 +20460,11 @@
"node": ">=0.10.0"
}
},
"node_modules/object-code": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/object-code/-/object-code-1.2.4.tgz",
"integrity": "sha512-uGq4ETUuWe+GA586NXEriiaozNuff+YNFXlpD8cVrM1GoiuTZpCABP+bZCWDrvQDoCiSTyiWAFHD/HF/iwhb2w=="
},
"node_modules/object-copy": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/object-copy/-/object-copy-0.1.0.tgz",
@@ -20202,6 +20720,12 @@
"wrappy": "1"
}
},
"node_modules/one-time": {
"version": "0.0.4",
"resolved": "https://registry.npmjs.org/one-time/-/one-time-0.0.4.tgz",
"integrity": "sha512-qAMrwuk2xLEutlASoiPiAMW3EN3K96Ka/ilSXYr6qR1zSVXw2j7+yDSqGTC4T9apfLYxM3tLLjKvgPdAUK7kYQ==",
"dev": true
},
"node_modules/onetime": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz",
@@ -21931,6 +22455,15 @@
"react": "^15.3.0 || ^16.0.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/react-colorful": {
"version": "5.6.1",
"resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.6.1.tgz",
"integrity": "sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw==",
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/react-dev-utils": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-12.0.0.tgz",
@@ -23259,6 +23792,15 @@
"ret": "~0.1.10"
}
},
"node_modules/safe-stable-stringify": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.3.1.tgz",
"integrity": "sha512-kYBSfT+troD9cDA85VDnHZ1rpHC50O0g1e6WlGHVCz/g+JS+9WKLj+XwFYyR8UbrZN8ll9HUpDAAddY58MGisg==",
"dev": true,
"engines": {
"node": ">=10"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
@@ -23886,6 +24428,21 @@
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
"dev": true
},
"node_modules/simple-swizzle": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz",
"integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==",
"dev": true,
"dependencies": {
"is-arrayish": "^0.3.1"
}
},
"node_modules/simple-swizzle/node_modules/is-arrayish": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz",
"integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==",
"dev": true
},
"node_modules/sisteransi": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
@@ -24381,6 +24938,15 @@
"integrity": "sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==",
"dev": true
},
"node_modules/stack-trace": {
"version": "0.0.10",
"resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz",
"integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==",
"dev": true,
"engines": {
"node": "*"
}
},
"node_modules/stack-utils": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.5.tgz",
@@ -24519,6 +25085,15 @@
"node": ">= 0.8"
}
},
"node_modules/stream-events": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz",
"integrity": "sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==",
"dev": true,
"dependencies": {
"stubs": "^3.0.0"
}
},
"node_modules/strict-uri-encode": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz",
@@ -24744,6 +25319,12 @@
"dev": true,
"optional": true
},
"node_modules/stubs": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/stubs/-/stubs-3.0.0.tgz",
"integrity": "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==",
"dev": true
},
"node_modules/style-loader": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/style-loader/-/style-loader-2.0.0.tgz",
@@ -24968,6 +25549,166 @@
"node": ">=6"
}
},
"node_modules/tabtab": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/tabtab/-/tabtab-3.0.2.tgz",
"integrity": "sha512-jANKmUe0sIQc/zTALTBy186PoM/k6aPrh3A7p6AaAfF6WPSbTx1JYeGIGH162btpH+mmVEXln+UxwViZHO2Jhg==",
"dev": true,
"dependencies": {
"debug": "^4.0.1",
"es6-promisify": "^6.0.0",
"inquirer": "^6.0.0",
"minimist": "^1.2.0",
"mkdirp": "^0.5.1",
"untildify": "^3.0.3"
}
},
"node_modules/tabtab/node_modules/ansi-escapes": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-3.2.0.tgz",
"integrity": "sha512-cBhpre4ma+U0T1oM5fXg7Dy1Jw7zzwv7lt/GoCpr+hDQJoYnKVPLL4dCvSEFMmQurOQvSrwT7SL/DAlhBI97RQ==",
"dev": true,
"engines": {
"node": ">=4"
}
},
"node_modules/tabtab/node_modules/ansi-regex": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.1.tgz",
"integrity": "sha512-+O9Jct8wf++lXxxFc4hc8LsjaSq0HFzzL7cVsw8pRDIPdjKD2mT4ytDZlLuSBZ4cLKZFXIrMGO7DbQCtMJJMKw==",
"dev": true,
"engines": {
"node": ">=4"
}
},
"node_modules/tabtab/node_modules/cli-cursor": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz",
"integrity": "sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw==",
"dev": true,
"dependencies": {
"restore-cursor": "^2.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/tabtab/node_modules/cli-width": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/cli-width/-/cli-width-2.2.1.tgz",
"integrity": "sha512-GRMWDxpOB6Dgk2E5Uo+3eEBvtOOlimMmpbFiKuLFnQzYDavtLFY3K5ona41jgN/WdRZtG7utuVSVTL4HbZHGkw==",
"dev": true
},
"node_modules/tabtab/node_modules/figures": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/figures/-/figures-2.0.0.tgz",
"integrity": "sha512-Oa2M9atig69ZkfwiApY8F2Yy+tzMbazyvqv21R0NsSC8floSOC09BbT1ITWAdoMGQvJ/aZnR1KMwdx9tvHnTNA==",
"dev": true,
"dependencies": {
"escape-string-regexp": "^1.0.5"
},
"engines": {
"node": ">=4"
}
},
"node_modules/tabtab/node_modules/inquirer": {
"version": "6.5.2",
"resolved": "https://registry.npmjs.org/inquirer/-/inquirer-6.5.2.tgz",
"integrity": "sha512-cntlB5ghuB0iuO65Ovoi8ogLHiWGs/5yNrtUcKjFhSSiVeAIVpD7koaSU9RM8mpXw5YDi9RdYXGQMaOURB7ycQ==",
"dev": true,
"dependencies": {
"ansi-escapes": "^3.2.0",
"chalk": "^2.4.2",
"cli-cursor": "^2.1.0",
"cli-width": "^2.0.0",
"external-editor": "^3.0.3",
"figures": "^2.0.0",
"lodash": "^4.17.12",
"mute-stream": "0.0.7",
"run-async": "^2.2.0",
"rxjs": "^6.4.0",
"string-width": "^2.1.0",
"strip-ansi": "^5.1.0",
"through": "^2.3.6"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/tabtab/node_modules/is-fullwidth-code-point": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz",
"integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==",
"dev": true,
"engines": {
"node": ">=4"
}
},
"node_modules/tabtab/node_modules/mimic-fn": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz",
"integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==",
"dev": true,
"engines": {
"node": ">=4"
}
},
"node_modules/tabtab/node_modules/mute-stream": {
"version": "0.0.7",
"resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.7.tgz",
"integrity": "sha512-r65nCZhrbXXb6dXOACihYApHw2Q6pV0M3V0PSxd74N0+D8nzAdEAITq2oAjA1jVnKI+tGvEBUpqiMh0+rW6zDQ==",
"dev": true
},
"node_modules/tabtab/node_modules/onetime": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/onetime/-/onetime-2.0.1.tgz",
"integrity": "sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ==",
"dev": true,
"dependencies": {
"mimic-fn": "^1.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/tabtab/node_modules/restore-cursor": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-2.0.0.tgz",
"integrity": "sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==",
"dev": true,
"dependencies": {
"onetime": "^2.0.0",
"signal-exit": "^3.0.2"
},
"engines": {
"node": ">=4"
}
},
"node_modules/tabtab/node_modules/string-width": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz",
"integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==",
"dev": true,
"dependencies": {
"is-fullwidth-code-point": "^2.0.0",
"strip-ansi": "^4.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/tabtab/node_modules/string-width/node_modules/strip-ansi": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz",
"integrity": "sha512-4XaJ2zQdCzROZDivEVIDPkcQn8LMFSa8kj8Gxb/Lnwzv9A8VctNZ+lfivC/sV3ivW8ElJTERXZoPBRrZKkNKow==",
"dev": true,
"dependencies": {
"ansi-regex": "^3.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/tapable": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz",
@@ -24995,6 +25736,22 @@
"node": ">= 0.8.0"
}
},
"node_modules/teeny-request": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-7.1.1.tgz",
"integrity": "sha512-iwY6rkW5DDGq8hE2YgNQlKbptYpY5Nn2xecjQiNjOXWbKzPGUfmeUBCSQbbr306d7Z7U2N0TPl+/SwYRfua1Dg==",
"dev": true,
"dependencies": {
"http-proxy-agent": "^4.0.0",
"https-proxy-agent": "^5.0.0",
"node-fetch": "^2.6.1",
"stream-events": "^1.0.5",
"uuid": "^8.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/temp-dir": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-1.0.0.tgz",
@@ -25115,6 +25872,12 @@
"node": ">=8"
}
},
"node_modules/text-hex": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz",
"integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==",
"dev": true
},
"node_modules/text-table": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
@@ -25322,6 +26085,12 @@
"node": ">=0.10.0"
}
},
"node_modules/triple-beam": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.3.0.tgz",
"integrity": "sha512-XrHUvV5HpdLmIj4uVMxHggLbFSZYIn7HEWsqePZcI50pco+MPqJ50wMGY794X7AOOhxOBAjbkqfAbEe/QMp2Lw==",
"dev": true
},
"node_modules/tryer": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/tryer/-/tryer-1.0.1.tgz",
@@ -25632,6 +26401,15 @@
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"dev": true
},
"node_modules/untildify": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/untildify/-/untildify-3.0.3.tgz",
"integrity": "sha512-iSk/J8efr8uPT/Z4eSUywnqyrQU7DSdMfdqK4iWEaUVVmcP5JcnpRqmVMwcwcnmI1ATFNgC5V90u09tBynNFKA==",
"dev": true,
"engines": {
"node": ">=4"
}
},
"node_modules/uri-js": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
@@ -25697,6 +26475,15 @@
"node": ">= 4"
}
},
"node_modules/urlgrey": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/urlgrey/-/urlgrey-1.0.0.tgz",
"integrity": "sha512-hJfIzMPJmI9IlLkby8QrsCykQ+SXDeO2W5Q9QTW3QpqZVTx4a/K7p8/5q+/isD8vsbVaFgql/gvAoQCRQ2Cb5w==",
"dev": true,
"dependencies": {
"fast-url-parser": "^1.1.3"
}
},
"node_modules/use": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz",
@@ -26655,6 +27442,68 @@
"integrity": "sha512-JcKqAHLPxcdb9KM49dufGXn2x3ssnfjbcaQdLlfZsL9rH9wgDQjUtDxbo8NE0F6SFvydeu1VhZe7hZuHsB2/pw==",
"dev": true
},
"node_modules/winston": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/winston/-/winston-3.2.1.tgz",
"integrity": "sha512-zU6vgnS9dAWCEKg/QYigd6cgMVVNwyTzKs81XZtTFuRwJOcDdBg7AU0mXVyNbs7O5RH2zdv+BdNZUlx7mXPuOw==",
"dev": true,
"dependencies": {
"async": "^2.6.1",
"diagnostics": "^1.1.1",
"is-stream": "^1.1.0",
"logform": "^2.1.1",
"one-time": "0.0.4",
"readable-stream": "^3.1.1",
"stack-trace": "0.0.x",
"triple-beam": "^1.3.0",
"winston-transport": "^4.3.0"
},
"engines": {
"node": ">= 6.4.0"
}
},
"node_modules/winston-transport": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.5.0.tgz",
"integrity": "sha512-YpZzcUzBedhlTAfJg6vJDlyEai/IFMIVcaEZZyl3UXIl4gmqRpU7AE89AHLkbzLUsv0NVmw7ts+iztqKxxPW1Q==",
"dev": true,
"dependencies": {
"logform": "^2.3.2",
"readable-stream": "^3.6.0",
"triple-beam": "^1.3.0"
},
"engines": {
"node": ">= 6.4.0"
}
},
"node_modules/winston-transport/node_modules/readable-stream": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz",
"integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==",
"dev": true,
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/winston/node_modules/readable-stream": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz",
"integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==",
"dev": true,
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/word-wrap": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz",
@@ -28120,6 +28969,90 @@
"integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==",
"dev": true
},
"@caporal/core": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@caporal/core/-/core-2.0.2.tgz",
"integrity": "sha512-o3J5aZINFWkkL+sL0DUs1dPHJjaetAAdwMRLbJ4U8aJW3K81E323IFMkFNYcOwTiPVhNzllC3USxZbU6xWFjFg==",
"dev": true,
"requires": {
"@types/glob": "^7.1.1",
"@types/lodash": "4.14.149",
"@types/node": "13.9.3",
"@types/table": "5.0.0",
"@types/tabtab": "^3.0.1",
"@types/wrap-ansi": "^3.0.0",
"chalk": "3.0.0",
"glob": "^7.1.6",
"lodash": "4.17.15",
"table": "5.4.6",
"tabtab": "^3.0.2",
"winston": "3.2.1",
"wrap-ansi": "^6.2.0"
},
"dependencies": {
"@types/node": {
"version": "13.9.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-13.9.3.tgz",
"integrity": "sha512-01s+ac4qerwd6RHD+mVbOEsraDHSgUaefQlEdBbUolnQFjKwCr7luvAlEwW1RFojh67u0z4OUTjPn9LEl4zIkA==",
"dev": true
},
"ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"requires": {
"color-convert": "^2.0.1"
}
},
"chalk": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz",
"integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==",
"dev": true,
"requires": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
}
},
"color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"requires": {
"color-name": "~1.1.4"
}
},
"color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true
},
"has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true
},
"lodash": {
"version": "4.17.15",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz",
"integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==",
"dev": true
},
"supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"requires": {
"has-flag": "^4.0.0"
}
}
}
},
"@cnakazawa/watch": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@cnakazawa/watch/-/watch-1.0.4.tgz",
@@ -28130,6 +29063,12 @@
"minimist": "^1.2.0"
}
},
"@colors/colors": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz",
"integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==",
"dev": true
},
"@cospired/i18n-iso-languages": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@cospired/i18n-iso-languages/-/i18n-iso-languages-2.2.0.tgz",
@@ -29262,17 +30201,30 @@
}
},
"@edx/frontend-component-footer": {
"version": "11.2.0",
"resolved": "https://registry.npmjs.org/@edx/frontend-component-footer/-/frontend-component-footer-11.2.0.tgz",
"integrity": "sha512-prN6SeoWenbNq6jCqlpmYx57OhNbVGw1wjQFVH+aA5JMjjLUf6PmjPG87fuD9udQyFteb2lvbCyongPEZOozrg==",
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/@edx/frontend-component-footer/-/frontend-component-footer-12.0.0.tgz",
"integrity": "sha512-m8Rx6ZPWzIN5XLrz6Ft3aTuFo0rty0jECd79CBYWdm0D9KD1WxoYEG+fElluyOQp/t42T5jLImHTSWjFURx5kw==",
"requires": {
"@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/fontawesome-svg-core": "6.4.0",
"@fortawesome/free-brands-svg-icons": "6.4.0",
"@fortawesome/free-regular-svg-icons": "6.4.0",
"@fortawesome/free-solid-svg-icons": "6.4.0",
"@fortawesome/react-fontawesome": "0.2.0"
},
"dependencies": {
"@fortawesome/fontawesome-common-types": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.4.0.tgz",
"integrity": "sha512-HNii132xfomg5QVZw0HwXXpN22s7VBHQBv9CeOu9tfJnhsWQNd2lmTNi8CSrnw5B+5YOmzu1UoPAyxaXsJ6RgQ=="
},
"@fortawesome/fontawesome-svg-core": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.4.0.tgz",
"integrity": "sha512-Bertv8xOiVELz5raB2FlXDPKt+m94MQ3JgDfsVbrqNpLU9+UE2E18GKjLKw+d3XbeYPqg1pzyQKGsrzbw+pPaw==",
"requires": {
"@fortawesome/fontawesome-common-types": "6.4.0"
}
},
"@fortawesome/react-fontawesome": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-0.2.0.tgz",
@@ -29284,20 +30236,87 @@
}
},
"@edx/frontend-component-header": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@edx/frontend-component-header/-/frontend-component-header-3.2.0.tgz",
"integrity": "sha512-ep9yOwe9CG03lSf/QBsBOZO3S/Ksrttgi4MkjpUe/6Sm/KdAnMCOhx2Gbj9jBcFrHqCkpeef26zGjjhpHGDwkQ==",
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@edx/frontend-component-header/-/frontend-component-header-4.0.0.tgz",
"integrity": "sha512-r/L3p2ZSI1DitjxVKAor18GmgJllafYslrdpzGI0vcX/gTemH13jf2Xr9iQqrT921DP2nzZ5GOwGJNptTSjiaA==",
"requires": {
"@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",
"@edx/paragon": "20.30.1",
"@fortawesome/fontawesome-svg-core": "6.3.0",
"@fortawesome/free-brands-svg-icons": "6.3.0",
"@fortawesome/free-regular-svg-icons": "6.3.0",
"@fortawesome/free-solid-svg-icons": "6.3.0",
"@fortawesome/react-fontawesome": "^0.2.0",
"babel-polyfill": "6.26.0",
"react-responsive": "8.2.0",
"react-transition-group": "4.4.5"
},
"dependencies": {
"@edx/paragon": {
"version": "20.30.1",
"resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-20.30.1.tgz",
"integrity": "sha512-v3Ek8deZWqVKi3IWP08Mj4egrvbmbqQEyRA6+qazHZdgHJA4qOP1SST42UKd9XxPeRbLWUgaJWd0iBAOAna/gw==",
"requires": {
"@fortawesome/fontawesome-svg-core": "^6.1.1",
"@fortawesome/react-fontawesome": "^0.1.18",
"@popperjs/core": "^2.11.4",
"bootstrap": "^4.6.2",
"classnames": "^2.3.1",
"email-prop-type": "^3.0.0",
"file-selector": "^0.6.0",
"font-awesome": "^4.7.0",
"glob": "^8.0.3",
"lodash.uniqby": "^4.7.0",
"mailto-link": "^2.0.0",
"prop-types": "^15.8.1",
"react-bootstrap": "^1.6.5",
"react-colorful": "^5.6.1",
"react-dropzone": "^14.2.1",
"react-focus-on": "^3.5.4",
"react-loading-skeleton": "^3.1.0",
"react-popper": "^2.2.5",
"react-proptype-conditional-require": "^1.0.4",
"react-responsive": "^8.2.0",
"react-table": "^7.7.0",
"react-transition-group": "^4.4.2",
"tabbable": "^5.3.3",
"uncontrollable": "^7.2.1",
"uuid": "^9.0.0"
},
"dependencies": {
"@fortawesome/react-fontawesome": {
"version": "0.1.19",
"resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-0.1.19.tgz",
"integrity": "sha512-Hyb+lB8T18cvLNX0S3llz7PcSOAJMLwiVKBuuzwM/nI5uoBw+gQjnf9il0fR1C3DKOI5Kc79pkJ4/xB0Uw9aFQ==",
"requires": {
"prop-types": "^15.8.1"
}
}
}
},
"@fortawesome/free-brands-svg-icons": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-6.3.0.tgz",
"integrity": "sha512-xI0c+a8xnKItAXCN8rZgCNCJQiVAd2Y7p9e2ND6zN3J3ekneu96qrePieJ7yA7073C1JxxoM3vH1RU7rYsaj8w==",
"requires": {
"@fortawesome/fontawesome-common-types": "6.3.0"
}
},
"@fortawesome/free-regular-svg-icons": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-6.3.0.tgz",
"integrity": "sha512-cZnwiVHZ51SVzWHOaNCIA+u9wevZjCuAGSvSYpNlm6A4H4Vhwh8481Bf/5rwheIC3fFKlgXxLKaw8Xeroz8Ntg==",
"requires": {
"@fortawesome/fontawesome-common-types": "6.3.0"
}
},
"@fortawesome/free-solid-svg-icons": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.3.0.tgz",
"integrity": "sha512-x5tMwzF2lTH8pyv8yeZRodItP2IVlzzmBuD1M7BjawWgg9XAvktqJJ91Qjgoaf8qJpHQ8FEU9VxRfOkLhh86QA==",
"requires": {
"@fortawesome/fontawesome-common-types": "6.3.0"
}
},
"@fortawesome/react-fontawesome": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-0.2.0.tgz",
@@ -29305,21 +30324,54 @@
"requires": {
"prop-types": "^15.8.1"
}
},
"brace-expansion": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
"requires": {
"balanced-match": "^1.0.0"
}
},
"glob": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz",
"integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==",
"requires": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^5.0.1",
"once": "^1.3.0"
}
},
"minimatch": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
"requires": {
"brace-expansion": "^2.0.1"
}
},
"uuid": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz",
"integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg=="
}
}
},
"@edx/frontend-platform": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/@edx/frontend-platform/-/frontend-platform-2.6.1.tgz",
"integrity": "sha512-5ZcHBvwmJRYPPKEqv+H4DbsfW9atr5V2+uY/zX58F/Tqh0T5X8MmgppVjplWFbmon5QV4aNw5C1ViQnirNqUiA==",
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@edx/frontend-platform/-/frontend-platform-4.2.0.tgz",
"integrity": "sha512-iDoFeccENQKBjqUgdjl5KSwBrjNEj8YW6Ual+6twcHHJUBg3yRoBEphwHIoRREcMgQjhdKVAdWj8eleh4JsEKA==",
"requires": {
"@cospired/i18n-iso-languages": "2.2.0",
"@formatjs/intl-pluralrules": "4.3.3",
"@formatjs/intl-relativetimeformat": "10.0.1",
"axios": "0.26.1",
"axios-cache-adapter": "2.7.3",
"axios": "0.27.2",
"axios-cache-interceptor": "0.10.7",
"form-urlencoded": "4.1.4",
"glob": "7.2.0",
"glob": "7.2.3",
"history": "4.10.1",
"i18n-iso-countries": "4.3.1",
"jwt-decode": "3.1.2",
@@ -29335,11 +30387,35 @@
},
"dependencies": {
"axios": {
"version": "0.26.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.26.1.tgz",
"integrity": "sha512-fPwcX4EvnSHuInCMItEhAGnaSEXRBjtzh9fOtsE6E1G6p7vl7edEeZe11QHf18+6+9gR5PbKV/sGKNaD8YaMeA==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.27.2.tgz",
"integrity": "sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==",
"requires": {
"follow-redirects": "^1.14.8"
"follow-redirects": "^1.14.9",
"form-data": "^4.0.0"
}
},
"form-data": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
"requires": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"mime-types": "^2.1.12"
}
},
"glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"requires": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
}
}
}
@@ -29383,19 +30459,6 @@
"uncontrollable": "^7.2.1"
},
"dependencies": {
"@fortawesome/fontawesome-common-types": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.2.0.tgz",
"integrity": "sha512-rBevIsj2nclStJ7AxTdfsa3ovHb1H+qApwrxcTVo+NNdeJiB9V75hsKfrkG5AwNcRUNxrPPiScGYCNmLMoh8pg=="
},
"@fortawesome/fontawesome-svg-core": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.2.0.tgz",
"integrity": "sha512-Cf2mAAeMWFMzpLC7Y9H1I4o3wEU+XovVJhTiNG8ZNgSQj53yl7OCJaS80K4YjrABWZzbAHVaoHE1dVJ27AAYXw==",
"requires": {
"@fortawesome/fontawesome-common-types": "6.2.0"
}
},
"brace-expansion": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
@@ -29719,40 +30782,61 @@
}
},
"@fortawesome/fontawesome-common-types": {
"version": "0.2.36",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-0.2.36.tgz",
"integrity": "sha512-a/7BiSgobHAgBWeN7N0w+lAhInrGxksn13uK7231n2m8EDPE3BMCl9NZLTGrj9ZXfCmC6LM0QLqXidIizVQ6yg=="
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.3.0.tgz",
"integrity": "sha512-4BC1NMoacEBzSXRwKjZ/X/gmnbp/HU5Qqat7E8xqorUtBFZS+bwfGH5/wqOC2K6GV0rgEobp3OjGRMa5fK9pFg=="
},
"@fortawesome/fontawesome-svg-core": {
"version": "1.2.36",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-1.2.36.tgz",
"integrity": "sha512-YUcsLQKYb6DmaJjIHdDWpBIGCcyE/W+p/LMGvjQem55Mm2XWVAP5kWTMKWLv9lwpCVjpLxPyOMOyUocP1GxrtA==",
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.3.0.tgz",
"integrity": "sha512-uz9YifyKlixV6AcKlOX8WNdtF7l6nakGyLYxYaCa823bEBqyj/U2ssqtctO38itNEwXb8/lMzjdoJ+aaJuOdrw==",
"requires": {
"@fortawesome/fontawesome-common-types": "^0.2.36"
"@fortawesome/fontawesome-common-types": "6.3.0"
}
},
"@fortawesome/free-brands-svg-icons": {
"version": "5.15.4",
"resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-5.15.4.tgz",
"integrity": "sha512-f1witbwycL9cTENJegcmcZRYyawAFbm8+c6IirLmwbbpqz46wyjbQYLuxOc7weXFXfB7QR8/Vd2u5R3q6JYD9g==",
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-6.4.0.tgz",
"integrity": "sha512-qvxTCo0FQ5k2N+VCXb/PZQ+QMhqRVM4OORiO6MXdG6bKolIojGU/srQ1ptvKk0JTbRgaJOfL2qMqGvBEZG7Z6g==",
"requires": {
"@fortawesome/fontawesome-common-types": "^0.2.36"
"@fortawesome/fontawesome-common-types": "6.4.0"
},
"dependencies": {
"@fortawesome/fontawesome-common-types": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.4.0.tgz",
"integrity": "sha512-HNii132xfomg5QVZw0HwXXpN22s7VBHQBv9CeOu9tfJnhsWQNd2lmTNi8CSrnw5B+5YOmzu1UoPAyxaXsJ6RgQ=="
}
}
},
"@fortawesome/free-regular-svg-icons": {
"version": "5.15.4",
"resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-5.15.4.tgz",
"integrity": "sha512-9VNNnU3CXHy9XednJ3wzQp6SwNwT3XaM26oS4Rp391GsxVYA+0oDR2J194YCIWf7jNRCYKjUCOduxdceLrx+xw==",
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-6.4.0.tgz",
"integrity": "sha512-ZfycI7D0KWPZtf7wtMFnQxs8qjBXArRzczABuMQqecA/nXohquJ5J/RCR77PmY5qGWkxAZDxpnUFVXKwtY/jPw==",
"requires": {
"@fortawesome/fontawesome-common-types": "^0.2.36"
"@fortawesome/fontawesome-common-types": "6.4.0"
},
"dependencies": {
"@fortawesome/fontawesome-common-types": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.4.0.tgz",
"integrity": "sha512-HNii132xfomg5QVZw0HwXXpN22s7VBHQBv9CeOu9tfJnhsWQNd2lmTNi8CSrnw5B+5YOmzu1UoPAyxaXsJ6RgQ=="
}
}
},
"@fortawesome/free-solid-svg-icons": {
"version": "5.15.4",
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-5.15.4.tgz",
"integrity": "sha512-JLmQfz6tdtwxoihXLg6lT78BorrFyCf59SAwBM6qV/0zXyVeDygJVb3fk+j5Qat+Yvcxp1buLTY5iDh1ZSAQ8w==",
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.4.0.tgz",
"integrity": "sha512-kutPeRGWm8V5dltFP1zGjQOEAzaLZj4StdQhWVZnfGFCvAPVvHh8qk5bRrU4KXnRRRNni5tKQI9PBAdI6MP8nQ==",
"requires": {
"@fortawesome/fontawesome-common-types": "^0.2.36"
"@fortawesome/fontawesome-common-types": "6.4.0"
},
"dependencies": {
"@fortawesome/fontawesome-common-types": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.4.0.tgz",
"integrity": "sha512-HNii132xfomg5QVZw0HwXXpN22s7VBHQBv9CeOu9tfJnhsWQNd2lmTNi8CSrnw5B+5YOmzu1UoPAyxaXsJ6RgQ=="
}
}
},
"@fortawesome/react-fontawesome": {
@@ -31711,6 +32795,12 @@
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
"dev": true
},
"@types/lodash": {
"version": "4.14.149",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.149.tgz",
"integrity": "sha512-ijGqzZt/b7BfzcK9vTrS6MFljQRPn5BFWOx8oE0GYxribu6uV+aA9zZuXI1zc/etK9E8nrgdoF2+LgUw7+9tJQ==",
"dev": true
},
"@types/mime": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz",
@@ -31861,6 +32951,21 @@
"integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==",
"dev": true
},
"@types/table": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@types/table/-/table-5.0.0.tgz",
"integrity": "sha512-fQLtGLZXor264zUPWI95WNDsZ3QV43/c0lJpR/h1hhLJumXRmHNsrvBfEzW2YMhb0EWCsn4U6h82IgwsajAuTA==",
"dev": true
},
"@types/tabtab": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/tabtab/-/tabtab-3.0.2.tgz",
"integrity": "sha512-d8aOSJPS3SEGZevyr7vbAVUNPWGFmdFlk13vbPPK87vz+gYGM57L8T11k4wK2mOgQYZjEVYQEqmCTvupPoQBWw==",
"dev": true,
"requires": {
"@types/node": "*"
}
},
"@types/tapable": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/tapable/-/tapable-1.0.8.tgz",
@@ -31939,6 +33044,12 @@
}
}
},
"@types/wrap-ansi": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@types/wrap-ansi/-/wrap-ansi-3.0.0.tgz",
"integrity": "sha512-ltIpx+kM7g/MLRZfkbL7EsCEjfzCcScLpkg37eXEtx5kmrAKBkTJwd1GIAjDSL8wTpM6Hzn5YO4pSb91BEwu1g==",
"dev": true
},
"@types/ws": {
"version": "8.5.3",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.3.tgz",
@@ -32341,6 +33452,12 @@
"sprintf-js": "~1.0.2"
}
},
"argv": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/argv/-/argv-0.0.2.tgz",
"integrity": "sha512-dEamhpPEwRUBpLNHeuCm/v+g0anFByHahxodVO/BbAarHVBBg2MccCwf9K+o1Pof+2btdnkJelYVUWjW/VrATw==",
"dev": true
},
"aria-hidden": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.1.3.tgz",
@@ -32466,8 +33583,7 @@
"asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"dev": true
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
},
"at-least-node": {
"version": "1.0.0",
@@ -32504,17 +33620,19 @@
"version": "0.21.4",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz",
"integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==",
"dev": true,
"requires": {
"follow-redirects": "^1.14.0"
}
},
"axios-cache-adapter": {
"version": "2.7.3",
"resolved": "https://registry.npmjs.org/axios-cache-adapter/-/axios-cache-adapter-2.7.3.tgz",
"integrity": "sha512-A+ZKJ9lhpjthOEp4Z3QR/a9xC4du1ALaAsejgRGrH9ef6kSDxdFrhRpulqsh9khsEnwXxGfgpUuDp1YXMNMEiQ==",
"axios-cache-interceptor": {
"version": "0.10.7",
"resolved": "https://registry.npmjs.org/axios-cache-interceptor/-/axios-cache-interceptor-0.10.7.tgz",
"integrity": "sha512-UjpxChG5DpF6Kf1IPGMLOzRDNL8ZNS6TOn1jTaVvCE7cWFU904jJwi0T1s+IbijpnLEjK2iq5uLIuR8Sj+RsFQ==",
"requires": {
"cache-control-esm": "1.0.0",
"md5": "^2.2.1"
"cache-parser": "^1.2.4",
"fast-defer": "^1.1.7",
"object-code": "^1.2.4"
}
},
"axios-mock-adapter": {
@@ -33434,10 +34552,10 @@
"unset-value": "^1.0.0"
}
},
"cache-control-esm": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/cache-control-esm/-/cache-control-esm-1.0.0.tgz",
"integrity": "sha512-Fa3UV4+eIk4EOih8FTV6EEsVKO0W5XWtNs6FC3InTfVz+EjurjPfDXY5wZDo/lxjDxg5RjNcurLyxEJBcEUx9g=="
"cache-parser": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/cache-parser/-/cache-parser-1.2.4.tgz",
"integrity": "sha512-O0KwuHuJnbHUrghHi2kGp0SxnWSIBXTYt7M8WVhW0kbPRUNUKoE/Of6e1rRD6AAxmfxFunKnt90yEK09D+sc5g=="
},
"cacheable-request": {
"version": "2.1.4",
@@ -33622,11 +34740,6 @@
"integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==",
"dev": true
},
"charenc": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz",
"integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA=="
},
"check-types": {
"version": "8.0.3",
"resolved": "https://registry.npmjs.org/check-types/-/check-types-8.0.3.tgz",
@@ -33889,6 +35002,19 @@
"integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==",
"dev": true
},
"codecov": {
"version": "3.8.3",
"resolved": "https://registry.npmjs.org/codecov/-/codecov-3.8.3.tgz",
"integrity": "sha512-Y8Hw+V3HgR7V71xWH2vQ9lyS358CbGCldWlJFR0JirqoGtOoas3R3/OclRTvgUYFK29mmJICDPauVKmpqbwhOA==",
"dev": true,
"requires": {
"argv": "0.0.2",
"ignore-walk": "3.0.4",
"js-yaml": "3.14.1",
"teeny-request": "7.1.1",
"urlgrey": "1.0.0"
}
},
"collect-v8-coverage": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.1.tgz",
@@ -33905,6 +35031,16 @@
"object-visit": "^1.0.0"
}
},
"color": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/color/-/color-3.2.1.tgz",
"integrity": "sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==",
"dev": true,
"requires": {
"color-convert": "^1.9.3",
"color-string": "^1.6.0"
}
},
"color-convert": {
"version": "1.9.3",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
@@ -33920,6 +35056,16 @@
"integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==",
"dev": true
},
"color-string": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
"integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
"dev": true,
"requires": {
"color-name": "^1.0.0",
"simple-swizzle": "^0.2.2"
}
},
"colord": {
"version": "2.9.2",
"resolved": "https://registry.npmjs.org/colord/-/colord-2.9.2.tgz",
@@ -33932,11 +35078,26 @@
"integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==",
"dev": true
},
"colornames": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/colornames/-/colornames-1.1.1.tgz",
"integrity": "sha512-/pyV40IrsdulWv+wFPmERh9k/mjsPZ64yUMDmWrtj/k1nmgrzzIENWKdaVKyBbvFdQWqkcaRxr+polCo3VMe7A==",
"dev": true
},
"colorspace": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/colorspace/-/colorspace-1.1.4.tgz",
"integrity": "sha512-BgvKJiuVu1igBUF2kEjRCZXol6wiiGbY5ipL/oVPwm0BL9sIpMIzM8IK7vwuxIIzOXMV3Ey5w+vxhm0rR/TN8w==",
"dev": true,
"requires": {
"color": "^3.1.3",
"text-hex": "1.0.x"
}
},
"combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dev": true,
"requires": {
"delayed-stream": "~1.0.0"
}
@@ -34170,11 +35331,6 @@
}
}
},
"crypt": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz",
"integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow=="
},
"css": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/css/-/css-3.0.0.tgz",
@@ -34778,8 +35934,7 @@
"delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"dev": true
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="
},
"depd": {
"version": "2.0.0",
@@ -34847,6 +36002,17 @@
"resolved": "https://registry.npmjs.org/diacritics/-/diacritics-1.3.0.tgz",
"integrity": "sha512-wlwEkqcsaxvPJML+rDh/2iS824jbREk6DUMUKkEaSlxdYHeS43cClJtsWglvw2RfeXGm6ohKDqsXteJ5sP5enA=="
},
"diagnostics": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/diagnostics/-/diagnostics-1.1.1.tgz",
"integrity": "sha512-8wn1PmdunLJ9Tqbx+Fx/ZEuHfJf4NKSN2ZBj7SJC/OWRWha843+WsTjqMe1B5E3p28jqBlp+mJ2fPVxPyNgYKQ==",
"dev": true,
"requires": {
"colorspace": "1.1.x",
"enabled": "1.0.x",
"kuler": "1.0.x"
}
},
"diff-sequences": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-27.5.1.tgz",
@@ -34972,9 +36138,9 @@
}
},
"dompurify": {
"version": "2.4.3",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.4.3.tgz",
"integrity": "sha512-q6QaLcakcRjebxjg8/+NP+h0rPfatOgOzc46Fst9VAA3jF2ApfKBNKMzdP4DYTqtUMXSCd5pRS/8Po/OmoCHZQ=="
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.3.8.tgz",
"integrity": "sha512-eVhaWoVibIzqdGYjwsBWodIQIaXFSB+cKDf4cfxLMsK0xiud6SE+/WCVx/Xw/UwQsa4cS3T2eITcdtmTg2UKcw=="
},
"domutils": {
"version": "2.8.0",
@@ -35142,6 +36308,15 @@
"resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz",
"integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q=="
},
"enabled": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/enabled/-/enabled-1.0.2.tgz",
"integrity": "sha512-nnzgVSpB35qKrUN8358SjO1bYAmxoThECTWw9s3J0x5G8A9hokKHVDFzBjVpCoSryo6MhN8woVyascN5jheaNA==",
"dev": true,
"requires": {
"env-variable": "0.0.x"
}
},
"encodeurl": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
@@ -35172,6 +36347,12 @@
"integrity": "sha512-WiyBqoomrwMdFG1e0kqvASYfnlb0lp8M5o5Fw2OFq1hNZxxcNk8Ik0Xm7LxzBhuidnZB/UtBqVCgUz3kBOP51Q==",
"dev": true
},
"env-variable": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/env-variable/-/env-variable-0.0.6.tgz",
"integrity": "sha512-bHz59NlBbtS0NhftmR8+ExBEekE7br0e01jw+kk0NDro7TtZzBYZ5ScGPs3OmwnpyfHTHOtr1Y6uedCdrIldtg==",
"dev": true
},
"envinfo": {
"version": "7.8.1",
"resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.8.1.tgz",
@@ -35227,6 +36408,17 @@
"unbox-primitive": "^1.0.2"
}
},
"es-check": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/es-check/-/es-check-6.2.1.tgz",
"integrity": "sha512-IPiRXUlwSTd2yMklIf9yEGe6GK5wCS8Sz1aTNHm1QSiYzI4aiq19giYbLi95tb+e0JJVKmcU0iQXQWW60a8V9A==",
"dev": true,
"requires": {
"@caporal/core": "^2.0.2",
"acorn": "^8.7.0",
"fast-glob": "^3.2.11"
}
},
"es-module-lexer": {
"version": "0.9.3",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-0.9.3.tgz",
@@ -35253,6 +36445,12 @@
"is-symbol": "^1.0.2"
}
},
"es6-promisify": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/es6-promisify/-/es6-promisify-6.1.1.tgz",
"integrity": "sha512-HBL8I3mIki5C1Cc9QjKUenHtnG0A5/xA8Q/AllRcfiwl2CZFXGK7ddBiCoRwAix4i2KxcQfjtIVcrVbB3vbmwg==",
"dev": true
},
"escalade": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz",
@@ -36202,6 +37400,11 @@
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
},
"fast-defer": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/fast-defer/-/fast-defer-1.1.7.tgz",
"integrity": "sha512-tJ01ulDWT2WhqxMKS20nXX6wyX2iInBYpbN3GO7yjKwXMY4qvkdBRxak9IFwBLlFDESox+SwSvqMCZDfe1tqeg=="
},
"fast-glob": {
"version": "3.2.11",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.11.tgz",
@@ -36226,6 +37429,23 @@
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
"dev": true
},
"fast-url-parser": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/fast-url-parser/-/fast-url-parser-1.1.3.tgz",
"integrity": "sha512-5jOCVXADYNuRkKFzNJ0dCCewsZiYo0dz8QNYljkOpFC6r2U4OBmKtvm/Tsuh4w1YYdDqDb31a8TVhBJ2OJKdqQ==",
"dev": true,
"requires": {
"punycode": "^1.3.2"
},
"dependencies": {
"punycode": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz",
"integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==",
"dev": true
}
}
},
"fast-xml-parser": {
"version": "3.21.1",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-3.21.1.tgz",
@@ -36279,6 +37499,12 @@
"pend": "~1.2.0"
}
},
"fecha": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz",
"integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==",
"dev": true
},
"figures": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz",
@@ -36962,6 +38188,7 @@
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz",
"integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==",
"dev": true,
"requires": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
@@ -37493,6 +38720,15 @@
"integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==",
"dev": true
},
"ignore-walk": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-3.0.4.tgz",
"integrity": "sha512-PY6Ii8o1jMRA1z4F2hRkH/xN59ox43DavKvD3oDpfurRlOJyAHpifIwpbdv1n4jt4ov0jSpw3kQ4GhJnpBL6WQ==",
"dev": true,
"requires": {
"minimatch": "^3.0.4"
}
},
"image-webpack-loader": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/image-webpack-loader/-/image-webpack-loader-8.1.0.tgz",
@@ -41452,6 +42688,15 @@
"integrity": "sha512-pJiBpiXMbt7dkzXe8Ghj/u4FfXOOa98fPW+bihOJ4SjnoijweJrNThJfd3ifXpXhREjpoF2mZVH1GfS9LV3kHQ==",
"dev": true
},
"kuler": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/kuler/-/kuler-1.0.1.tgz",
"integrity": "sha512-J9nVUucG1p/skKul6DU3PUZrhs0LPulNaeUOox0IyXDi8S4CztTHs1gQphhuZmzXG7VOQSf6NJfKuzteQLv9gQ==",
"dev": true,
"requires": {
"colornames": "^1.1.1"
}
},
"leven": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
@@ -41610,6 +42855,19 @@
"resolved": "https://registry.npmjs.org/lodash.uniqby/-/lodash.uniqby-4.7.0.tgz",
"integrity": "sha512-e/zcLx6CSbmaEgFHCA7BnoQKyCtKMxnuWrJygbwPs/AIn+IMKl66L8/s+wBUn5LRw2pZx3bUHibiV1b6aTWIww=="
},
"logform": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/logform/-/logform-2.4.0.tgz",
"integrity": "sha512-CPSJw4ftjf517EhXZGGvTHHkYobo7ZCc0kvwUoOYcjfR2UVrI66RHj8MCrfAdEitdmFqbu2BYdYs8FHHZSb6iw==",
"dev": true,
"requires": {
"@colors/colors": "1.5.0",
"fecha": "^4.2.0",
"ms": "^2.1.1",
"safe-stable-stringify": "^2.3.1",
"triple-beam": "^1.3.0"
}
},
"loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -41720,23 +42978,6 @@
"css-mediaquery": "^0.1.2"
}
},
"md5": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz",
"integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==",
"requires": {
"charenc": "0.0.2",
"crypt": "0.0.2",
"is-buffer": "~1.1.6"
},
"dependencies": {
"is-buffer": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
"integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w=="
}
}
},
"mdn-data": {
"version": "2.0.14",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz",
@@ -41993,6 +43234,39 @@
}
}
},
"node-fetch": {
"version": "2.6.7",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz",
"integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==",
"dev": true,
"requires": {
"whatwg-url": "^5.0.0"
},
"dependencies": {
"tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"dev": true
},
"webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"dev": true
},
"whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"dev": true,
"requires": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
}
}
},
"node-forge": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz",
@@ -42151,6 +43425,11 @@
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="
},
"object-code": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/object-code/-/object-code-1.2.4.tgz",
"integrity": "sha512-uGq4ETUuWe+GA586NXEriiaozNuff+YNFXlpD8cVrM1GoiuTZpCABP+bZCWDrvQDoCiSTyiWAFHD/HF/iwhb2w=="
},
"object-copy": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/object-copy/-/object-copy-0.1.0.tgz",
@@ -42344,6 +43623,12 @@
"wrappy": "1"
}
},
"one-time": {
"version": "0.0.4",
"resolved": "https://registry.npmjs.org/one-time/-/one-time-0.0.4.tgz",
"integrity": "sha512-qAMrwuk2xLEutlASoiPiAMW3EN3K96Ka/ilSXYr6qR1zSVXw2j7+yDSqGTC4T9apfLYxM3tLLjKvgPdAUK7kYQ==",
"dev": true
},
"onetime": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz",
@@ -43557,6 +44842,12 @@
"@babel/runtime": "^7.12.13"
}
},
"react-colorful": {
"version": "5.6.1",
"resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.6.1.tgz",
"integrity": "sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw==",
"requires": {}
},
"react-dev-utils": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-12.0.0.tgz",
@@ -44559,6 +45850,12 @@
"ret": "~0.1.10"
}
},
"safe-stable-stringify": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.3.1.tgz",
"integrity": "sha512-kYBSfT+troD9cDA85VDnHZ1rpHC50O0g1e6WlGHVCz/g+JS+9WKLj+XwFYyR8UbrZN8ll9HUpDAAddY58MGisg==",
"dev": true
},
"safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
@@ -45067,6 +46364,23 @@
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
"dev": true
},
"simple-swizzle": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz",
"integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==",
"dev": true,
"requires": {
"is-arrayish": "^0.3.1"
},
"dependencies": {
"is-arrayish": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz",
"integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==",
"dev": true
}
}
},
"sisteransi": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
@@ -45490,6 +46804,12 @@
"integrity": "sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==",
"dev": true
},
"stack-trace": {
"version": "0.0.10",
"resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz",
"integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==",
"dev": true
},
"stack-utils": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.5.tgz",
@@ -45603,6 +46923,15 @@
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
"dev": true
},
"stream-events": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz",
"integrity": "sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==",
"dev": true,
"requires": {
"stubs": "^3.0.0"
}
},
"strict-uri-encode": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz",
@@ -45783,6 +47112,12 @@
"dev": true,
"optional": true
},
"stubs": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/stubs/-/stubs-3.0.0.tgz",
"integrity": "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==",
"dev": true
},
"style-loader": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/style-loader/-/style-loader-2.0.0.tgz",
@@ -45956,6 +47291,137 @@
}
}
},
"tabtab": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/tabtab/-/tabtab-3.0.2.tgz",
"integrity": "sha512-jANKmUe0sIQc/zTALTBy186PoM/k6aPrh3A7p6AaAfF6WPSbTx1JYeGIGH162btpH+mmVEXln+UxwViZHO2Jhg==",
"dev": true,
"requires": {
"debug": "^4.0.1",
"es6-promisify": "^6.0.0",
"inquirer": "^6.0.0",
"minimist": "^1.2.0",
"mkdirp": "^0.5.1",
"untildify": "^3.0.3"
},
"dependencies": {
"ansi-escapes": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-3.2.0.tgz",
"integrity": "sha512-cBhpre4ma+U0T1oM5fXg7Dy1Jw7zzwv7lt/GoCpr+hDQJoYnKVPLL4dCvSEFMmQurOQvSrwT7SL/DAlhBI97RQ==",
"dev": true
},
"ansi-regex": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.1.tgz",
"integrity": "sha512-+O9Jct8wf++lXxxFc4hc8LsjaSq0HFzzL7cVsw8pRDIPdjKD2mT4ytDZlLuSBZ4cLKZFXIrMGO7DbQCtMJJMKw==",
"dev": true
},
"cli-cursor": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz",
"integrity": "sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw==",
"dev": true,
"requires": {
"restore-cursor": "^2.0.0"
}
},
"cli-width": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/cli-width/-/cli-width-2.2.1.tgz",
"integrity": "sha512-GRMWDxpOB6Dgk2E5Uo+3eEBvtOOlimMmpbFiKuLFnQzYDavtLFY3K5ona41jgN/WdRZtG7utuVSVTL4HbZHGkw==",
"dev": true
},
"figures": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/figures/-/figures-2.0.0.tgz",
"integrity": "sha512-Oa2M9atig69ZkfwiApY8F2Yy+tzMbazyvqv21R0NsSC8floSOC09BbT1ITWAdoMGQvJ/aZnR1KMwdx9tvHnTNA==",
"dev": true,
"requires": {
"escape-string-regexp": "^1.0.5"
}
},
"inquirer": {
"version": "6.5.2",
"resolved": "https://registry.npmjs.org/inquirer/-/inquirer-6.5.2.tgz",
"integrity": "sha512-cntlB5ghuB0iuO65Ovoi8ogLHiWGs/5yNrtUcKjFhSSiVeAIVpD7koaSU9RM8mpXw5YDi9RdYXGQMaOURB7ycQ==",
"dev": true,
"requires": {
"ansi-escapes": "^3.2.0",
"chalk": "^2.4.2",
"cli-cursor": "^2.1.0",
"cli-width": "^2.0.0",
"external-editor": "^3.0.3",
"figures": "^2.0.0",
"lodash": "^4.17.12",
"mute-stream": "0.0.7",
"run-async": "^2.2.0",
"rxjs": "^6.4.0",
"string-width": "^2.1.0",
"strip-ansi": "^5.1.0",
"through": "^2.3.6"
}
},
"is-fullwidth-code-point": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz",
"integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==",
"dev": true
},
"mimic-fn": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz",
"integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==",
"dev": true
},
"mute-stream": {
"version": "0.0.7",
"resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.7.tgz",
"integrity": "sha512-r65nCZhrbXXb6dXOACihYApHw2Q6pV0M3V0PSxd74N0+D8nzAdEAITq2oAjA1jVnKI+tGvEBUpqiMh0+rW6zDQ==",
"dev": true
},
"onetime": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/onetime/-/onetime-2.0.1.tgz",
"integrity": "sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ==",
"dev": true,
"requires": {
"mimic-fn": "^1.0.0"
}
},
"restore-cursor": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-2.0.0.tgz",
"integrity": "sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==",
"dev": true,
"requires": {
"onetime": "^2.0.0",
"signal-exit": "^3.0.2"
}
},
"string-width": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz",
"integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==",
"dev": true,
"requires": {
"is-fullwidth-code-point": "^2.0.0",
"strip-ansi": "^4.0.0"
},
"dependencies": {
"strip-ansi": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz",
"integrity": "sha512-4XaJ2zQdCzROZDivEVIDPkcQn8LMFSa8kj8Gxb/Lnwzv9A8VctNZ+lfivC/sV3ivW8ElJTERXZoPBRrZKkNKow==",
"dev": true,
"requires": {
"ansi-regex": "^3.0.0"
}
}
}
}
}
},
"tapable": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz",
@@ -45977,6 +47443,19 @@
"xtend": "^4.0.0"
}
},
"teeny-request": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-7.1.1.tgz",
"integrity": "sha512-iwY6rkW5DDGq8hE2YgNQlKbptYpY5Nn2xecjQiNjOXWbKzPGUfmeUBCSQbbr306d7Z7U2N0TPl+/SwYRfua1Dg==",
"dev": true,
"requires": {
"http-proxy-agent": "^4.0.0",
"https-proxy-agent": "^5.0.0",
"node-fetch": "^2.6.1",
"stream-events": "^1.0.5",
"uuid": "^8.0.0"
}
},
"temp-dir": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-1.0.0.tgz",
@@ -46055,6 +47534,12 @@
"minimatch": "^3.0.4"
}
},
"text-hex": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz",
"integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==",
"dev": true
},
"text-table": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
@@ -46230,6 +47715,12 @@
"escape-string-regexp": "^1.0.2"
}
},
"triple-beam": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.3.0.tgz",
"integrity": "sha512-XrHUvV5HpdLmIj4uVMxHggLbFSZYIn7HEWsqePZcI50pco+MPqJ50wMGY794X7AOOhxOBAjbkqfAbEe/QMp2Lw==",
"dev": true
},
"tryer": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/tryer/-/tryer-1.0.1.tgz",
@@ -46475,6 +47966,12 @@
}
}
},
"untildify": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/untildify/-/untildify-3.0.3.tgz",
"integrity": "sha512-iSk/J8efr8uPT/Z4eSUywnqyrQU7DSdMfdqK4iWEaUVVmcP5JcnpRqmVMwcwcnmI1ATFNgC5V90u09tBynNFKA==",
"dev": true
},
"uri-js": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
@@ -46517,6 +48014,15 @@
"dev": true,
"optional": true
},
"urlgrey": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/urlgrey/-/urlgrey-1.0.0.tgz",
"integrity": "sha512-hJfIzMPJmI9IlLkby8QrsCykQ+SXDeO2W5Q9QTW3QpqZVTx4a/K7p8/5q+/isD8vsbVaFgql/gvAoQCRQ2Cb5w==",
"dev": true,
"requires": {
"fast-url-parser": "^1.1.3"
}
},
"use": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz",
@@ -47217,6 +48723,60 @@
"integrity": "sha512-JcKqAHLPxcdb9KM49dufGXn2x3ssnfjbcaQdLlfZsL9rH9wgDQjUtDxbo8NE0F6SFvydeu1VhZe7hZuHsB2/pw==",
"dev": true
},
"winston": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/winston/-/winston-3.2.1.tgz",
"integrity": "sha512-zU6vgnS9dAWCEKg/QYigd6cgMVVNwyTzKs81XZtTFuRwJOcDdBg7AU0mXVyNbs7O5RH2zdv+BdNZUlx7mXPuOw==",
"dev": true,
"requires": {
"async": "^2.6.1",
"diagnostics": "^1.1.1",
"is-stream": "^1.1.0",
"logform": "^2.1.1",
"one-time": "0.0.4",
"readable-stream": "^3.1.1",
"stack-trace": "0.0.x",
"triple-beam": "^1.3.0",
"winston-transport": "^4.3.0"
},
"dependencies": {
"readable-stream": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz",
"integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==",
"dev": true,
"requires": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
}
}
}
},
"winston-transport": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.5.0.tgz",
"integrity": "sha512-YpZzcUzBedhlTAfJg6vJDlyEai/IFMIVcaEZZyl3UXIl4gmqRpU7AE89AHLkbzLUsv0NVmw7ts+iztqKxxPW1Q==",
"dev": true,
"requires": {
"logform": "^2.3.2",
"readable-stream": "^3.6.0",
"triple-beam": "^1.3.0"
},
"dependencies": {
"readable-stream": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz",
"integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==",
"dev": true,
"requires": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
}
}
}
},
"word-wrap": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz",

View File

@@ -34,22 +34,22 @@
},
"dependencies": {
"@edx/brand": "npm:@edx/brand-openedx@1.1.0",
"@edx/frontend-component-footer": "11.2.0",
"@edx/frontend-component-header": "3.2.0",
"@edx/frontend-platform": "2.6.1",
"@edx/frontend-component-footer": "12.0.0",
"@edx/frontend-component-header": "4.0.0",
"@edx/frontend-platform": "4.2.0",
"@edx/paragon": "20.15.0",
"@reduxjs/toolkit": "1.8.0",
"@tinymce/tinymce-react": "3.13.1",
"babel-polyfill": "6.26.0",
"classnames": "2.3.1",
"core-js": "3.21.1",
"dompurify": "^2.4.3",
"formik": "2.2.9",
"lodash.snakecase": "4.1.1",
"prop-types": "15.8.1",
"raw-loader": "4.0.2",
"react": "16.14.0",
"react-dom": "16.14.0",
"react-mathjax-preview": "2.2.6",
"react-redux": "7.2.6",
"react-router": "5.2.1",
"react-router-dom": "5.3.0",
@@ -68,6 +68,8 @@
"@testing-library/user-event": "13.5.0",
"axios-mock-adapter": "1.20.0",
"babel-plugin-react-intl": "8.2.25",
"codecov": "3.8.3",
"es-check": "6.2.1",
"eslint-plugin-simple-import-sort": "7.0.0",
"glob": "7.2.0",
"husky": "7.0.4",

View File

@@ -9,43 +9,6 @@
href="<%=htmlWebpackPlugin.options.FAVICON_URL%>"
type="image/x-icon"
/>
<script>
window.MathJax = {
tex: {
inlineMath: [
['$', '$'],
['\\\\(', '\\\\)'],
['\\(', '\\)'],
['[mathjaxinline]', '[/mathjaxinline]'],
['\\begin{math}', '\\end{math}'],
],
displayMath: [
['[mathjax]', '[/mathjax]'],
['$$', '$$'],
['\\\\[', '\\\\]'],
['\\[', '\\]'],
['\\begin{displaymath}', '\\end{displaymath}'],
['\\begin{equation}', '\\end{equation}'],
],
processEscapes: true,
processEnvironments: true,
autoload: {
color: [],
colorv2: ['color']
},
packages: {'[+]': ['noerrors']}
},
options: {
ignoreHtmlClass: 'tex2jax_ignore',
processHtmlClass: 'tex2jax_process'
},
loader: {
load: ['input/asciimath', '[tex]/noerrors']
}
};
</script>
<script async src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js" id="MathJax-script"></script>
</head>
<body>
<div id="root" class="small"></div>

View File

@@ -120,27 +120,28 @@ function FilterBar({
<div className="d-flex flex-row py-2 justify-content-between">
{filters.map((value) => (
<Form.RadioSet
key={value.name}
name={value.name}
className="d-flex flex-column list-group list-group-flush"
value={selectedFilters[value.name]}
onChange={onFilterChange}
>
{value.filters.map(filterName => {
const element = allFilters.find(obj => obj.id === filterName);
if (element) {
return (
<ActionItem
key={element.id}
id={element.id}
label={element.label}
value={element.value}
selected={selectedFilters[value.name]}
/>
);
}
return false;
})}
{
value.filters.map(filterName => {
const element = allFilters.find(obj => obj.id === filterName);
if (element) {
return (
<ActionItem
id={element.id}
label={element.label}
value={element.value}
selected={selectedFilters[value.name]}
/>
);
}
return false;
})
}
</Form.RadioSet>
))}
</div>

View File

@@ -1,38 +1,53 @@
import React, { useEffect, useRef } from 'react';
import React from 'react';
import PropTypes from 'prop-types';
import DOMPurify from 'dompurify';
import MathJax from 'react-mathjax-preview';
import { logError } from '@edx/frontend-platform/logging';
const baseConfig = {
showMathMenu: true,
tex2jax: {
inlineMath: [
['$', '$'],
['\\\\(', '\\\\)'],
['\\(', '\\)'],
['[mathjaxinline]', '[/mathjaxinline]'],
['\\begin{math}', '\\end{math}'],
],
displayMath: [
['[mathjax]', '[/mathjax]'],
['$$', '$$'],
['\\\\[', '\\\\]'],
['\\[', '\\]'],
['\\begin{displaymath}', '\\end{displaymath}'],
['\\begin{equation}', '\\end{equation}'],
],
},
const defaultSanitizeOptions = {
USE_PROFILES: { html: true },
ADD_ATTR: ['columnalign'],
skipStartupTypeset: true,
};
function HTMLLoader({
htmlNode, componentId, cssClassName, testId,
}) {
const sanitizedMath = DOMPurify.sanitize(htmlNode, { ...defaultSanitizeOptions });
const previewRef = useRef();
useEffect(() => {
let promise = Promise.resolve(); // Used to hold chain of typesetting calls
function typeset(code) {
promise = promise.then(() => window.MathJax?.typesetPromise(code()))
.catch((err) => logError(`Typeset failed: ${err.message}`));
return promise;
}
typeset(() => {
previewRef.current.innerHTML = sanitizedMath;
});
}, [htmlNode]);
function HTMLLoader({ htmlNode, componentId, cssClassName }) {
const isLatex = htmlNode.match(/(\${1,2})((?:\\.|.)*)\1/)
|| htmlNode.match(/(\[mathjax](.+?)\[\/mathjax])+/)
|| htmlNode.match(/(\[mathjaxinline](.+?)\[\/mathjaxinline])+/)
|| htmlNode.match(/(\\begin\{math}(.+?)\\end\{math})+/)
|| htmlNode.match(/(\\begin\{displaymath}(.+?)\\end\{displaymath})+/)
|| htmlNode.match(/(\\begin\{equation}(.+?)\\end\{equation})+/)
|| htmlNode.match(/(\\\[(.+?)\\\])+/)
|| htmlNode.match(/(\\\((.+?)\\\))+/);
return (
<div ref={previewRef} className={cssClassName} id={componentId} data-testid={testId} />
isLatex ? (
<MathJax
math={htmlNode}
id={componentId}
className={cssClassName}
sanitizeOptions={{ USE_PROFILES: { html: true } }}
config={baseConfig}
/>
)
// eslint-disable-next-line react/no-danger
: <div className={cssClassName} id={componentId} dangerouslySetInnerHTML={{ __html: htmlNode }} />
);
}
@@ -40,14 +55,12 @@ HTMLLoader.propTypes = {
htmlNode: PropTypes.node,
componentId: PropTypes.string,
cssClassName: PropTypes.string,
testId: PropTypes.string,
};
HTMLLoader.defaultProps = {
htmlNode: '',
componentId: null,
cssClassName: '',
testId: '',
};
export default HTMLLoader;

View File

@@ -1,3 +0,0 @@
/* eslint-disable import/prefer-default-export */
export const selectCourseTabs = state => state.courseTabs;

View File

@@ -29,7 +29,7 @@ function PostPreviewPane({
className="float-right p-3"
iconClassNames="icon-size"
/>
<HTMLLoader htmlNode={htmlNode} cssClassName="text-primary" componentId="post-preview" testId="post-preview" />
<HTMLLoader htmlNode={htmlNode} cssClassName="text-primary" />
</div>
)}
<div className="d-flex justify-content-end">
@@ -37,10 +37,9 @@ function PostPreviewPane({
&& (
<Button
variant="link"
size="sm"
size="md"
onClick={() => setShowPreviewPane(true)}
className={`text-primary-500 p-0 ${editExisting && 'mb-4.5'}`}
style={{ lineHeight: '26px' }}
className={`text-primary-500 px-0 ${editExisting && 'mb-4.5'}`}
>
{intl.formatMessage(messages.showPreviewButton)}
</Button>

View File

@@ -32,7 +32,6 @@ import 'tinymce/plugins/lists';
import 'tinymce/plugins/emoticons';
import 'tinymce/plugins/emoticons/js/emojis';
import 'tinymce/plugins/charmap';
import 'tinymce/plugins/paste';
/* eslint import/no-webpack-loader-syntax: off */
// eslint-disable-next-line import/no-unresolved
import edxBrandCss from '!!raw-loader!sass-loader!../index.scss';
@@ -101,13 +100,12 @@ export default function TinyMCEEditor(props) {
skin: false,
menubar: false,
branding: false,
paste_data_images: false,
contextmenu: false,
browser_spellcheck: true,
a11y_advanced_options: true,
autosave_interval: '1s',
autosave_restore_when_empty: false,
plugins: 'autoresize autosave codesample link lists image imagetools code emoticons charmap paste',
plugins: 'autoresize autosave codesample link lists image imagetools code emoticons charmap',
toolbar: 'undo redo'
+ ' | formatselect | bold italic underline'
+ ' | link blockquote openedx_code image'
@@ -119,7 +117,6 @@ export default function TinyMCEEditor(props) {
content_css: false,
content_style: contentStyle,
body_class: 'm-2 text-editor',
relative_urls: false,
default_link_target: '_blank',
target_list: false,
images_upload_handler: uploadHandler,

View File

@@ -190,8 +190,6 @@ export const Routes = {
CATEGORY: `${BASE_PATH}/category/:category`,
CATEGORY_POST: `${BASE_PATH}/category/:category/posts/:postId`,
TOPIC: `${BASE_PATH}/topics/:topicId`,
TOPIC_POST: `${BASE_PATH}/topics/:topicId/posts/:postId`,
TOPIC_POST_EDIT: `${BASE_PATH}/topics/:topicId/posts/:postId/edit`,
},
};

View File

@@ -38,6 +38,12 @@ export const selectTopicsUnderCategory = createSelector(
),
);
export const selectSequences = createSelector(
selectChapters,
selectBlocks,
(chapterIds, blocks) => chapterIds?.flatMap(cId => blocks[cId].children.map(seqId => blocks[seqId])) || [],
);
export const selectArchivedTopics = createSelector(
state => state.topics.topics,
state => state.topics.archivedIds || [],

View File

@@ -1,6 +1,4 @@
import React, {
useContext, useEffect, useMemo, useState,
} from 'react';
import React, { useContext, useEffect, useMemo } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
@@ -10,8 +8,7 @@ import { useHistory, useLocation } from 'react-router-dom';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import {
Button, Icon, IconButton,
Spinner,
Button, Icon, IconButton, Spinner,
} from '@edx/paragon';
import { ArrowBack } from '@edx/paragon/icons';
@@ -20,12 +17,12 @@ import {
} from '../../data/constants';
import { useDispatchWithState } from '../../data/hooks';
import { DiscussionContext } from '../common/context';
import { useIsOnDesktop, useUserCanAddThreadInBlackoutDate } from '../data/hooks';
import { useIsOnDesktop } from '../data/hooks';
import { EmptyPage } from '../empty-posts';
import { Post } from '../posts';
import { selectThread } from '../posts/data/selectors';
import { fetchThread, markThreadAsRead } from '../posts/data/thunks';
import { discussionsPath, filterPosts, isLastElementOfList } from '../utils';
import { discussionsPath, filterPosts } from '../utils';
import { selectThreadComments, selectThreadCurrentPage, selectThreadHasMorePages } from './data/selectors';
import { fetchThreadComments } from './data/thunks';
import { Comment, ResponseEditor } from './comment';
@@ -80,103 +77,47 @@ function DiscussionCommentsView({
isLoading,
handleLoadMoreResponses,
} = usePostComments(postId, endorsed);
const sortedComments = useMemo(() => [...filterPosts(comments, 'endorsed'),
...filterPosts(comments, 'unendorsed')], [comments]);
const endorsedComments = useMemo(() => [...filterPosts(comments, 'endorsed')], [comments]);
const unEndorsedComments = useMemo(() => [...filterPosts(comments, 'unendorsed')], [comments]);
const userCanAddThreadInBlackoutDate = useUserCanAddThreadInBlackoutDate();
const [addingResponse, setAddingResponse] = useState(false);
const handleDefinition = (message, commentsLength) => (
<div
className="mx-4 my-14px text-gray-700 font-style-normal font-family-inter"
role="heading"
aria-level="2"
style={{ lineHeight: '24px' }}
>
{intl.formatMessage(message, { num: commentsLength })}
</div>
);
const handleComments = (postComments, showLoadMoreResponses = false) => (
<div className="mx-4" role="list">
{postComments.map((comment) => (
<Comment
comment={comment}
key={comment.id}
postType={postType}
isClosedPost={isClosed}
marginBottom={isLastElementOfList(postComments, comment)}
/>
))}
{hasMorePages && !isLoading && !showLoadMoreResponses && (
<Button
onClick={handleLoadMoreResponses}
variant="link"
block="true"
className="px-4 mt-3 py-0 mb-2 font-style-normal font-family-inter font-weight-500 font-size-14"
style={{
lineHeight: '24px',
border: '0px',
}}
data-testid="load-more-comments"
>
{intl.formatMessage(messages.loadMoreResponses)}
</Button>
)}
{isLoading && !showLoadMoreResponses && (
<div className="mb-2 mt-3 d-flex justify-content-center">
<Spinner animation="border" variant="primary" className="spinner-dimentions" />
</div>
)}
</div>
);
return (
<>
{((hasMorePages && isLoading) || !isLoading) && (
<>
{endorsedComments.length > 0 && (
<>
{handleDefinition(messages.endorsedResponseCount, endorsedComments.length)}
{endorsed === EndorsementStatus.DISCUSSION
? handleComments(endorsedComments, true)
: handleComments(endorsedComments, false)}
</>
)}
{endorsed !== EndorsementStatus.ENDORSED && (
<>
{handleDefinition(messages.responseCount, unEndorsedComments.length)}
{unEndorsedComments.length === 0 && <br />}
{handleComments(unEndorsedComments, false)}
{(userCanAddThreadInBlackoutDate && !!unEndorsedComments.length && !isClosed) && (
<div className="mx-4">
{!addingResponse && (
<Button
variant="plain"
block="true"
className="card mb-4 px-0 py-10px mt-2 font-style-normal font-family-inter font-weight-500 font-size-14 text-primary-500"
style={{
lineHeight: '24px',
border: '0px',
}}
onClick={() => setAddingResponse(true)}
data-testid="add-response"
>
{intl.formatMessage(messages.addResponse)}
</Button>
)}
<ResponseEditor
postId={postId}
handleCloseEditor={() => setAddingResponse(false)}
addingResponse={addingResponse}
/>
</div>
)}
</>
)}
</>
{((hasMorePages && isLoading) || !isLoading)
&& (
<div className="mx-4 text-primary-700" role="heading" aria-level="2" style={{ lineHeight: '28px' }}>
{endorsed === EndorsementStatus.ENDORSED
? intl.formatMessage(messages.endorsedResponseCount, { num: sortedComments.length })
: intl.formatMessage(messages.responseCount, { num: sortedComments.length })}
</div>
)}
<div className="mx-4" role="list">
{sortedComments.map(comment => (
<Comment comment={comment} key={comment.id} postType={postType} isClosedPost={isClosed} />
))}
{hasMorePages && !isLoading && (
<Button
onClick={handleLoadMoreResponses}
variant="link"
block="true"
className="card p-4 mb-4 font-weight-500 font-size-14"
style={{
lineHeight: '20px',
}}
data-testid="load-more-comments"
>
{intl.formatMessage(messages.loadMoreResponses)}
</Button>
)}
{isLoading
&& (
<div className="card my-4 p-4 d-flex align-items-center">
<Spinner animation="border" variant="primary" />
</div>
)}
{!!sortedComments.length && !isClosed
&& <ResponseEditor postId={postId} addWrappingDiv />}
</div>
</>
);
@@ -199,14 +140,12 @@ function CommentsView({ intl }) {
const history = useHistory();
const location = useLocation();
const isOnDesktop = useIsOnDesktop();
const [addingResponse, setAddingResponse] = useState(false);
const {
courseId, learnerUsername, category, topicId, page, enableInContextSidebar,
courseId, learnerUsername, category, topicId, page, inContext,
} = useContext(DiscussionContext);
useEffect(() => {
if (!thread) { submitDispatch(fetchThread(postId, courseId, true)); }
setAddingResponse(false);
}, [postId]);
if (!thread) {
@@ -216,20 +155,14 @@ function CommentsView({ intl }) {
);
}
return (
<div style={{
position: 'absolute',
top: '50%',
}}
>
<Spinner animation="border" variant="primary" data-testid="loading-indicator" />
</div>
<Spinner animation="border" variant="primary" data-testid="loading-indicator" />
);
}
return (
<>
{!isOnDesktop && (
enableInContextSidebar ? (
inContext ? (
<>
<div className="px-4 py-1.5 bg-white">
<Button
@@ -260,52 +193,41 @@ function CommentsView({ intl }) {
/>
)
)}
<div
className={classNames('discussion-comments d-flex flex-column card border-0', {
'post-card-margin post-card-padding': !enableInContextSidebar,
'post-card-padding rounded-0 border-0 mb-4': enableInContextSidebar,
})}
<div className={classNames('discussion-comments d-flex flex-column card', {
'm-4 p-4.5': !inContext,
'p-4 rounded-0 border-0 mb-4': inContext,
})}
>
<Post post={thread} handleAddResponseButton={() => setAddingResponse(true)} />
{!thread.closed && (
<ResponseEditor
postId={postId}
handleCloseEditor={() => setAddingResponse(false)}
addingResponse={addingResponse}
/>
)}
<Post post={thread} />
{!thread.closed && <ResponseEditor postId={postId} /> }
</div>
{
thread.type === ThreadType.DISCUSSION && (
{thread.type === ThreadType.DISCUSSION && (
<DiscussionCommentsView
postId={postId}
intl={intl}
postType={thread.type}
endorsed={EndorsementStatus.DISCUSSION}
isClosed={thread.closed}
/>
)}
{thread.type === ThreadType.QUESTION && (
<>
<DiscussionCommentsView
postId={postId}
intl={intl}
postType={thread.type}
endorsed={EndorsementStatus.DISCUSSION}
endorsed={EndorsementStatus.ENDORSED}
isClosed={thread.closed}
/>
)
}
{
thread.type === ThreadType.QUESTION && (
<>
<DiscussionCommentsView
postId={postId}
intl={intl}
postType={thread.type}
endorsed={EndorsementStatus.ENDORSED}
isClosed={thread.closed}
/>
<DiscussionCommentsView
postId={postId}
intl={intl}
postType={thread.type}
endorsed={EndorsementStatus.UNENDORSED}
isClosed={thread.closed}
/>
</>
)
}
<DiscussionCommentsView
postId={postId}
intl={intl}
postType={thread.type}
endorsed={EndorsementStatus.UNENDORSED}
isClosed={thread.closed}
/>
</>
)}
</>
);
}

View File

@@ -162,7 +162,7 @@ describe('CommentsView', () => {
it('should show and hide the editor', async () => {
renderComponent(discussionPostId);
await screen.findByTestId('thread-1');
await waitFor(() => screen.findByText('comment number 1', { exact: false }));
const addResponseButtons = screen.getAllByRole('button', { name: /add a response/i });
await act(async () => {
fireEvent.click(
@@ -178,7 +178,7 @@ describe('CommentsView', () => {
it('should allow posting a response', async () => {
renderComponent(discussionPostId);
await screen.findByTestId('thread-1');
await waitFor(() => screen.findByText('comment number 1', { exact: false }));
const responseButtons = screen.getAllByRole('button', { name: /add a response/i });
await act(async () => {
fireEvent.click(
@@ -195,25 +195,21 @@ describe('CommentsView', () => {
);
});
expect(screen.queryByTestId('tinymce-editor')).not.toBeInTheDocument();
await waitFor(async () => expect(await screen.findByTestId('comment-1')).toBeInTheDocument());
await waitFor(async () => expect(await screen.findByText('testing123', { exact: false })).toBeInTheDocument());
});
it('should not allow posting a response on a closed post', async () => {
renderComponent(closedPostId);
await act(async () => {
fireEvent.mouseOver(await waitFor(() => screen.findByText('Thread-2', { exact: false })));
});
expect(screen.queryByRole('button', { name: /add response/i }, { hidden: false })).toBeDisabled();
await waitFor(() => screen.findByText('Thread-2', { exact: false }));
expect(screen.queryByRole('button', { name: /add a response/i })).not.toBeInTheDocument();
});
it('should allow posting a comment', async () => {
renderComponent(discussionPostId);
await act(async () => {
fireEvent.mouseOver(await waitFor(() => screen.findByTestId('comment-1')));
});
await waitFor(() => screen.findByText('comment number 1', { exact: false }));
await act(async () => {
fireEvent.click(
screen.getAllByRole('button', { name: /add comment/i })[0],
screen.getAllByRole('button', { name: /add a comment/i })[0],
);
});
act(() => {
@@ -226,26 +222,22 @@ describe('CommentsView', () => {
);
});
expect(screen.queryByTestId('tinymce-editor')).not.toBeInTheDocument();
await waitFor(async () => expect(await screen.findByTestId('comment-comment-1')).toBeInTheDocument());
await waitFor(async () => expect(await screen.findByText('testing123', { exact: false })).toBeInTheDocument());
});
it('should not allow posting a comment on a closed post', async () => {
renderComponent(closedPostId);
await screen.findByTestId('thread-2');
await waitFor(() => screen.findByText('thread-2', { exact: false }));
await act(async () => {
fireEvent.mouseOver(await waitFor(() => screen.findByTestId('comment-3')));
expect(
screen.queryByRole('button', { name: /add a comment/i }),
).not.toBeInTheDocument();
});
const addCommentButton = screen.getAllByRole('button', { name: /add comment/i }, { hidden: false })[0];
expect(addCommentButton).toBeDisabled();
});
it('should allow editing an existing comment', async () => {
renderComponent(discussionPostId);
await act(async () => {
fireEvent.mouseOver(await waitFor(() => screen.findByTestId('comment-1')));
});
await waitFor(() => screen.findByText('comment number 1', { exact: false }));
await act(async () => {
fireEvent.click(
// The first edit menu is for the post, the second will be for the first comment.
@@ -262,7 +254,7 @@ describe('CommentsView', () => {
fireEvent.click(screen.getByRole('button', { name: /submit/i }));
});
await waitFor(async () => {
expect(await screen.findByTestId('comment-1')).toBeInTheDocument();
expect(await screen.findByText('testing123', { exact: false })).toBeInTheDocument();
});
});
@@ -286,9 +278,7 @@ describe('CommentsView', () => {
it('should show reason codes when editing an existing comment', async () => {
setupCourseConfig();
renderComponent(discussionPostId);
await act(async () => {
fireEvent.mouseOver(await waitFor(() => screen.findByTestId('comment-1')));
});
await waitFor(() => screen.findByText('comment number 1', { exact: false }));
await act(async () => {
fireEvent.click(
// The first edit menu is for the post, the second will be for the first comment.
@@ -301,12 +291,10 @@ describe('CommentsView', () => {
expect(screen.queryByRole('combobox', { name: /reason for editing/i })).toBeInTheDocument();
expect(screen.getAllByRole('option', { name: /reason \d/i })).toHaveLength(2);
await act(async () => {
fireEvent.change(screen.queryByRole('combobox', { name: /reason for editing/i }),
{ target: { value: null } });
fireEvent.change(screen.queryByRole('combobox', { name: /reason for editing/i }), { target: { value: null } });
});
await act(async () => {
fireEvent.change(screen.queryByRole('combobox',
{ name: /reason for editing/i }), { target: { value: 'reason-1' } });
fireEvent.change(screen.queryByRole('combobox', { name: /reason for editing/i }), { target: { value: 'reason-1' } });
});
await act(async () => {
fireEvent.change(screen.getByTestId('tinymce-editor'), { target: { value: 'testing123' } });
@@ -320,9 +308,6 @@ describe('CommentsView', () => {
it('should show reason codes when closing a post', async () => {
setupCourseConfig();
renderComponent(discussionPostId);
await act(async () => {
fireEvent.mouseOver(await waitFor(() => screen.findByTestId('post-thread-1')));
});
await act(async () => {
fireEvent.click(
// The first edit menu is for the post
@@ -351,9 +336,6 @@ describe('CommentsView', () => {
it('should close the post directly if reason codes are not enabled', async () => {
setupCourseConfig(false);
renderComponent(discussionPostId);
await act(async () => {
fireEvent.mouseOver(await waitFor(() => screen.findByTestId('post-thread-1')));
});
await act(async () => {
fireEvent.click(
// The first edit menu is for the post
@@ -373,9 +355,6 @@ describe('CommentsView', () => {
async (reasonCodesEnabled) => {
setupCourseConfig(reasonCodesEnabled);
renderComponent(closedPostId);
await act(async () => {
fireEvent.mouseOver(await waitFor(() => screen.findByText('Thread-2', { exact: false })));
});
await act(async () => {
fireEvent.click(
// The first edit menu is for the post
@@ -394,9 +373,6 @@ describe('CommentsView', () => {
it('should show the editor if the post is edited', async () => {
setupCourseConfig(false);
renderComponent(discussionPostId);
await act(async () => {
fireEvent.mouseOver(await waitFor(() => screen.findByTestId('post-thread-1')));
});
await act(async () => {
fireEvent.click(
// The first edit menu is for the post
@@ -408,12 +384,8 @@ describe('CommentsView', () => {
});
expect(testLocation.pathname).toBe(`/${courseId}/posts/${discussionPostId}/edit`);
});
it('should allow pinning the post', async () => {
renderComponent(discussionPostId);
await act(async () => {
fireEvent.mouseOver(await waitFor(() => screen.findByTestId('post-thread-1')));
});
await act(async () => {
fireEvent.click(
// The first edit menu is for the post
@@ -425,12 +397,8 @@ describe('CommentsView', () => {
});
assertLastUpdateData({ pinned: false });
});
it('should allow reporting the post', async () => {
renderComponent(discussionPostId);
await act(async () => {
fireEvent.mouseOver(await waitFor(() => screen.findByTestId('post-thread-1')));
});
await act(async () => {
fireEvent.click(
// The first edit menu is for the post
@@ -440,11 +408,6 @@ describe('CommentsView', () => {
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /report/i }));
});
expect(screen.queryByRole('dialog', { name: /Report \w+/i, exact: false })).toBeInTheDocument();
await act(async () => {
fireEvent.click(screen.queryByRole('button', { name: /Confirm/i }));
});
expect(screen.queryByRole('dialog', { name: /Report \w+/i, exact: false })).not.toBeInTheDocument();
assertLastUpdateData({ abuse_flagged: true });
});
@@ -452,9 +415,7 @@ describe('CommentsView', () => {
renderComponent(discussionPostId);
// Wait for the content to load
await act(async () => {
fireEvent.mouseOver(await waitFor(() => screen.findByTestId('comment-7')));
});
await screen.findByText('comment number 7', { exact: false });
const view = screen.getByTestId('comment-comment-1');
const likeButton = within(view).getByRole('button', { name: /like/i });
@@ -465,41 +426,26 @@ describe('CommentsView', () => {
expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject({ voted: true });
});
it('handles endorsing comments', async () => {
it.each([
['endorsing comments', 'Endorse', { endorsed: true }],
['reporting comments', 'Report', { abuse_flagged: true }],
])('handles %s', async (label, buttonLabel, patchData) => {
renderComponent(discussionPostId);
// Wait for the content to load
await act(async () => {
fireEvent.mouseOver(await waitFor(() => screen.findByTestId('comment-7')));
});
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /Endorse/i }));
});
expect(axiosMock.history.patch).toHaveLength(2);
expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject({ endorsed: true });
});
it('handles reporting comments', async () => {
renderComponent(discussionPostId);
// Wait for the content to load
await act(async () => {
fireEvent.mouseOver(await waitFor(() => screen.findByTestId('comment-7')));
});
await screen.findByText('comment number 7', { exact: false });
// There should be three buttons, one for the post, the second for the
// comment and the third for a response to that comment
const actionButtons = screen.queryAllByRole('button', { name: /actions menu/i });
await act(async () => {
fireEvent.click(actionButtons[0]);
fireEvent.click(actionButtons[1]);
});
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /Report/i }));
fireEvent.click(screen.getByRole('button', { name: buttonLabel }));
});
expect(screen.queryByRole('dialog', { name: /Report \w+/i, exact: false })).toBeInTheDocument();
await act(async () => {
fireEvent.click(screen.queryByRole('button', { name: /Confirm/i }));
});
expect(screen.queryByRole('dialog', { name: /Report \w+/i, exact: false })).not.toBeInTheDocument();
expect(axiosMock.history.patch).toHaveLength(2);
expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject({ abuse_flagged: true });
expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject(patchData);
});
});
@@ -514,9 +460,9 @@ describe('CommentsView', () => {
it('initially loads only the first page', async () => {
renderComponent(discussionPostId);
expect(await screen.findByTestId('comment-1'))
expect(await screen.findByText('comment number 1', { exact: false }))
.toBeInTheDocument();
expect(screen.queryByTestId('comment-2'))
expect(screen.queryByText('comment number 2', { exact: false }))
.not
.toBeInTheDocument();
});
@@ -527,8 +473,8 @@ describe('CommentsView', () => {
const loadMoreButton = await findLoadMoreCommentsButton();
fireEvent.click(loadMoreButton);
await screen.findByTestId('comment-1');
await screen.findByTestId('comment-2');
await screen.findByText('comment number 1', { exact: false });
await screen.findByText('comment number 2', { exact: false });
});
it('newly loaded comments are appended to the old ones', async () => {
@@ -537,9 +483,9 @@ describe('CommentsView', () => {
const loadMoreButton = await findLoadMoreCommentsButton();
fireEvent.click(loadMoreButton);
await screen.findByTestId('comment-1');
await screen.findByText('comment number 1', { exact: false });
// check that comments from the first page are also displayed
expect(screen.queryByTestId('comment-2'))
expect(screen.queryByText('comment number 2', { exact: false }))
.toBeInTheDocument();
});
@@ -552,7 +498,7 @@ describe('CommentsView', () => {
fireEvent.click(loadMoreButton);
}
await screen.findByTestId('comment-2');
await screen.findByText('comment number 2', { exact: false });
await expect(findLoadMoreCommentsButton())
.rejects
.toThrow();
@@ -564,11 +510,11 @@ describe('CommentsView', () => {
it('initially loads only the first page', async () => {
act(() => renderComponent(questionPostId));
expect(await screen.findByTestId('comment-3'))
expect(await screen.findByText('comment number 3', { exact: false }))
.toBeInTheDocument();
expect(await screen.findByTestId('comment-5'))
expect(await screen.findByText('endorsed comment number 5', { exact: false }))
.toBeInTheDocument();
expect(screen.queryByTestId('comment-4'))
expect(screen.queryByText('comment number 4', { exact: false }))
.not
.toBeInTheDocument();
});
@@ -581,15 +527,15 @@ describe('CommentsView', () => {
const [loadMoreButtonEndorsed, loadMoreButtonUnendorsed] = await findLoadMoreCommentsButtons();
// Both load more buttons should show
expect(await findLoadMoreCommentsButtons()).toHaveLength(2);
expect(await screen.findByTestId('comment-3'))
expect(await screen.findByText('unendorsed comment number 3', { exact: false }))
.toBeInTheDocument();
expect(await screen.findByTestId('comment-5'))
expect(await screen.findByText('endorsed comment number 5', { exact: false }))
.toBeInTheDocument();
// Comments from next page should not be loaded yet.
expect(await screen.queryByTestId('comment-6'))
expect(await screen.queryByText('endorsed comment number 6', { exact: false }))
.not
.toBeInTheDocument();
expect(await screen.queryByTestId('comment-4'))
expect(await screen.queryByText('unendorsed comment number 4', { exact: false }))
.not
.toBeInTheDocument();
@@ -597,10 +543,10 @@ describe('CommentsView', () => {
fireEvent.click(loadMoreButtonEndorsed);
});
// Endorsed comment from next page should be loaded now.
await waitFor(() => expect(screen.queryByTestId('comment-6'))
await waitFor(() => expect(screen.queryByText('endorsed comment number 6', { exact: false }))
.toBeInTheDocument());
// Unendorsed comment from next page should not be loaded yet.
expect(await screen.queryByTestId('comment-4'))
expect(await screen.queryByText('unendorsed comment number 4', { exact: false }))
.not
.toBeInTheDocument();
// Now only one load more buttons should show, for unendorsed comments
@@ -609,7 +555,7 @@ describe('CommentsView', () => {
fireEvent.click(loadMoreButtonUnendorsed);
});
// Unendorsed comment from next page should be loaded now.
await waitFor(() => expect(screen.queryByTestId('comment-4'))
await waitFor(() => expect(screen.queryByText('unendorsed comment number 4', { exact: false }))
.toBeInTheDocument());
await expect(findLoadMoreCommentsButtons()).rejects.toThrow();
});
@@ -621,8 +567,8 @@ describe('CommentsView', () => {
it('initially loads only the first page', async () => {
renderComponent(discussionPostId);
await waitFor(() => screen.findByTestId('comment-7'));
expect(screen.queryByTestId('comment-8')).not.toBeInTheDocument();
await waitFor(() => screen.findByText('comment number 7', { exact: false }));
expect(screen.queryByText('comment number 8', { exact: false })).not.toBeInTheDocument();
});
it('pressing load more button will load next page of responses', async () => {
@@ -633,7 +579,7 @@ describe('CommentsView', () => {
fireEvent.click(loadMoreButton);
});
await screen.findByTestId('comment-8');
await screen.findByText('comment number 8', { exact: false });
});
it('newly loaded responses are appended to the old ones', async () => {
@@ -644,9 +590,9 @@ describe('CommentsView', () => {
fireEvent.click(loadMoreButton);
});
await screen.findByTestId('comment-8');
await screen.findByText('comment number 8', { exact: false });
// check that comments from the first page are also displayed
expect(screen.queryByTestId('comment-7')).toBeInTheDocument();
expect(screen.queryByText('comment number 7', { exact: false })).toBeInTheDocument();
});
it('load more button is hidden when no more responses pages to load', async () => {
@@ -660,7 +606,7 @@ describe('CommentsView', () => {
});
}
await screen.findByTestId('comment-8');
await screen.findByText('comment number 8', { exact: false });
await expect(findLoadMoreCommentsResponsesButton())
.rejects
.toThrow();
@@ -670,9 +616,7 @@ describe('CommentsView', () => {
renderComponent(discussionPostId);
// Wait for the content to load
await act(async () => {
fireEvent.mouseOver(await waitFor(() => screen.findByTestId('comment-7')));
});
await screen.findByText('comment number 7', { exact: false });
const view = screen.getByTestId('comment-comment-1');
const likeButton = within(view).getByRole('button', { name: /like/i });
@@ -683,26 +627,14 @@ describe('CommentsView', () => {
expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject({ voted: true });
});
it('handles endorsing comments', async () => {
it.each([
['endorsing comments', 'Endorse', { endorsed: true }],
['reporting comments', 'Report', { abuse_flagged: true }],
])('handles %s', async (label, buttonLabel, patchData) => {
renderComponent(discussionPostId);
// Wait for the content to load
await act(async () => {
fireEvent.mouseOver(await waitFor(() => screen.findByTestId('comment-7')));
});
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /Endorse/i }));
});
expect(axiosMock.history.patch).toHaveLength(2);
expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject({ endorsed: true });
});
it('handles reporting comments', async () => {
renderComponent(discussionPostId);
// Wait for the content to load
await act(async () => {
fireEvent.mouseOver(await waitFor(() => screen.findByTestId('comment-7')));
});
await screen.findByText('comment number 7', { exact: false });
// There should be three buttons, one for the post, the second for the
// comment and the third for a response to that comment
@@ -710,17 +642,11 @@ describe('CommentsView', () => {
await act(async () => {
fireEvent.click(actionButtons[1]);
});
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /Report/i }));
fireEvent.click(screen.getByRole('button', { name: buttonLabel }));
});
expect(screen.queryByRole('dialog', { name: /Report \w+/i, exact: false })).toBeInTheDocument();
await act(async () => {
fireEvent.click(screen.queryByRole('button', { name: /Confirm/i }));
});
expect(screen.queryByRole('dialog', { name: /Report \w+/i, exact: false })).not.toBeInTheDocument();
expect(axiosMock.history.patch).toHaveLength(2);
expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject({ abuse_flagged: true });
expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject(patchData);
});
});
@@ -735,12 +661,8 @@ describe('CommentsView', () => {
test(`for ${component}`, async () => {
renderComponent(discussionPostId);
// Wait for the content to load
// await waitFor(() => expect(screen.findByTestId('post-thread-1')).toBeInTheDocument());
await waitFor(() => expect(screen.queryByText('This is Thread-1', { exact: false })).toBeInTheDocument());
await waitFor(() => expect(screen.queryByText('comment number 7', { exact: false })).toBeInTheDocument());
const content = screen.getByTestId(testId);
await act(async () => {
fireEvent.mouseOver(content);
});
const actionsButton = within(content).getAllByRole('button', { name: /actions menu/i })[0];
await act(async () => {
fireEvent.click(actionsButton);

View File

@@ -17,16 +17,16 @@ function CommentIcons({
timeago.register('time-locale', timeLocale);
const handleLike = () => dispatch(editComment(comment.id, { voted: !comment.voted }));
if (comment.voteCount === 0) {
return null;
}
return (
<div className="ml-n1.5 mt-10px">
<div className="d-flex flex-row align-items-center">
<LikeButton
count={comment.voteCount}
onClick={handleLike}
voted={comment.voted}
/>
<div className="d-flex flex-fill text-gray-500 justify-content-end" title={comment.createdAt}>
{timeago.format(comment.createdAt, 'time-locale')}
</div>
</div>
);
}

View File

@@ -8,13 +8,12 @@ import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button, useToggle } from '@edx/paragon';
import HTMLLoader from '../../../components/HTMLLoader';
import { ContentActions, EndorsementStatus } from '../../../data/constants';
import { AlertBanner, Confirmation, EndorsedAlertBanner } from '../../common';
import { ContentActions } from '../../../data/constants';
import { AlertBanner, DeleteConfirmation, EndorsedAlertBanner } from '../../common';
import { DiscussionContext } from '../../common/context';
import HoverCard from '../../common/HoverCard';
import { useUserCanAddThreadInBlackoutDate } from '../../data/hooks';
import { selectBlackoutDate } from '../../data/selectors';
import { fetchThread } from '../../posts/data/thunks';
import { useActions } from '../../utils';
import { inBlackoutDateRange } from '../../utils';
import CommentIcons from '../comment-icons/CommentIcons';
import { selectCommentCurrentPage, selectCommentHasMorePages, selectCommentResponses } from '../data/selectors';
import { editComment, fetchCommentResponses, removeComment } from '../data/thunks';
@@ -30,7 +29,6 @@ function Comment({
showFullThread = true,
isClosedPost,
intl,
marginBottom,
}) {
const dispatch = useDispatch();
const hasChildren = comment.childCount > 0;
@@ -38,12 +36,10 @@ function Comment({
const inlineReplies = useSelector(selectCommentResponses(comment.id));
const [isEditing, setEditing] = useState(false);
const [isDeleting, showDeleteConfirmation, hideDeleteConfirmation] = useToggle(false);
const [isReporting, showReportConfirmation, hideReportConfirmation] = useToggle(false);
const [isReplying, setReplying] = useState(false);
const hasMorePages = useSelector(selectCommentHasMorePages(comment.id));
const currentPage = useSelector(selectCommentCurrentPage(comment.id));
const userCanAddThreadInBlackoutDate = useUserCanAddThreadInBlackoutDate();
const [showHoverCard, setShowHoverCard] = useState(false);
const blackoutDateRange = useSelector(selectBlackoutDate);
const {
courseId,
} = useContext(DiscussionContext);
@@ -53,29 +49,6 @@ function Comment({
dispatch(fetchCommentResponses(comment.id, { page: 1 }));
}
}, [comment.id]);
const actions = useActions({
...comment,
postType,
});
const endorseIcons = actions.find(({ action }) => action === EndorsementStatus.ENDORSED);
const handleAbusedFlag = () => {
if (comment.abuseFlagged) {
dispatch(editComment(comment.id, { flagged: !comment.abuseFlagged }));
} else {
showReportConfirmation();
}
};
const handleDeleteConfirmation = () => {
dispatch(removeComment(comment.id));
hideDeleteConfirmation();
};
const handleReportConfirmation = () => {
dispatch(editComment(comment.id, { flagged: !comment.abuseFlagged }));
hideReportConfirmation();
};
const actionHandlers = {
[ContentActions.EDIT_CONTENT]: () => setEditing(true),
@@ -84,129 +57,86 @@ function Comment({
await dispatch(fetchThread(comment.threadId, courseId));
},
[ContentActions.DELETE]: showDeleteConfirmation,
[ContentActions.REPORT]: () => handleAbusedFlag(),
[ContentActions.REPORT]: () => dispatch(editComment(comment.id, { flagged: !comment.abuseFlagged })),
};
const handleLoadMoreComments = () => (
dispatch(fetchCommentResponses(comment.id, { page: currentPage + 1 }))
);
return (
<div className={classNames({ 'mb-3': (showFullThread && !marginBottom) })}>
{/* eslint-disable jsx-a11y/no-noninteractive-tabindex */}
<div
tabIndex="0"
className="d-flex flex-column card on-focus"
data-testid={`comment-${comment.id}`}
role="listitem"
>
<Confirmation
<div className={classNames({ 'py-2 my-3': showFullThread })}>
<div className="d-flex flex-column card" data-testid={`comment-${comment.id}`} role="listitem">
<DeleteConfirmation
isOpen={isDeleting}
title={intl.formatMessage(messages.deleteResponseTitle)}
description={intl.formatMessage(messages.deleteResponseDescription)}
onClose={hideDeleteConfirmation}
comfirmAction={handleDeleteConfirmation}
closeButtonVaraint="tertiary"
confirmButtonText={intl.formatMessage(messages.deleteConfirmationDelete)}
onDelete={() => {
dispatch(removeComment(comment.id));
hideDeleteConfirmation();
}}
/>
{!comment.abuseFlagged && (
<Confirmation
isOpen={isReporting}
title={intl.formatMessage(messages.reportResponseTitle)}
description={intl.formatMessage(messages.reportResponseDescription)}
onClose={hideReportConfirmation}
comfirmAction={handleReportConfirmation}
confirmButtonVariant="danger"
/>
)}
<EndorsedAlertBanner postType={postType} content={comment} />
<div
className="d-flex flex-column post-card-comment px-4 pt-3.5 pb-10px"
aria-level={5}
onMouseEnter={() => setShowHoverCard(true)}
onMouseLeave={() => setShowHoverCard(false)}
onFocus={() => setShowHoverCard(true)}
onBlur={() => setShowHoverCard(false)}
>
{showHoverCard && (
<HoverCard
commentOrPost={comment}
actionHandlers={actionHandlers}
handleResponseCommentButton={() => setReplying(true)}
onLike={() => dispatch(editComment(comment.id, { voted: !comment.voted }))}
addResponseCommentButtonMessage={intl.formatMessage(messages.addComment)}
isClosedPost={isClosedPost}
endorseIcons={endorseIcons}
/>
)}
<div className="d-flex flex-column p-4.5">
<AlertBanner content={comment} />
<CommentHeader comment={comment} />
<CommentHeader comment={comment} actionHandlers={actionHandlers} postType={postType} />
{isEditing
? (
<CommentEditor comment={comment} onCloseEditor={() => setEditing(false)} formClasses="pt-3" />
)
: (
<HTMLLoader
cssClassName="comment-body html-loader text-break mt-14px font-style-normal font-family-inter text-primary-500"
componentId="comment"
htmlNode={comment.renderedBody}
testId={comment.id}
/>
)}
: <HTMLLoader cssClassName="comment-body pt-4 text-primary-500" componentId="comment" htmlNode={comment.renderedBody} />}
<CommentIcons
comment={comment}
following={comment.following}
onLike={() => dispatch(editComment(comment.id, { voted: !comment.voted }))}
createdAt={comment.createdAt}
/>
{inlineReplies.length > 0 && (
<div className="d-flex flex-column mt-0.5" role="list">
{/* Pass along intl since component used here is the one before it's injected with `injectIntl` */}
{inlineReplies.map(inlineReply => (
<Reply
reply={inlineReply}
postType={postType}
key={inlineReply.id}
intl={intl}
/>
))}
</div>
)}
<div className="sr-only" role="heading" aria-level="3"> {intl.formatMessage(messages.replies, { count: inlineReplies.length })}</div>
<div className="d-flex flex-column" role="list">
{/* Pass along intl since component used here is the one before it's injected with `injectIntl` */}
{inlineReplies.map(inlineReply => (
<Reply
reply={inlineReply}
postType={postType}
key={inlineReply.id}
intl={intl}
/>
))}
</div>
{hasMorePages && (
<Button
onClick={handleLoadMoreComments}
variant="link"
block="true"
className="font-size-14 font-style-normal font-family-inter pt-10px border-0 font-weight-500 pb-0"
data-testid="load-more-comments-responses"
style={{
lineHeight: '20px',
}}
>
{intl.formatMessage(messages.loadMoreComments)}
</Button>
<Button
onClick={handleLoadMoreComments}
variant="link"
block="true"
className="mt-4.5 font-size-14 font-style-normal font-family-inter font-weight-500 px-2.5 py-2"
data-testid="load-more-comments-responses"
style={{
lineHeight: '20px',
}}
>
{intl.formatMessage(messages.loadMoreComments)}
</Button>
)}
{!isNested && showFullThread && (
isReplying ? (
<div className="mt-2.5">
<CommentEditor
comment={{
threadId: comment.threadId,
parentId: comment.id,
}}
edit={false}
onCloseEditor={() => setReplying(false)}
/>
</div>
<CommentEditor
comment={{
threadId: comment.threadId,
parentId: comment.id,
}}
edit={false}
onCloseEditor={() => setReplying(false)}
/>
) : (
<>
{!isClosedPost && userCanAddThreadInBlackoutDate && (inlineReplies.length >= 5)
{(!isClosedPost && !inBlackoutDateRange(blackoutDateRange))
&& (
<Button
className="d-flex flex-grow mt-2 font-size-14 font-style-normal font-family-inter font-weight-500 text-primary-500"
variant="plain"
className="d-flex flex-grow mt-3 py-2 font-size-14"
variant="outline-primary"
style={{
lineHeight: '24px',
height: '36px',
lineHeight: '20px',
}}
onClick={() => setReplying(true)}
>
@@ -214,6 +144,7 @@ function Comment({
</Button>
)}
</>
)
)}
</div>
@@ -228,13 +159,11 @@ Comment.propTypes = {
showFullThread: PropTypes.bool,
isClosedPost: PropTypes.bool,
intl: intlShape.isRequired,
marginBottom: PropTypes.bool,
};
Comment.defaultProps = {
showFullThread: true,
isClosedPost: false,
marginBottom: true,
};
export default injectIntl(Comment);

View File

@@ -1,24 +1,46 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useSelector } from 'react-redux';
import { injectIntl } from '@edx/frontend-platform/i18n';
import { Avatar } from '@edx/paragon';
import { logError } from '@edx/frontend-platform/logging';
import {
Avatar, Icon,
} from '@edx/paragon';
import { AvatarOutlineAndLabelColors } from '../../../data/constants';
import { AvatarOutlineAndLabelColors, EndorsementStatus, ThreadType } from '../../../data/constants';
import { AuthorLabel } from '../../common';
import ActionsDropdown from '../../common/ActionsDropdown';
import { useAlertBannerVisible } from '../../data/hooks';
import { selectAuthorAvatars } from '../../posts/data/selectors';
import { useActions } from '../../utils';
import { commentShape } from './proptypes';
function CommentHeader({
comment,
postType,
actionHandlers,
}) {
const authorAvatars = useSelector(selectAuthorAvatars(comment.author));
const colorClass = AvatarOutlineAndLabelColors[comment.authorLabel];
const hasAnyAlert = useAlertBannerVisible(comment);
const actions = useActions({
...comment,
postType,
});
const actionIcons = actions.find(({ action }) => action === EndorsementStatus.ENDORSED);
const handleIcons = (action) => {
const actionFunction = actionHandlers[action];
if (actionFunction) {
actionFunction();
} else {
logError(`Unknown or unimplemented action ${action}`);
}
};
return (
<div className={classNames('d-flex flex-row justify-content-between', {
'mt-2': hasAnyAlert,
@@ -39,8 +61,32 @@ function CommentHeader({
authorLabel={comment.authorLabel}
labelColor={colorClass && `text-${colorClass}`}
linkToProfile
postCreatedAt={comment.createdAt}
postOrComment
/>
</div>
<div className="d-flex align-items-center">
{actionIcons && (
<span className="btn-icon btn-icon-sm mr-1 align-items-center">
<Icon
data-testid="check-icon"
onClick={
() => {
handleIcons(actionIcons.action);
}
}
src={actionIcons.icon}
className={['endorse', 'unendorse'].includes(actionIcons.id) ? 'text-dark-500' : 'text-success-500'}
size="sm"
/>
</span>
)}
<ActionsDropdown
commentOrPost={{
...comment,
postType,
}}
actionHandlers={actionHandlers}
/>
</div>
</div>
@@ -49,6 +95,8 @@ function CommentHeader({
CommentHeader.propTypes = {
comment: commentShape.isRequired,
actionHandlers: PropTypes.objectOf(PropTypes.func).isRequired,
postType: PropTypes.oneOf([ThreadType.QUESTION, ThreadType.DISCUSSION]).isRequired,
};
export default injectIntl(CommentHeader);

View File

@@ -0,0 +1,57 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { IntlProvider } from 'react-intl';
import { initializeMockApp } from '@edx/frontend-platform';
import { AppProvider } from '@edx/frontend-platform/react';
import { initializeStore } from '../../../store';
import { DiscussionContext } from '../../common/context';
import CommentHeader from './CommentHeader';
let store;
function renderComponent(comment, postType, actionHandlers) {
return render(
<IntlProvider locale="en">
<AppProvider store={store}>
<DiscussionContext.Provider
value={{ courseId: 'course-v1:edX+TestX+Test_Course' }}
>
<CommentHeader comment={comment} postType={postType} actionHandlers={actionHandlers} />
</DiscussionContext.Provider>
</AppProvider>
</IntlProvider>,
);
}
const mockComment = {
author: 'abc123',
authorLabel: 'ABC 123',
endorsed: true,
editableFields: ['endorsed'],
};
describe('Comment Header', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
});
it('should render verified icon for endorsed discussion posts', () => {
renderComponent(mockComment, 'discussion', {});
expect(screen.queryAllByTestId('check-icon')).toHaveLength(1);
});
it('should render check icon for endorsed question posts', () => {
renderComponent(mockComment, 'question', {});
expect(screen.queryAllByTestId('check-icon')).toHaveLength(1);
});
});

View File

@@ -10,7 +10,7 @@ import { Avatar, useToggle } from '@edx/paragon';
import HTMLLoader from '../../../components/HTMLLoader';
import { AvatarOutlineAndLabelColors, ContentActions } from '../../../data/constants';
import {
ActionsDropdown, AlertBanner, AuthorLabel, Confirmation,
ActionsDropdown, AlertBanner, AuthorLabel, DeleteConfirmation,
} from '../../common';
import timeLocale from '../../common/time-locale';
import { useAlertBannerVisible } from '../../data/hooks';
@@ -29,26 +29,6 @@ function Reply({
const dispatch = useDispatch();
const [isEditing, setEditing] = useState(false);
const [isDeleting, showDeleteConfirmation, hideDeleteConfirmation] = useToggle(false);
const [isReporting, showReportConfirmation, hideReportConfirmation] = useToggle(false);
const handleAbusedFlag = () => {
if (reply.abuseFlagged) {
dispatch(editComment(reply.id, { flagged: !reply.abuseFlagged }));
} else {
showReportConfirmation();
}
};
const handleDeleteConfirmation = () => {
dispatch(removeComment(reply.id));
hideDeleteConfirmation();
};
const handleReportConfirmation = () => {
dispatch(editComment(reply.id, { flagged: !reply.abuseFlagged }));
hideReportConfirmation();
};
const actionHandlers = {
[ContentActions.EDIT_CONTENT]: () => setEditing(true),
[ContentActions.ENDORSE]: () => dispatch(editComment(
@@ -57,33 +37,25 @@ function Reply({
ContentActions.ENDORSE,
)),
[ContentActions.DELETE]: showDeleteConfirmation,
[ContentActions.REPORT]: () => handleAbusedFlag(),
[ContentActions.REPORT]: () => dispatch(editComment(reply.id, { flagged: !reply.abuseFlagged })),
};
const authorAvatars = useSelector(selectAuthorAvatars(reply.author));
const colorClass = AvatarOutlineAndLabelColors[reply.authorLabel];
const hasAnyAlert = useAlertBannerVisible(reply);
return (
<div className="d-flex flex-column mt-2.5 " data-testid={`reply-${reply.id}`} role="listitem">
<Confirmation
<div className="d-flex flex-column mt-4.5" data-testid={`reply-${reply.id}`} role="listitem">
<DeleteConfirmation
isOpen={isDeleting}
title={intl.formatMessage(messages.deleteCommentTitle)}
description={intl.formatMessage(messages.deleteCommentDescription)}
onClose={hideDeleteConfirmation}
comfirmAction={handleDeleteConfirmation}
closeButtonVaraint="tertiary"
confirmButtonText={intl.formatMessage(messages.deleteConfirmationDelete)}
onDelete={() => {
dispatch(removeComment(reply.id));
hideDeleteConfirmation();
}}
/>
{!reply.abuseFlagged && (
<Confirmation
isOpen={isReporting}
title={intl.formatMessage(messages.reportCommentTitle)}
description={intl.formatMessage(messages.reportCommentDescription)}
onClose={hideReportConfirmation}
comfirmAction={handleReportConfirmation}
confirmButtonVariant="danger"
/>
)}
{hasAnyAlert && (
<div className="d-flex">
<div className="d-flex invisible">
@@ -108,41 +80,27 @@ function Reply({
/>
</div>
<div
className="bg-light-300 pl-4 pt-2.5 pr-2.5 pb-10px flex-fill"
className="bg-light-300 px-4 pb-2 pt-2.5 flex-fill"
style={{ borderRadius: '0rem 0.375rem 0.375rem' }}
>
<div className="d-flex flex-row justify-content-between" style={{ height: '24px' }}>
<AuthorLabel
author={reply.author}
authorLabel={reply.authorLabel}
labelColor={colorClass && `text-${colorClass}`}
linkToProfile
postCreatedAt={reply.createdAt}
postOrComment
<div className="d-flex flex-row justify-content-between align-items-center mb-0.5">
<AuthorLabel author={reply.author} authorLabel={reply.authorLabel} labelColor={colorClass && `text-${colorClass}`} linkToProfile />
<ActionsDropdown
commentOrPost={{
...reply,
postType,
}}
actionHandlers={actionHandlers}
/>
<div className="ml-auto d-flex" style={{ lineHeight: '24px' }}>
<ActionsDropdown
commentOrPost={{
...reply,
postType,
}}
actionHandlers={actionHandlers}
iconSize="inline"
/>
</div>
</div>
{isEditing
? <CommentEditor comment={reply} onCloseEditor={() => setEditing(false)} />
: (
<HTMLLoader
componentId="reply"
htmlNode={reply.renderedBody}
cssClassName="html-loader text-break font-style-normal pb-1 font-family-inter text-primary-500"
testId={reply.id}
/>
)}
: <HTMLLoader componentId="reply" htmlNode={reply.renderedBody} cssClassName="text-primary-500" />}
</div>
</div>
<div className="text-gray-500 align-self-end mt-2" title={reply.createdAt}>
{timeago.format(reply.createdAt, 'time-locale')}
</div>
</div>
);
}

View File

@@ -1,39 +1,62 @@
import React, { useEffect } from 'react';
import React, { useContext, useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useSelector } from 'react-redux';
import { injectIntl } from '@edx/frontend-platform/i18n';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button } from '@edx/paragon';
import { DiscussionContext } from '../../common/context';
import { selectBlackoutDate } from '../../data/selectors';
import { inBlackoutDateRange } from '../../utils';
import messages from '../messages';
import CommentEditor from './CommentEditor';
function ResponseEditor({
postId,
intl,
addWrappingDiv,
handleCloseEditor,
addingResponse,
}) {
const { inContext } = useContext(DiscussionContext);
const [addingResponse, setAddingResponse] = useState(false);
useEffect(() => {
handleCloseEditor();
setAddingResponse(false);
}, [postId]);
const blackoutDateRange = useSelector(selectBlackoutDate);
return addingResponse
&& (
? (
<div className={classNames({ 'bg-white p-4 mb-4 rounded': addWrappingDiv })}>
<CommentEditor
comment={{ threadId: postId }}
edit={false}
onCloseEditor={handleCloseEditor}
onCloseEditor={() => setAddingResponse(false)}
/>
</div>
)
: !inBlackoutDateRange(blackoutDateRange) && (
<div className={classNames({ 'mb-4': addWrappingDiv }, 'actions d-flex')}>
<Button
variant="primary"
className={classNames('px-2.5 py-2 font-size-14', { 'w-100': inContext })}
onClick={() => setAddingResponse(true)}
style={{
lineHeight: '20px',
}}
>
{intl.formatMessage(messages.addResponse)}
</Button>
</div>
);
}
ResponseEditor.propTypes = {
postId: PropTypes.string.isRequired,
intl: intlShape.isRequired,
addWrappingDiv: PropTypes.bool,
handleCloseEditor: PropTypes.func.isRequired,
addingResponse: PropTypes.bool.isRequired,
};
ResponseEditor.defaultProps = {

View File

@@ -1,15 +1,15 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
addComment: {
id: 'discussions.comments.comment.addComment',
defaultMessage: 'Add comment',
description: 'Button to add a comment to a response',
},
addResponse: {
id: 'discussions.comments.comment.addResponse',
defaultMessage: 'Add a response',
description: 'Button to add a response to a response',
description: 'Button to add a response in a thread of forum posts',
},
addComment: {
id: 'discussions.comments.comment.addComment',
defaultMessage: 'Add a comment',
description: 'Button to add a comment to a response',
},
abuseFlaggedMessage: {
id: 'discussions.comments.comment.abuseFlaggedMessage',
@@ -148,31 +148,6 @@ const messages = defineMessages({
defaultMessage: 'Are you sure you want to permanently delete this comment?',
description: 'Text displayed in confirmation dialog when deleting a comment',
},
deleteConfirmationDelete: {
id: 'discussions.delete.confirmation.button.delete',
defaultMessage: 'Delete',
description: 'Delete button shown on delete confirmation dialog',
},
reportResponseTitle: {
id: 'discussions.editor.response.response.title',
defaultMessage: 'Report inappropriate content?',
description: 'Title of confirmation dialog shown when reporting a response',
},
reportResponseDescription: {
id: 'discussions.editor.response.description',
defaultMessage: 'The discussion moderation team will review this content and take appropriate action.',
description: 'Text displayed in confirmation dialog when deleting a response',
},
reportCommentTitle: {
id: 'discussions.editor.report.comment.title',
defaultMessage: 'Report inappropriate content?',
description: 'Title of confirmation dialog shown when reporting a comment',
},
reportCommentDescription: {
id: 'discussions.editor.report.comment.description',
defaultMessage: 'The discussion moderation team will review this content and take appropriate action.',
description: 'Text displayed in confirmation dialog when deleting a response',
},
editReasonCode: {
id: 'discussions.editor.comments.editReasonCode',
defaultMessage: 'Reason for editing',
@@ -188,11 +163,6 @@ const messages = defineMessages({
defaultMessage: 'Edited by',
description: 'Text shown to users to indicate who edited a post. Followed by the username of editor.',
},
fullStop: {
id: 'discussions.comment.comments.fullStop',
defaultMessage: '•',
description: 'Fullstop shown to users to indicate who edited a post. Followed by a reason.',
},
reason: {
id: 'discussions.comment.comments.reason',
defaultMessage: 'Reason',
@@ -202,6 +172,11 @@ const messages = defineMessages({
id: 'discussions.post.closedBy',
defaultMessage: 'Post closed by',
},
replies: {
id: 'discussion.comment.repliesHeading',
defaultMessage: '{count} replies for the response added',
description: 'Text added for screen reader to understand nesting replies.',
},
time: {
id: 'discussion.comment.time',
defaultMessage: '{time} ago',

View File

@@ -23,13 +23,11 @@ function ActionsDropdown({
commentOrPost,
disabled,
actionHandlers,
iconSize,
dropDownIconSize,
}) {
const [isOpen, open, close] = useToggle(false);
const [target, setTarget] = useState(null);
const actions = useActions(commentOrPost);
const { enableInContextSidebar } = useContext(DiscussionContext);
const { inContext } = useContext(DiscussionContext);
const handleActions = (action) => {
const actionFunction = actionHandlers[action];
if (actionFunction) {
@@ -51,43 +49,40 @@ function ActionsDropdown({
src={MoreHoriz}
iconAs={Icon}
disabled={disabled}
size={iconSize}
size="sm"
ref={setTarget}
iconClassNames={dropDownIconSize ? 'dropdown-icon-dimentions' : ''}
/>
<div className="actions-dropdown">
<ModalPopup
onClose={close}
positionRef={target}
isOpen={isOpen}
placement={enableInContextSidebar ? 'left' : 'auto-start'}
<ModalPopup
onClose={close}
positionRef={target}
isOpen={isOpen}
placement={inContext ? 'left' : 'auto-start'}
>
<div
className="bg-white p-1 shadow d-flex flex-column"
data-testid="actions-dropdown-modal-popup"
>
<div
className="bg-white p-1 shadow d-flex flex-column"
data-testid="actions-dropdown-modal-popup"
>
{actions.map(action => (
<React.Fragment key={action.id}>
{(action.action === ContentActions.DELETE)
&& <Dropdown.Divider />}
{actions.map(action => (
<React.Fragment key={action.id}>
{action.action === ContentActions.DELETE
&& <Dropdown.Divider />}
<Dropdown.Item
as={Button}
variant="tertiary"
size="inline"
onClick={() => {
close();
handleActions(action.action);
}}
className="d-flex justify-content-start py-1.5 mr-4"
>
<Icon src={action.icon} className="mr-1" /> {intl.formatMessage(action.label)}
</Dropdown.Item>
</React.Fragment>
))}
</div>
</ModalPopup>
</div>
<Dropdown.Item
as={Button}
variant="tertiary"
size="inline"
onClick={() => {
close();
handleActions(action.action);
}}
className="d-flex justify-content-start py-1.5 mr-4"
>
<Icon src={action.icon} className="mr-1" /> {intl.formatMessage(action.label)}
</Dropdown.Item>
</React.Fragment>
))}
</div>
</ModalPopup>
</>
);
}
@@ -97,14 +92,10 @@ ActionsDropdown.propTypes = {
commentOrPost: PropTypes.oneOfType([commentShape, postShape]).isRequired,
disabled: PropTypes.bool,
actionHandlers: PropTypes.objectOf(PropTypes.func).isRequired,
iconSize: PropTypes.string,
dropDownIconSize: PropTypes.bool,
};
ActionsDropdown.defaultProps = {
disabled: false,
iconSize: 'sm',
dropDownIconSize: false,
};
export default injectIntl(ActionsDropdown);

View File

@@ -6,13 +6,11 @@ import { useSelector } from 'react-redux';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Alert } from '@edx/paragon';
import { Report } from '@edx/paragon/icons';
import { Error } from '@edx/paragon/icons';
import { commentShape } from '../comments/comment/proptypes';
import messages from '../comments/messages';
import {
selectModerationSettings, selectUserHasModerationPrivileges, selectUserIsGroupTa, selectUserIsStaff,
} from '../data/selectors';
import { selectModerationSettings, selectUserHasModerationPrivileges, selectUserIsGroupTa } from '../data/selectors';
import { postShape } from '../posts/post/proptypes';
import AuthorLabel from './AuthorLabel';
@@ -22,56 +20,40 @@ function AlertBanner({
}) {
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
const userIsGroupTa = useSelector(selectUserIsGroupTa);
const userIsGlobalStaff = useSelector(selectUserIsStaff);
const { reasonCodesEnabled } = useSelector(selectModerationSettings);
const userIsContentAuthor = getAuthenticatedUser().username === content.author;
const canSeeLastEditOrClosedAlert = (userHasModerationPrivileges || userIsContentAuthor || userIsGroupTa);
const canSeeReportedBanner = content?.abuseFlagged;
const canSeeLastEditOrClosedAlert = (userHasModerationPrivileges || userIsGroupTa
|| userIsGlobalStaff || userIsContentAuthor
);
return (
<>
{canSeeReportedBanner && (
<Alert icon={Report} variant="danger" className="px-3 mb-1 py-10px shadow-none flex-fill">
<Alert icon={Error} variant="danger" className="px-3 mb-2 py-10px shadow-none flex-fill">
{intl.formatMessage(messages.abuseFlaggedMessage)}
</Alert>
)}
{reasonCodesEnabled && canSeeLastEditOrClosedAlert && (
<>
{content.lastEdit?.reason && (
<Alert variant="info" className="px-3 shadow-none mb-1 py-10px bg-light-200">
<div className="d-flex align-items-center flex-wrap text-gray-700 font-family-inter">
<Alert variant="info" className="px-3 shadow-none mb-2 py-10px bg-light-200">
<div className="d-flex align-items-center flex-wrap">
{intl.formatMessage(messages.editedBy)}
<span className="ml-1 mr-3">
<AuthorLabel author={content.lastEdit.editorUsername} linkToProfile postOrComment />
</span>
<span
className="mx-1.5 font-family-inter font-size-8 font-style-normal text-light-700"
style={{ lineHeight: '15px' }}
>
{intl.formatMessage(messages.fullStop)}
<AuthorLabel author={content.lastEdit.editorUsername} linkToProfile />
</span>
{intl.formatMessage(messages.reason)}:&nbsp;{content.lastEdit.reason}
</div>
</Alert>
)}
{content.closed && (
<Alert variant="info" className="px-3 shadow-none mb-1 py-10px bg-light-200">
<div className="d-flex align-items-center flex-wrap text-gray-700 font-family-inter">
<Alert variant="info" className="px-3 shadow-none mb-2 py-10px bg-light-200">
<div className="d-flex align-items-center flex-wrap">
{intl.formatMessage(messages.closedBy)}
<span className="ml-1 ">
<AuthorLabel author={content.closedBy} linkToProfile postOrComment />
<AuthorLabel author={content.closedBy} linkToProfile />
</span>
<span
className="mx-1.5 font-family-inter font-size-8 font-style-normal text-light-700"
style={{ lineHeight: '15px' }}
>
{intl.formatMessage(messages.fullStop)}
</span>
<span className="mx-1" />
{content.closeReason && (`${intl.formatMessage(messages.reason)}: ${content.closeReason}`)}
</div>
</Alert>
)}

View File

@@ -3,10 +3,9 @@ import PropTypes from 'prop-types';
import classNames from 'classnames';
import { Link, useLocation } from 'react-router-dom';
import * as timeago from 'timeago.js';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Icon, OverlayTrigger, Tooltip } from '@edx/paragon';
import { Icon } from '@edx/paragon';
import { Institution, School } from '@edx/paragon/icons';
import { Routes } from '../../data/constants';
@@ -14,7 +13,6 @@ import { useShowLearnersTab } from '../data/hooks';
import messages from '../messages';
import { discussionsPath } from '../utils';
import { DiscussionContext } from './context';
import timeLocale from './time-locale';
function AuthorLabel({
intl,
@@ -23,15 +21,11 @@ function AuthorLabel({
linkToProfile,
labelColor,
alert,
postCreatedAt,
authorToolTip,
postOrComment,
}) {
const location = useLocation();
const { courseId } = useContext(DiscussionContext);
let icon = null;
let authorLabelMessage = null;
timeago.register('time-locale', timeLocale);
if (authorLabel === 'Staff') {
icon = Institution;
@@ -43,56 +37,37 @@ function AuthorLabel({
}
const isRetiredUser = author ? author.startsWith('retired__user') : false;
const showTextPrimary = !authorLabelMessage && !isRetiredUser && !alert;
const className = classNames('d-flex align-items-center', { 'mb-0.5': !postOrComment }, labelColor);
const className = classNames('d-flex align-items-center', labelColor);
const showUserNameAsLink = useShowLearnersTab()
&& linkToProfile && author && author !== intl.formatMessage(messages.anonymous);
&& linkToProfile && author && author !== intl.formatMessage(messages.anonymous);
const labelContents = (
<div className={className} style={{ lineHeight: '24px' }}>
{!alert && (
<span
className={classNames('mr-1.5 font-size-14 font-style-normal font-family-inter font-weight-500', {
'text-gray-700': isRetiredUser,
'text-primary-500': !authorLabelMessage && !isRetiredUser,
})}
role="heading"
aria-level="2"
>
{isRetiredUser ? '[Deactivated]' : author}
</span>
)}
<OverlayTrigger
overlay={(
<Tooltip id={`endorsed-by-${author}-tooltip`}>
{author}
</Tooltip>
)}
trigger={['hover', 'focus']}
>
<div className={classNames('d-flex flex-row align-items-center', {
'disable-div': !authorToolTip,
<div className={className}>
<span
className={classNames('mr-1 font-size-14 font-style-normal font-family-inter font-weight-500', {
'text-gray-700': isRetiredUser,
'text-primary-500': !authorLabelMessage && !isRetiredUser && !alert,
})}
>
<Icon
style={{
width: '1rem',
height: '1rem',
}}
src={icon}
data-testid="author-icon"
/>
</div>
</OverlayTrigger>
role="heading"
aria-level="2"
>
{isRetiredUser ? '[Deactivated]' : author }
</span>
{icon && (
<Icon
style={{
width: '1rem',
height: '1rem',
}}
src={icon}
/>
)}
{authorLabelMessage && (
<span
className={classNames('mr-1.5 font-size-14 font-style-normal font-family-inter font-weight-500', {
'text-primary-500': showTextPrimary,
className={classNames('mr-1 font-size-14 font-style-normal font-family-inter font-weight-500', {
'text-primary-500': !authorLabelMessage && !isRetiredUser && !alert,
'text-gray-700': isRetiredUser,
})}
style={{ marginLeft: '2px' }}
@@ -100,19 +75,6 @@ function AuthorLabel({
{authorLabelMessage}
</span>
)}
{postCreatedAt && (
<span
title={postCreatedAt}
className={classNames('font-family-inter align-content-center', {
'text-white': alert,
'text-gray-500': !alert,
})}
style={{ lineHeight: '20px', fontSize: '12px', marginBottom: '-2.3px' }}
>
{timeago.format(postCreatedAt, 'time-locale')}
</span>
)}
</div>
);
@@ -138,9 +100,6 @@ AuthorLabel.propTypes = {
linkToProfile: PropTypes.bool,
labelColor: PropTypes.string,
alert: PropTypes.bool,
postCreatedAt: PropTypes.string,
authorToolTip: PropTypes.bool,
postOrComment: PropTypes.bool,
};
AuthorLabel.defaultProps = {
@@ -148,9 +107,6 @@ AuthorLabel.defaultProps = {
authorLabel: null,
labelColor: '',
alert: false,
postCreatedAt: null,
authorToolTip: false,
postOrComment: false,
};
export default injectIntl(AuthorLabel);

View File

@@ -6,16 +6,13 @@ import { ActionRow, Button, ModalDialog } from '@edx/paragon';
import messages from '../messages';
function Confirmation({
function DeleteConfirmation({
intl,
isOpen,
title,
description,
onClose,
comfirmAction,
closeButtonVaraint,
confirmButtonVariant,
confirmButtonText,
onDelete,
}) {
return (
<ModalDialog title={title} isOpen={isOpen} hasCloseButton={false} onClose={onClose} zIndex={5000}>
@@ -29,11 +26,11 @@ function Confirmation({
</ModalDialog.Body>
<ModalDialog.Footer>
<ActionRow>
<ModalDialog.CloseButton variant={closeButtonVaraint}>
{intl.formatMessage(messages.confirmationCancel)}
<ModalDialog.CloseButton variant="tertiary">
{intl.formatMessage(messages.deleteConfirmationCancel)}
</ModalDialog.CloseButton>
<Button variant={confirmButtonVariant} onClick={comfirmAction}>
{ confirmButtonText || intl.formatMessage(messages.confirmationConfirm)}
<Button variant="primary" onClick={onDelete}>
{intl.formatMessage(messages.deleteConfirmationDelete)}
</Button>
</ActionRow>
</ModalDialog.Footer>
@@ -41,22 +38,13 @@ function Confirmation({
);
}
Confirmation.propTypes = {
DeleteConfirmation.propTypes = {
intl: intlShape.isRequired,
isOpen: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
comfirmAction: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
title: PropTypes.string.isRequired,
description: PropTypes.string.isRequired,
closeButtonVaraint: PropTypes.string,
confirmButtonVariant: PropTypes.string,
confirmButtonText: PropTypes.string,
};
Confirmation.defaultProps = {
closeButtonVaraint: 'default',
confirmButtonVariant: 'primary',
confirmButtonText: '',
};
export default injectIntl(Confirmation);
export default injectIntl(DeleteConfirmation);

View File

@@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
import * as timeago from 'timeago.js';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Alert, Icon } from '@edx/paragon';
import { Alert } from '@edx/paragon';
import { CheckCircle, Verified } from '@edx/paragon/icons';
import { ThreadType } from '../../data/constants';
@@ -27,35 +27,32 @@ function EndorsedAlertBanner({
content.endorsed && (
<Alert
variant="plain"
className={`px-2.5 mb-0 py-8px align-items-center shadow-none ${classes}`}
className={`px-3 mb-0 py-10px align-items-center shadow-none ${classes}`}
style={{ borderRadius: '0.375rem 0.375rem 0 0' }}
icon={iconClass}
>
<div className="d-flex justify-content-between flex-wrap">
<div className="d-flex align-items-center">
<Icon
src={iconClass}
style={{
width: '21px',
height: '20px',
}}
/>
<strong className="ml-2 font-family-inter">{intl.formatMessage(
isQuestion
? messages.answer
: messages.endorsed,
)}
</strong>
</div>
<span className="d-flex align-items-center align-items-center flex-wrap" style={{ marginRight: '-1px' }}>
<strong className="lead">{intl.formatMessage(
isQuestion
? messages.answer
: messages.endorsed,
)}
</strong>
<span className="d-flex align-items-center mr-1 flex-wrap">
<span className="mr-1">
{intl.formatMessage(
isQuestion
? messages.answeredLabel
: messages.endorsedLabel,
)}
</span>
<AuthorLabel
author={content.endorsedBy}
authorLabel={content.endorsedByLabel}
linkToProfile
alert={content.endorsed}
postCreatedAt={content.endorsedAt}
authorToolTip
postOrComment
/>
{intl.formatMessage(messages.time, { time: timeago.format(content.endorsedAt, 'time-locale') })}
</span>
</div>
</Alert>

View File

@@ -46,21 +46,21 @@ describe.each([
type: 'comment',
postType: ThreadType.QUESTION,
props: { endorsed: true, endorsedBy: 'test-user', endorsedByLabel: 'Staff' },
expectText: [messages.answer.defaultMessage, 'Staff'],
expectText: [messages.answer.defaultMessage, messages.answeredLabel.defaultMessage, 'test-user', 'Staff'],
},
{
label: 'TA endorsed comment in a question thread',
type: 'comment',
postType: ThreadType.QUESTION,
props: { endorsed: true, endorsedBy: 'test-user', endorsedByLabel: 'Community TA' },
expectText: [messages.answer.defaultMessage, 'TA'],
expectText: [messages.answer.defaultMessage, messages.answeredLabel.defaultMessage, 'test-user', 'TA'],
},
{
label: 'endorsed comment in a discussion thread',
type: 'comment',
postType: ThreadType.DISCUSSION,
props: { endorsed: true, endorsedBy: 'test-user' },
expectText: [messages.endorsed.defaultMessage],
expectText: [messages.endorsed.defaultMessage, messages.endorsedLabel.defaultMessage, 'test-user'],
},
])('EndorsedAlertBanner', ({
label, type, postType, props, expectText,

View File

@@ -1,123 +0,0 @@
import React, { useContext } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { injectIntl } from '@edx/frontend-platform/i18n';
import {
Button,
Icon, IconButton,
} from '@edx/paragon';
import {
StarFilled, StarOutline, ThumbUpFilled, ThumbUpOutline,
} from '../../components/icons';
import { commentShape } from '../comments/comment/proptypes';
import { useUserCanAddThreadInBlackoutDate } from '../data/hooks';
import { postShape } from '../posts/post/proptypes';
import ActionsDropdown from './ActionsDropdown';
import { DiscussionContext } from './context';
function HoverCard({
commentOrPost,
actionHandlers,
handleResponseCommentButton,
addResponseCommentButtonMessage,
onLike,
onFollow,
isClosedPost,
endorseIcons,
}) {
const { enableInContextSidebar } = useContext(DiscussionContext);
const userCanAddThreadInBlackoutDate = useUserCanAddThreadInBlackoutDate();
return (
<div
className="d-flex flex-fill justify-content-end align-items-center hover-card mr-n4 position-absolute"
data-testid="hover-card"
>
{userCanAddThreadInBlackoutDate && (
<div className="d-flex">
<Button
variant="tertiary"
className={classNames('px-2.5 py-2 border-0 font-style-normal font-family-inter text-gray-700 font-size-12',
{ 'w-100': enableInContextSidebar })}
onClick={() => handleResponseCommentButton()}
disabled={isClosedPost}
style={{
lineHeight: '20px',
}}
>
{addResponseCommentButtonMessage}
</Button>
</div>
)}
{endorseIcons && (
<div className="hover-button">
<IconButton
src={endorseIcons.icon}
iconAs={Icon}
onClick={() => {
const actionFunction = actionHandlers[endorseIcons.action];
actionFunction();
}}
className={['endorse', 'unendorse'].includes(endorseIcons.id) ? 'text-dark-500' : 'text-success-500'}
size="sm"
alt="Endorse"
/>
</div>
)}
<div className="hover-button">
<IconButton
src={commentOrPost.voted ? ThumbUpFilled : ThumbUpOutline}
iconAs={Icon}
size="sm"
alt="Like"
iconClassNames="like-icon-dimentions"
onClick={(e) => {
e.preventDefault();
onLike();
}}
/>
</div>
{commentOrPost.following !== undefined && (
<div className="hover-button">
<IconButton
src={commentOrPost.following ? StarFilled : StarOutline}
iconAs={Icon}
size="sm"
alt="Follow"
iconClassNames="follow-icon-dimentions"
onClick={(e) => {
e.preventDefault();
onFollow();
return true;
}}
/>
</div>
)}
<div className="hover-button ml-auto">
<ActionsDropdown commentOrPost={commentOrPost} actionHandlers={actionHandlers} dropDownIconSize />
</div>
</div>
);
}
HoverCard.propTypes = {
commentOrPost: PropTypes.oneOfType([commentShape, postShape]).isRequired,
actionHandlers: PropTypes.objectOf(PropTypes.func).isRequired,
handleResponseCommentButton: PropTypes.func.isRequired,
onLike: PropTypes.func.isRequired,
onFollow: PropTypes.func,
addResponseCommentButtonMessage: PropTypes.string.isRequired,
isClosedPost: PropTypes.bool.isRequired,
endorseIcons: PropTypes.objectOf(PropTypes.any),
};
HoverCard.defaultProps = {
onFollow: () => null,
endorseIcons: null,
};
export default injectIntl(HoverCard);

View File

@@ -1,194 +0,0 @@
import {
render, screen, waitFor, within,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import MockAdapter from 'axios-mock-adapter';
import { IntlProvider } from 'react-intl';
import { MemoryRouter, Route } from 'react-router';
import { Factory } from 'rosie';
import { camelCaseObject, initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
import { initializeStore } from '../../store';
import { executeThunk } from '../../test-utils';
import { getCommentsApiUrl } from '../comments/data/api';
import DiscussionContent from '../discussions-home/DiscussionContent';
import { getThreadsApiUrl } from '../posts/data/api';
import { fetchThreads } from '../posts/data/thunks';
import { DiscussionContext } from './context';
import '../posts/data/__factories__';
import '../comments/data/__factories__';
const commentsApiUrl = getCommentsApiUrl();
const threadsApiUrl = getThreadsApiUrl();
const discussionPostId = 'thread-1';
const questionPostId = 'thread-2';
const courseId = 'course-v1:edX+TestX+Test_Course';
let store;
let axiosMock;
let container;
function mockAxiosReturnPagedComments() {
[null, false, true].forEach(endorsed => {
const postId = endorsed === null ? discussionPostId : questionPostId;
[1, 2].forEach(page => {
axiosMock
.onGet(commentsApiUrl, {
params: {
thread_id: postId,
page,
page_size: undefined,
requested_fields: 'profile_image',
endorsed,
},
})
.reply(200, Factory.build('commentsResult', { can_delete: true }, {
threadId: postId,
page,
pageSize: 1,
count: 2,
endorsed,
childCount: page === 1 ? 2 : 0,
}));
});
});
}
function mockAxiosReturnPagedCommentsResponses() {
const parentId = 'comment-1';
const commentsResponsesApiUrl = `${commentsApiUrl}${parentId}/`;
const paramsTemplate = {
page: undefined,
page_size: undefined,
requested_fields: 'profile_image',
};
for (let page = 1; page <= 2; page++) {
axiosMock
.onGet(commentsResponsesApiUrl, { params: { ...paramsTemplate, page } })
.reply(200, Factory.build('commentsResult', null, {
parentId,
page,
pageSize: 1,
count: 2,
}));
}
}
function renderComponent(postId) {
const wrapper = render(
<IntlProvider locale="en">
<AppProvider store={store}>
<DiscussionContext.Provider
value={{ courseId }}
>
<MemoryRouter initialEntries={[`/${courseId}/posts/${postId}`]}>
<DiscussionContent />
<Route
path="*"
/>
</MemoryRouter>
</DiscussionContext.Provider>
</AppProvider>
</IntlProvider>,
);
container = wrapper.container;
return container;
}
describe('HoverCard', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
Factory.resetAll();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock.onGet(threadsApiUrl)
.reply(200, Factory.build('threadsResult'));
axiosMock.onPatch(new RegExp(`${commentsApiUrl}*`)).reply(({
url,
data,
}) => {
const commentId = url.match(/comments\/(?<id>[a-z1-9-]+)\//).groups.id;
const {
rawBody,
} = camelCaseObject(JSON.parse(data));
return [200, Factory.build('comment', {
id: commentId,
rendered_body: rawBody,
raw_body: rawBody,
})];
});
axiosMock.onPost(commentsApiUrl)
.reply(({ data }) => {
const {
rawBody,
threadId,
} = camelCaseObject(JSON.parse(data));
return [200, Factory.build(
'comment',
{
rendered_body: rawBody,
raw_body: rawBody,
thread_id: threadId,
},
)];
});
executeThunk(fetchThreads(courseId), store.dispatch, store.getState);
mockAxiosReturnPagedComments();
mockAxiosReturnPagedCommentsResponses();
});
test('it should show hover card when hovered on post', async () => {
renderComponent(discussionPostId);
const post = screen.getByTestId('post-thread-1');
userEvent.hover(post);
expect(screen.getByTestId('hover-card')).toBeInTheDocument();
});
test('it should show hover card when hovered on comment', async () => {
renderComponent(discussionPostId);
const comment = await waitFor(() => screen.findByTestId('comment-1'));
userEvent.hover(comment);
expect(screen.getByTestId('hover-card')).toBeInTheDocument();
});
test('it should not show hover card when post and comment not hovered', async () => {
renderComponent(discussionPostId);
expect(screen.queryByTestId('hover-card')).not.toBeInTheDocument();
});
test('it should show add response, like, follow and actions menu for hovered post', async () => {
renderComponent(discussionPostId);
const post = screen.getByTestId('post-thread-1');
userEvent.hover(post);
const view = screen.getByTestId('hover-card');
expect(within(view).queryByRole('button', { name: /Add response/i })).toBeInTheDocument();
expect(within(view).getByRole('button', { name: /like/i })).toBeInTheDocument();
expect(within(view).queryByRole('button', { name: /follow/i })).toBeInTheDocument();
expect(within(view).queryByRole('button', { name: /actions menu/i })).toBeInTheDocument();
});
test('it should show add comment, Endorse, like and actions menu Buttons for hovered comment', async () => {
renderComponent(questionPostId);
const comment = await waitFor(() => screen.findByTestId('comment-3'));
userEvent.hover(comment);
const view = screen.getByTestId('hover-card');
expect(screen.getByTestId('hover-card')).toBeInTheDocument();
expect(within(view).queryByRole('button', { name: /Add comment/i })).toBeInTheDocument();
expect(within(view).getByRole('button', { name: /Endorse/i })).toBeInTheDocument();
expect(within(view).queryByRole('button', { name: /like/i })).toBeInTheDocument();
expect(within(view).queryByRole('button', { name: /actions menu/i })).toBeInTheDocument();
});
});

View File

@@ -6,7 +6,7 @@ export const DiscussionContext = React.createContext({
courseId: null,
postId: null,
topicId: null,
enableInContextSidebar: false,
inContext: false,
category: null,
learnerUsername: null,
});

View File

@@ -1,5 +1,5 @@
export { default as ActionsDropdown } from './ActionsDropdown';
export { default as AlertBanner } from './AlertBanner';
export { default as AuthorLabel } from './AuthorLabel';
export { default as Confirmation } from './Confirmation';
export { default as DeleteConfirmation } from './DeleteConfirmation';
export { default as EndorsedAlertBanner } from './EndorsedAlertBanner';

View File

@@ -1,9 +1,6 @@
/* eslint-disable import/prefer-default-export */
import {
useContext,
useEffect,
useRef,
useState,
useContext, useEffect, useRef, useState,
} from 'react';
import { useDispatch, useSelector } from 'react-redux';
@@ -13,30 +10,20 @@ import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { AppContext } from '@edx/frontend-platform/react';
import { breakpoints, useWindowSize } from '@edx/paragon';
import { RequestStatus, Routes } from '../../data/constants';
import { Routes } from '../../data/constants';
import { selectTopicsUnderCategory } from '../../data/selectors';
import { fetchCourseBlocks } from '../../data/thunks';
import { DiscussionContext } from '../common/context';
import { clearRedirect } from '../posts/data';
import { threadsLoadingStatus } from '../posts/data/selectors';
import { selectTopics } from '../topics/data/selectors';
import tourCheckpoints from '../tours/constants';
import { selectTours } from '../tours/data/selectors';
import { updateTourShowStatus } from '../tours/data/thunks';
import messages from '../tours/messages';
import { discussionsPath, inBlackoutDateRange } from '../utils';
import { fetchCourseTopics } from '../topics/data/thunks';
import { discussionsPath } from '../utils';
import {
selectAreThreadsFiltered,
selectBlackoutDate,
selectEnableInContext,
selectIsCourseAdmin,
selectIsCourseStaff,
selectLearnersTabEnabled,
selectAreThreadsFiltered, selectLearnersTabEnabled,
selectModerationSettings,
selectPostThreadCount,
selectUserHasModerationPrivileges,
selectUserIsGroupTa,
selectUserIsStaff,
} from './selectors';
import { fetchCourseConfig } from './thunks';
@@ -47,28 +34,27 @@ export function useTotalTopicThreadCount() {
return 0;
}
return Object.keys(topics)
.reduce((total, topicId) => {
const topic = topics[topicId];
return total + topic.threadCounts.discussion + topic.threadCounts.question;
}, 0);
return Object.keys(topics).reduce((total, topicId) => {
const topic = topics[topicId];
return total + topic.threadCounts.discussion + topic.threadCounts.question;
}, 0);
}
export const useSidebarVisible = () => {
const enableInContext = useSelector(selectEnableInContext);
const isViewingTopics = useRouteMatch(Routes.TOPICS.ALL);
const isViewingLearners = useRouteMatch(Routes.LEARNERS.PATH);
const isFiltered = useSelector(selectAreThreadsFiltered);
const totalThreads = useSelector(selectPostThreadCount);
const isThreadsEmpty = Boolean(useSelector(threadsLoadingStatus()) === RequestStatus.SUCCESSFUL && !totalThreads);
const isIncontextTopicsView = Boolean(useRouteMatch(Routes.TOPICS.PATH) && enableInContext);
const hideSidebar = Boolean(isThreadsEmpty && !isFiltered && !(isViewingTopics?.isExact || isViewingLearners));
const isViewingTopics = useRouteMatch(Routes.TOPICS.PATH);
const isViewingLearners = useRouteMatch(Routes.LEARNERS.PATH);
if (isIncontextTopicsView) {
if (isFiltered) {
return true;
}
return !hideSidebar;
if (isViewingTopics || isViewingLearners) {
return true;
}
return totalThreads > 0;
};
export function useCourseDiscussionData(courseId) {
@@ -78,6 +64,7 @@ export function useCourseDiscussionData(courseId) {
useEffect(() => {
async function fetchBaseData() {
await dispatch(fetchCourseConfig(courseId));
await dispatch(fetchCourseTopics(courseId));
await dispatch(fetchCourseBlocks(courseId, authenticatedUser.username));
}
@@ -85,7 +72,7 @@ export function useCourseDiscussionData(courseId) {
}, [courseId]);
}
export function useRedirectToThread(courseId, enableInContextSidebar) {
export function useRedirectToThread(courseId, inContext) {
const dispatch = useDispatch();
const redirectToThread = useSelector(
(state) => state.threads.redirectToThread,
@@ -98,7 +85,7 @@ export function useRedirectToThread(courseId, enableInContextSidebar) {
// stored in redirectToThread
if (redirectToThread) {
dispatch(clearRedirect());
const newLocation = discussionsPath(Routes.COMMENTS.PAGES[enableInContextSidebar ? 'topics' : 'my-posts'], {
const newLocation = discussionsPath(Routes.COMMENTS.PAGES[inContext ? 'topics' : 'my-posts'], {
courseId,
postId: redirectToThread.threadId,
topicId: redirectToThread.topicId,
@@ -110,7 +97,7 @@ export function useRedirectToThread(courseId, enableInContextSidebar) {
export function useIsOnDesktop() {
const windowSize = useWindowSize();
return windowSize.width >= breakpoints.medium.minWidth;
return windowSize.width >= breakpoints.large.minWidth;
}
export function useIsOnXLDesktop() {
@@ -187,40 +174,3 @@ export const useCurrentDiscussionTopic = () => {
}
return null;
};
export const useUserCanAddThreadInBlackoutDate = () => {
const blackoutDateRange = useSelector(selectBlackoutDate);
const isUserAdmin = useSelector(selectUserIsStaff);
const userHasModerationPrivilages = useSelector(selectUserHasModerationPrivileges);
const isUserGroupTA = useSelector(selectUserIsGroupTa);
const isCourseAdmin = useSelector(selectIsCourseAdmin);
const isCourseStaff = useSelector(selectIsCourseStaff);
const isInBlackoutDateRange = inBlackoutDateRange(blackoutDateRange);
return (!(isInBlackoutDateRange)
|| (isUserAdmin || userHasModerationPrivilages || isUserGroupTA || isCourseAdmin || isCourseStaff));
};
function camelToConstant(string) {
return string.replace(/[A-Z]/g, (match) => `_${match}`).toUpperCase();
}
export const useTourConfiguration = (intl) => {
const dispatch = useDispatch();
const { enableInContextSidebar } = useContext(DiscussionContext);
const tours = useSelector(selectTours);
return tours.map((tour) => (
{
tourId: tour.tourName,
advanceButtonText: intl.formatMessage(messages.advanceButtonText),
dismissButtonText: intl.formatMessage(messages.dismissButtonText),
endButtonText: intl.formatMessage(messages.endButtonText),
enabled: tour && Boolean(tour.showTour && !enableInContextSidebar),
onDismiss: () => dispatch(updateTourShowStatus(tour.id)),
onEnd: () => dispatch(updateTourShowStatus(tour.id)),
checkpoints: tourCheckpoints(intl)[camelToConstant(tour.tourName)],
}
));
};

View File

@@ -1,33 +1,15 @@
import { render } from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import { IntlProvider } from 'react-intl';
import { Factory } from 'rosie';
import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
import { initializeStore } from '../../store';
import { executeThunk } from '../../test-utils';
import { DiscussionContext } from '../common/context';
import { getCourseConfigApiUrl } from './api';
import { useCurrentDiscussionTopic, useUserCanAddThreadInBlackoutDate } from './hooks';
import { fetchCourseConfig } from './thunks';
import { useCurrentDiscussionTopic } from './hooks';
const courseId = 'course-v1:edX+TestX+Test_Course';
const courseConfigApiUrl = getCourseConfigApiUrl();
let store;
let axiosMock;
const generateApiResponse = (blackouts = [], isCourseAdmin = false) => ({
blackouts,
hasModerationPrivileges: false,
isGroupTa: false,
isCourseAdmin,
isCourseStaff: false,
isUserAdmin: false,
});
initializeMockApp();
describe('Hooks', () => {
describe('useCurrentDiscussionTopic', () => {
function ComponentWithHook() {
@@ -57,7 +39,6 @@ describe('Hooks', () => {
}
beforeEach(() => {
initializeMockApp();
store = initializeStore({
blocks: {
blocks: {
@@ -101,75 +82,4 @@ describe('Hooks', () => {
expect(queryByText('null')).toBeInTheDocument();
});
});
describe('useUserCanAddThreadInBlackoutDate', () => {
function ComponentWithHook() {
const userCanAddThreadInBlackoutDate = useUserCanAddThreadInBlackoutDate();
return (
<div>
{String(userCanAddThreadInBlackoutDate)}
</div>
);
}
function renderComponent() {
return render(
<IntlProvider locale="en">
<AppProvider store={store}>
<ComponentWithHook />
</AppProvider>
</IntlProvider>,
);
}
describe('User can add Thread in blackoutdates ', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
Factory.resetAll();
store = initializeStore();
});
test('when blackoutdates are not active and Role is Learner return true', async () => {
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`)
.reply(200, generateApiResponse([], false));
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
const { queryByText } = renderComponent();
expect(queryByText('true')).toBeInTheDocument();
});
test('when blackoutdates are not active and Role is not Learner return true', async () => {
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`)
.reply(200, generateApiResponse([], true));
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
const { queryByText } = renderComponent();
expect(queryByText('true')).toBeInTheDocument();
});
test('when blackoutdates are active and Role is Learner return false', async () => {
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`)
.reply(200, generateApiResponse([{
start: '2022-11-25T00:00:00Z',
end: '2050-11-25T23:59:00Z',
}], false));
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
const { queryByText } = renderComponent();
expect(queryByText('false')).toBeInTheDocument();
});
test('when blackoutdates are active and Role is not Learner return true', async () => {
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`)
.reply(200, generateApiResponse([
{ start: '2022-11-25T00:00:00Z', end: '2050-11-25T23:59:00Z' }], true));
const { queryByText } = renderComponent();
expect(queryByText('true')).toBeInTheDocument();
});
});
});
});

View File

@@ -24,12 +24,6 @@ export const selectBlackoutDate = state => state.config.blackouts;
export const selectGroupAtSubsection = state => state.config.groupAtSubsection;
export const selectIsCourseAdmin = state => state.config.isCourseAdmin;
export const selectIsCourseStaff = state => state.config.isCourseStaff;
export const selectEnableInContext = state => state.config.enableInContext;
export const selectModerationSettings = state => ({
postCloseReasons: state.config.postCloseReasons,
editReasons: state.config.editReasons,
@@ -53,7 +47,7 @@ export function selectAreThreadsFiltered(state) {
export function selectTopicThreadCount(topicId) {
return state => {
const topic = topicId && state.topics?.topics[topicId];
const topic = state.topics.topics[topicId];
if (!topic) {
return 0;
}

View File

@@ -14,8 +14,6 @@ const configSlice = createSlice({
groupAtSubsection: false,
hasModerationPrivileges: false,
isGroupTa: false,
isCourseAdmin: false,
isCourseStaff: false,
isUserAdmin: false,
learnersTabEnabled: false,
settings: {
@@ -27,7 +25,6 @@ const configSlice = createSlice({
reasonCodesEnabled: false,
editReasons: [],
postCloseReasons: [],
enableInContext: false,
},
reducers: {
fetchConfigRequest: (state) => {

View File

@@ -3,7 +3,7 @@ import { camelCaseObject } from '@edx/frontend-platform';
import { logError } from '@edx/frontend-platform/logging';
import {
DiscussionProvider, LearnersOrdering,
LearnersOrdering,
PostsStatusFilter,
} from '../../data/constants';
import { setSortedBy } from '../learners/data';
@@ -36,10 +36,7 @@ export function fetchCourseConfig(courseId) {
learnerSort = LearnersOrdering.BY_FLAG;
}
dispatch(fetchConfigSuccess(camelCaseObject({
...config,
enable_in_context: config.provider === DiscussionProvider.OPEN_EDX,
})));
dispatch(fetchConfigSuccess(camelCaseObject(config)));
dispatch(setSortedBy(learnerSort));
dispatch(setStatusFilter(postsFilterStatus));
} catch (error) {

View File

@@ -14,72 +14,51 @@ import { DiscussionContext } from '../common/context';
import {
useContainerSize, useIsOnDesktop, useIsOnXLDesktop, useShowLearnersTab,
} from '../data/hooks';
import { selectconfigLoadingStatus, selectEnableInContext } from '../data/selectors';
import { TopicPostsView, TopicsView as InContextTopicsView } from '../in-context-topics';
import { selectconfigLoadingStatus } from '../data/selectors';
import { LearnerPostsView, LearnersView } from '../learners';
import { PostsView } from '../posts';
import { TopicsView as LegacyTopicsView } from '../topics';
import { TopicsView } from '../topics';
export default function DiscussionSidebar({ displaySidebar, postActionBarRef }) {
const location = useLocation();
const isOnDesktop = useIsOnDesktop();
const isOnXLDesktop = useIsOnXLDesktop();
const { enableInContextSidebar } = useContext(DiscussionContext);
const enableInContext = useSelector(selectEnableInContext);
const configStatus = useSelector(selectconfigLoadingStatus);
const redirectToLearnersTab = useShowLearnersTab();
const sidebarRef = useRef(null);
const postActionBarHeight = useContainerSize(postActionBarRef);
const { height: windowHeight } = useWindowSize();
const { inContext } = useContext(DiscussionContext);
useEffect(() => {
if (sidebarRef && postActionBarHeight && !enableInContextSidebar) {
if (sidebarRef && postActionBarHeight && !inContext) {
if (isOnDesktop) {
sidebarRef.current.style.maxHeight = `${windowHeight - postActionBarHeight}px`;
}
sidebarRef.current.style.minHeight = `${windowHeight - postActionBarHeight}px`;
sidebarRef.current.style.top = `${postActionBarHeight}px`;
}
}, [sidebarRef, postActionBarHeight, enableInContextSidebar]);
}, [sidebarRef, postActionBarHeight, inContext]);
return (
<div
ref={sidebarRef}
className={classNames('flex-column position-sticky', {
className={classNames('flex-column position-sticky', {
'd-none': !displaySidebar,
'd-flex overflow-auto box-shadow-centered-1': displaySidebar,
'd-flex overflow-auto': displaySidebar,
'w-100': !isOnDesktop,
'sidebar-desktop-width': isOnDesktop && !isOnXLDesktop,
'w-25 sidebar-XL-width': isOnXLDesktop,
'min-content-height': !enableInContextSidebar,
'min-content-height': !inContext,
})}
data-testid="sidebar"
>
<Switch>
{enableInContext && !enableInContextSidebar && (
<Route
path={Routes.TOPICS.ALL}
component={InContextTopicsView}
exact
/>
)}
{enableInContext && !enableInContextSidebar && (
<Route
path={[
Routes.TOPICS.TOPIC,
Routes.TOPICS.CATEGORY,
Routes.TOPICS.TOPIC_POST,
Routes.TOPICS.TOPIC_POST_EDIT,
]}
component={TopicPostsView}
exact
/>
)}
<Route
path={[Routes.POSTS.ALL_POSTS, Routes.POSTS.MY_POSTS, Routes.POSTS.PATH, Routes.TOPICS.CATEGORY]}
path={[Routes.POSTS.PATH, Routes.POSTS.ALL_POSTS, Routes.TOPICS.CATEGORY, Routes.POSTS.MY_POSTS]}
component={PostsView}
/>
<Route path={Routes.TOPICS.PATH} component={LegacyTopicsView} />
<Route path={Routes.TOPICS.PATH} component={TopicsView} />
{redirectToLearnersTab && (
<Route path={Routes.LEARNERS.POSTS} component={LearnerPostsView} />
)}
@@ -87,13 +66,13 @@ export default function DiscussionSidebar({ displaySidebar, postActionBarRef })
<Route path={Routes.LEARNERS.PATH} component={LearnersView} />
)}
{configStatus === RequestStatus.SUCCESSFUL && (
<Redirect
from={Routes.DISCUSSIONS.PATH}
to={{
...location,
pathname: Routes.POSTS.ALL_POSTS,
}}
/>
<Redirect
from={Routes.DISCUSSIONS.PATH}
to={{
...location,
pathname: Routes.POSTS.ALL_POSTS,
}}
/>
)}
</Switch>
</div>

View File

@@ -12,53 +12,60 @@ import { getConfig } from '@edx/frontend-platform';
import { PostActionsBar } from '../../components';
import { CourseTabsNavigation } from '../../components/NavigationBar';
import { selectCourseTabs } from '../../components/NavigationBar/data/selectors';
import { ALL_ROUTES, DiscussionProvider, Routes } from '../../data/constants';
import { DiscussionContext } from '../common/context';
import {
useCourseDiscussionData, useIsOnDesktop, useRedirectToThread, useShowLearnersTab, useSidebarVisible,
} from '../data/hooks';
import { selectDiscussionProvider, selectEnableInContext } from '../data/selectors';
import { selectDiscussionProvider } from '../data/selectors';
import { EmptyLearners, EmptyPosts, EmptyTopics } from '../empty-posts';
import { EmptyTopic as InContextEmptyTopics } from '../in-context-topics/components';
import messages from '../messages';
import { LegacyBreadcrumbMenu, NavigationBar } from '../navigation';
import { selectPostEditorVisible } from '../posts/data/selectors';
import DiscussionsProductTour from '../tours/DiscussionsProductTour';
import { BreadcrumbMenu, LegacyBreadcrumbMenu, NavigationBar } from '../navigation';
import { postMessageToParent } from '../utils';
import BlackoutInformationBanner from './BlackoutInformationBanner';
import DiscussionContent from './DiscussionContent';
import DiscussionSidebar from './DiscussionSidebar';
import InformationBanner from './InformationBanner';
import InformationBanner from './InformationsBanner';
export default function DiscussionsHome() {
const location = useLocation();
const postActionBarRef = useRef(null);
const postEditorVisible = useSelector(selectPostEditorVisible);
const provider = useSelector(selectDiscussionProvider);
const enableInContext = useSelector(selectEnableInContext);
const { courseNumber, courseTitle, org } = useSelector(selectCourseTabs);
const { params: { page } } = useRouteMatch(`${Routes.COMMENTS.PAGE}?`);
const postEditorVisible = useSelector(
(state) => state.threads.postEditorVisible,
);
const {
params: { page },
} = useRouteMatch(`${Routes.COMMENTS.PAGE}?`);
const { params: { path } } = useRouteMatch(`${Routes.DISCUSSIONS.PATH}/:path*`);
const { params } = useRouteMatch(ALL_ROUTES);
const isRedirectToLearners = useShowLearnersTab();
const isOnDesktop = useIsOnDesktop();
let displaySidebar = useSidebarVisible();
const enableInContextSidebar = Boolean(new URLSearchParams(location.search).get('inContextSidebar') !== null);
const isFeedbackBannerVisible = getConfig().DISPLAY_FEEDBACK_BANNER === 'true';
const {
courseId, postId, topicId, category, learnerUsername,
courseId,
postId,
topicId,
category,
learnerUsername,
} = params;
const inContext = new URLSearchParams(location.search).get('inContext') !== null;
// Display the content area if we are currently viewing/editing a post or creating one.
const displayContentArea = postId || postEditorVisible || (learnerUsername && postId);
let displaySidebar = useSidebarVisible();
const isOnDesktop = useIsOnDesktop();
const { courseNumber, courseTitle, org } = useSelector((state) => state.courseTabs);
if (displayContentArea) {
// If the window is larger than a particular size, show the sidebar for navigating between posts/topics.
// However, for smaller screens or embeds, only show the sidebar if the content area isn't displayed.
displaySidebar = isOnDesktop;
}
const provider = useSelector(selectDiscussionProvider);
useCourseDiscussionData(courseId);
useRedirectToThread(courseId, enableInContextSidebar);
/* Display the content area if we are currently viewing/editing a post or creating one.
If the window is larger than a particular size, show the sidebar for navigating between posts/topics.
However, for smaller screens or embeds, onlyshow the sidebar if the content area isn't displayed. */
const displayContentArea = (postId || postEditorVisible || (learnerUsername && postId));
if (displayContentArea) { displaySidebar = isOnDesktop; }
useRedirectToThread(courseId, inContext);
useEffect(() => {
if (path && path !== 'undefined') {
postMessageToParent('discussions.navigate', { path });
@@ -71,47 +78,41 @@ export default function DiscussionsHome() {
courseId,
postId,
topicId,
enableInContextSidebar,
inContext,
category,
learnerUsername,
}}
>
{!enableInContextSidebar && <Header courseOrg={org} courseNumber={courseNumber} courseTitle={courseTitle} />}
{!inContext && <Header courseOrg={org} courseNumber={courseNumber} courseTitle={courseTitle} />}
<main className="container-fluid d-flex flex-column p-0 w-100" id="main" tabIndex="-1">
{!enableInContextSidebar && <CourseTabsNavigation activeTab="discussion" courseId={courseId} />}
{!inContext && <CourseTabsNavigation activeTab="discussion" courseId={courseId} />}
<div
className={classNames('header-action-bar', {
'shadow-none border-light-300 border-bottom': enableInContextSidebar,
})}
className={classNames('header-action-bar', { 'shadow-none border-light-300 border-bottom': inContext })}
ref={postActionBarRef}
>
<div
className={classNames('d-flex flex-row justify-content-between navbar fixed-top', {
'pl-4 pr-2.5 py-1.5': enableInContextSidebar,
'pl-4 pr-2.5 py-1.5': inContext,
})}
>
{!enableInContextSidebar && <Route path={Routes.DISCUSSIONS.PATH} component={NavigationBar} />}
<PostActionsBar />
{!inContext && <Route path={Routes.DISCUSSIONS.PATH} component={NavigationBar} />}
<PostActionsBar inContext={inContext} />
</div>
{isFeedbackBannerVisible && <InformationBanner />}
<BlackoutInformationBanner />
</div>
{provider === DiscussionProvider.LEGACY && (
{!inContext && (
<Route
path={[Routes.POSTS.PATH, Routes.TOPICS.CATEGORY]}
component={LegacyBreadcrumbMenu}
component={provider === DiscussionProvider.LEGACY ? LegacyBreadcrumbMenu : BreadcrumbMenu}
/>
)}
<div className="d-flex flex-row">
<DiscussionSidebar displaySidebar={displaySidebar} postActionBarRef={postActionBarRef} />
{displayContentArea && <DiscussionContent />}
{!displayContentArea && (
<Switch>
<Route
path={Routes.TOPICS.PATH}
component={(enableInContext || enableInContextSidebar) ? InContextEmptyTopics : EmptyTopics}
/>
<Route path={Routes.TOPICS.PATH} component={EmptyTopics} />
<Route
path={Routes.POSTS.MY_POSTS}
render={routeProps => <EmptyPosts {...routeProps} subTitleMessage={messages.emptyMyPosts} />}
@@ -124,9 +125,8 @@ export default function DiscussionsHome() {
</Switch>
)}
</div>
<DiscussionsProductTour />
</main>
{!enableInContextSidebar && <Footer />}
{!inContext && <Footer />}
</DiscussionContext.Provider>
);
}

View File

@@ -1,25 +1,18 @@
import {
fireEvent, render, screen, waitFor,
} from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import { act } from 'react-dom/test-utils';
import { IntlProvider } from 'react-intl';
import { Context as ResponsiveContext } from 'react-responsive';
import { MemoryRouter } from 'react-router';
import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
import { initializeStore } from '../../store';
import { executeThunk } from '../../test-utils';
import { getCourseConfigApiUrl } from '../data/api';
import { fetchCourseConfig } from '../data/thunks';
import navigationBarMessages from '../navigation/navigation-bar/messages';
import DiscussionsHome from './DiscussionsHome';
const courseConfigApiUrl = getCourseConfigApiUrl();
let axiosMock;
let store;
const courseId = 'course-v1:edX+DemoX+Demo_Course';
@@ -47,7 +40,7 @@ describe('DiscussionsHome', () => {
roles: [],
},
});
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
store = initializeStore();
});
@@ -70,9 +63,7 @@ describe('DiscussionsHome', () => {
});
test('in-context view should show close button', async () => {
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`).reply(200, { provider: 'openedx' });
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
renderComponent(`/${courseId}/topics?inContextSidebar`);
renderComponent(`/${courseId}/topics?inContext`);
expect(screen.queryByText(navigationBarMessages.allTopics.defaultMessage))
.not
@@ -82,12 +73,10 @@ describe('DiscussionsHome', () => {
});
test('the close button should post a message', async () => {
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`).reply(200, { provider: 'openedx' });
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
const { parent } = window;
delete window.parent;
window.parent = { ...window, postMessage: jest.fn() };
renderComponent(`/${courseId}/topics?inContextSidebar`);
renderComponent(`/${courseId}/topics?inContext`);
const closeButton = screen.queryByRole('button', { name: 'Close' });
@@ -99,7 +88,7 @@ describe('DiscussionsHome', () => {
window.parent = parent;
});
test('header, course navigation bar and footer are only visible in Discussions MFE', async () => {
test('header, course navigation bar and footer are visible', async () => {
renderComponent();
expect(screen.queryByRole('banner')).toBeInTheDocument();
expect(document.getElementById('courseTabsNavigation')).toBeInTheDocument();

View File

@@ -8,7 +8,7 @@ import { initializeStore } from '../../store';
import { DiscussionContext } from '../common/context';
import { fetchConfigSuccess } from '../data/slices';
import messages from '../messages';
import InformationBanner from './InformationBanner';
import InformationBanner from './InformationsBanner';
import '../posts/data/__factories__';

View File

@@ -52,11 +52,7 @@ function EmptyPosts({ intl, subTitleMessage }) {
}
EmptyPosts.propTypes = {
subTitleMessage: propTypes.shape({
id: propTypes.string,
defaultMessage: propTypes.string,
description: propTypes.string,
}).isRequired,
subTitleMessage: propTypes.string.isRequired,
intl: intlShape.isRequired,
};

View File

@@ -1,85 +0,0 @@
import React, { useContext } from 'react';
import { useSelector } from 'react-redux';
import { useLocation } from 'react-router-dom';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Spinner } from '@edx/paragon';
import { RequestStatus, Routes } from '../../data/constants';
import { DiscussionContext } from '../common/context';
import { selectTopicThreads } from '../posts/data/selectors';
import PostsList from '../posts/PostsList';
import { discussionsPath, handleKeyDown } from '../utils';
import {
selectArchivedTopic, selectLoadingStatus, selectNonCoursewareTopics,
selectSubsection, selectSubsectionUnits, selectUnits,
} from './data/selectors';
import { BackButton, NoResults } from './components';
import messages from './messages';
import { Topic } from './topic';
function TopicPostsView({ intl }) {
const location = useLocation();
const { courseId, topicId, category } = useContext(DiscussionContext);
const topicsLoadingStatus = useSelector(selectLoadingStatus);
const posts = useSelector(selectTopicThreads([topicId]));
const selectedSubsectionUnits = useSelector(selectSubsectionUnits(category));
const selectedSubsection = useSelector(selectSubsection(category));
const selectedUnit = useSelector(selectUnits)?.find(unit => unit.id === topicId);
const selectedNonCoursewareTopic = useSelector(selectNonCoursewareTopics)?.find(topic => topic.id === topicId);
const selectedArchivedTopic = useSelector(selectArchivedTopic(topicId));
const backButtonPath = () => {
const path = selectedUnit ? Routes.TOPICS.CATEGORY : Routes.TOPICS.ALL;
const params = selectedUnit ? { courseId, category: selectedUnit?.parentId } : { courseId };
return discussionsPath(path, params)(location);
};
return (
<div className="discussion-posts d-flex flex-column h-100">
{topicId ? (
<BackButton
path={backButtonPath()}
title={selectedUnit?.name || selectedNonCoursewareTopic?.name || selectedArchivedTopic?.name
|| intl.formatMessage(messages.unnamedTopic)}
/>
) : (
<BackButton
path={discussionsPath(Routes.TOPICS.ALL, { courseId })(location)}
title={selectedSubsection?.displayName || intl.formatMessage(messages.unnamedSubsection)}
/>
)}
<div className="border-bottom border-light-400" />
<div className="list-group list-group-flush flex-fill" role="list" onKeyDown={e => handleKeyDown(e)}>
{topicId ? (
<PostsList
posts={posts}
topics={[topicId]}
/>
) : (
selectedSubsectionUnits?.map((unit) => (
<Topic
key={unit.id}
topic={unit}
/>
))
)}
{(category && selectedSubsectionUnits.length === 0 && topicsLoadingStatus === RequestStatus.SUCCESSFUL) && (
<NoResults />
)}
{(category && topicsLoadingStatus === RequestStatus.IN_PROGRESS) && (
<div className="d-flex justify-content-center p-4">
<Spinner animation="border" variant="primary" size="lg" />
</div>
)}
</div>
</div>
);
}
TopicPostsView.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(TopicPostsView);

View File

@@ -1,121 +0,0 @@
import React, { useContext, useEffect } from 'react';
import classNames from 'classnames';
import isEmpty from 'lodash/isEmpty';
import { useDispatch, useSelector } from 'react-redux';
import { Spinner } from '@edx/paragon';
import SearchInfo from '../../components/SearchInfo';
import { RequestStatus } from '../../data/constants';
import { DiscussionContext } from '../common/context';
import { selectAreThreadsFiltered, selectDiscussionProvider } from '../data/selectors';
import { clearFilter, clearSort } from '../posts/data/slices';
import NoResults from '../posts/NoResults';
import { handleKeyDown } from '../utils';
import {
selectArchivedTopics, selectCoursewareTopics, selectFilteredTopics, selectLoadingStatus,
selectNonCoursewareTopics, selectTopicFilter, selectTopics,
} from './data/selectors';
import { setFilter } from './data/slices';
import { fetchCourseTopicsV3 } from './data/thunks';
import { ArchivedBaseGroup, SectionBaseGroup, Topic } from './topic';
function TopicsList() {
const loadingStatus = useSelector(selectLoadingStatus);
const coursewareTopics = useSelector(selectCoursewareTopics);
const nonCoursewareTopics = useSelector(selectNonCoursewareTopics);
const archivedTopics = useSelector(selectArchivedTopics);
return (
<>
{nonCoursewareTopics?.map((topic, index) => (
<Topic
key={topic.id}
topic={topic}
showDivider={(nonCoursewareTopics.length - 1) !== index}
/>
))}
{coursewareTopics?.map((topic, index) => (
<SectionBaseGroup
key={topic.id}
section={topic?.children}
sectionId={topic.id}
sectionTitle={topic.displayName}
showDivider={(coursewareTopics.length - 1) !== index}
/>
))}
{!isEmpty(archivedTopics) && (
<ArchivedBaseGroup
archivedTopics={archivedTopics}
showDivider={(!isEmpty(nonCoursewareTopics) || !isEmpty(coursewareTopics))}
/>
)}
{loadingStatus === RequestStatus.IN_PROGRESS && (
<div className="d-flex justify-content-center p-4">
<Spinner animation="border" variant="primary" size="lg" />
</div>
)}
</>
);
}
function TopicsView() {
const dispatch = useDispatch();
const { courseId } = useContext(DiscussionContext);
const provider = useSelector(selectDiscussionProvider);
const topicFilter = useSelector(selectTopicFilter);
const filteredTopics = useSelector(selectFilteredTopics);
const loadingStatus = useSelector(selectLoadingStatus);
const isPostsFiltered = useSelector(selectAreThreadsFiltered);
const topics = useSelector(selectTopics);
useEffect(() => {
if (provider) {
dispatch(fetchCourseTopicsV3(courseId));
}
}, [provider]);
useEffect(() => {
if (isPostsFiltered) {
dispatch(clearFilter());
dispatch(clearSort());
}
}, [isPostsFiltered]);
return (
<div className="d-flex flex-column h-100" data-testid="inContext-topics-view">
{topicFilter && (
<>
<SearchInfo
text={topicFilter}
count={filteredTopics.length}
loadingStatus={loadingStatus}
onClear={() => dispatch(setFilter(''))}
/>
{filteredTopics.length === 0 && loadingStatus === RequestStatus.SUCCESSFUL && <NoResults />}
</>
)}
<div
className={classNames('list-group list-group-flush flex-fill', {
'justify-content-center': loadingStatus === RequestStatus.IN_PROGRESS && isEmpty(topics),
})}
role="list"
onKeyDown={e => handleKeyDown(e)}
>
{topicFilter ? (
filteredTopics?.map((topic) => (
<Topic
key={topic.id}
topic={topic}
/>
))
) : (
<TopicsList />
)}
</div>
</div>
);
}
export default TopicsView;

View File

@@ -1,41 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useHistory } from 'react-router-dom';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Icon, IconButton } from '@edx/paragon';
import { ArrowBack } from '@edx/paragon/icons';
import messages from '../messages';
function BackButton({ intl, path, title }) {
const history = useHistory();
return (
<>
<div className="d-flex py-2.5 px-3 font-weight-bold border-light-400 border-bottom">
<IconButton
src={ArrowBack}
iconAs={Icon}
style={{ padding: '18px' }}
size="inline"
onClick={() => history.push(path)}
alt={intl.formatMessage(messages.backAlt)}
/>
<div className="d-flex flex-fill justify-content-center align-items-center mr-4.5">
{title}
</div>
</div>
<div className="border-bottom border-light-400" />
</>
);
}
BackButton.propTypes = {
intl: intlShape.isRequired,
path: PropTypes.shape({}).isRequired,
title: PropTypes.string.isRequired,
};
export default injectIntl(BackButton);

View File

@@ -1,83 +0,0 @@
import React, { useContext } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useRouteMatch } from 'react-router';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { ALL_ROUTES } from '../../../data/constants';
import { DiscussionContext } from '../../common/context';
import { useIsOnDesktop } from '../../data/hooks';
import { selectPostThreadCount } from '../../data/selectors';
import EmptyPage from '../../empty-posts/EmptyPage';
import messages from '../../messages';
import { messages as postMessages, showPostEditor } from '../../posts';
import { selectCourseWareThreadsCount, selectTotalTopicsThreadsCount } from '../data/selectors';
function EmptyTopics({ intl }) {
const match = useRouteMatch(ALL_ROUTES);
const dispatch = useDispatch();
const { enableInContextSidebar } = useContext(DiscussionContext);
const courseWareThreadsCount = useSelector(selectCourseWareThreadsCount(match.params.category));
const topicThreadsCount = useSelector(selectPostThreadCount);
// hasGlobalThreads is used to determine if there are any post available in courseware and non-courseware topics
const hasGlobalThreads = useSelector(selectTotalTopicsThreadsCount) > 0;
function addPost() {
return dispatch(showPostEditor());
}
const isOnDesktop = useIsOnDesktop();
let title = messages.emptyTitle;
let fullWidth = false;
let subTitle;
let action;
let actionText;
if (!isOnDesktop) {
return null;
}
if (match.params.topicId) {
if (topicThreadsCount > 0) {
title = messages.noPostSelected;
} else {
action = addPost;
actionText = postMessages.addAPost;
subTitle = messages.emptyTopic;
fullWidth = true;
}
} else if (match.params.category) {
if (enableInContextSidebar && topicThreadsCount > 0) {
title = messages.noPostSelected;
} else if (courseWareThreadsCount > 0) {
title = messages.noTopicSelected;
} else {
action = addPost;
actionText = postMessages.addAPost;
subTitle = messages.emptyTopic;
fullWidth = true;
}
} else if (hasGlobalThreads) {
title = messages.noTopicSelected;
} else {
fullWidth = true;
}
return (
<EmptyPage
title={intl.formatMessage(title)}
subTitle={subTitle && intl.formatMessage(subTitle)}
action={action}
actionText={actionText && intl.formatMessage(actionText)}
fullWidth={fullWidth}
/>
);
}
EmptyTopics.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(EmptyTopics);

View File

@@ -1,29 +0,0 @@
import { useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { selectTopics } from '../data/selectors';
import messages from '../messages';
function NoResults({ intl }) {
const topics = useSelector(selectTopics);
let title = messages.nothingHere;
const helpMessage = '';
if (topics.length === 0) {
title = messages.noTopicExists;
}
return (
<div className="h-100 mt-5 align-self-center mx-auto w-50 d-flex flex-column justify-content-center text-center">
<h4 className="font-weight-normal text-primary-500">{intl.formatMessage(title)}</h4>
{ helpMessage && <small className="font-weight-normal text-gray-700">{intl.formatMessage(helpMessage)}</small>}
</div>
);
}
NoResults.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(NoResults);

View File

@@ -1,4 +0,0 @@
/* eslint-disable import/prefer-default-export */
export { default as BackButton } from './BackButton';
export { default as EmptyTopic } from './EmptyTopics';
export { default as NoResults } from './NoResults';

View File

@@ -1,11 +0,0 @@
/* eslint-disable import/prefer-default-export */
import { camelCaseObject } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { getApiBaseUrl } from '../../../data/constants';
export async function getCourseTopicsV3(courseId) {
const url = `${getApiBaseUrl()}/api/discussion/v3/course_topics/${courseId}`;
const { data } = await getAuthenticatedHttpClient().get(url);
return camelCaseObject(data);
}

View File

@@ -1 +0,0 @@
export * from './slices';

View File

@@ -1,67 +0,0 @@
/* eslint-disable import/prefer-default-export */
import { createSelector } from '@reduxjs/toolkit';
export const selectTopicFilter = state => state.inContextTopics.filter.trim().toLowerCase();
export const selectTopics = state => state.inContextTopics.topics;
export const selectCoursewareTopics = state => state.inContextTopics.coursewareTopics;
export const selectNonCoursewareTopics = state => state.inContextTopics.nonCoursewareTopics;
export const selectNonCoursewareIds = state => state.inContextTopics.nonCoursewareIds;
export const selectUnits = state => state.inContextTopics.units;
export const selectSubsectionUnits = subsectionId => state => state.inContextTopics.units?.filter(
unit => unit.parentId === subsectionId,
);
export const selectSubsection = category => createSelector(
selectCoursewareTopics,
(coursewareTopics) => (
coursewareTopics?.map((topic) => topic?.children)?.flat()?.find((topic) => topic.id === category)
),
);
export const selectArchivedTopics = state => state.inContextTopics.archivedTopics;
export const selectArchivedTopic = topic => createSelector(
selectArchivedTopics,
(archivedTopics) => (
archivedTopics?.find((archivedTopic) => archivedTopic.id === topic)
),
);
export const selectLoadingStatus = state => state.inContextTopics.status;
export const selectFilteredTopics = createSelector(
selectUnits,
selectNonCoursewareTopics,
selectTopicFilter,
(units, nonCoursewareTopics, filter) => (
(units && nonCoursewareTopics && filter) && [...units, ...nonCoursewareTopics]?.filter(
topic => topic.name.toLowerCase().includes(filter),
)
),
);
export const selectTotalTopicsThreadsCount = createSelector(
selectUnits,
selectNonCoursewareTopics,
(units, nonCoursewareTopics) => (
(units && nonCoursewareTopics) && [...units, ...nonCoursewareTopics]?.reduce((total, topic) => (
total + topic.threadCounts.discussion + topic.threadCounts.question
), 0)
),
);
export const selectCourseWareThreadsCount = category => createSelector(
selectSubsectionUnits(category),
(units) => (
units?.reduce((total, unit) => (
total + unit.threadCounts.discussion + unit.threadCounts.question
), 0)
),
);

View File

@@ -1,51 +0,0 @@
/* eslint-disable no-param-reassign,import/prefer-default-export */
import { createSlice } from '@reduxjs/toolkit';
import { RequestStatus } from '../../../data/constants';
const topicsSlice = createSlice({
name: 'inContextTopics',
initialState: {
status: RequestStatus.IN_PROGRESS,
topics: [],
coursewareTopics: [],
nonCoursewareTopics: [],
nonCoursewareIds: [],
units: [],
archivedTopics: [],
filter: '',
},
reducers: {
fetchCourseTopicsRequest: (state) => {
state.status = RequestStatus.IN_PROGRESS;
},
fetchCourseTopicsSuccess: (state, { payload }) => {
state.status = RequestStatus.SUCCESSFUL;
state.topics = payload.topics;
state.coursewareTopics = payload.coursewareTopics;
state.nonCoursewareTopics = payload.nonCoursewareTopics;
state.nonCoursewareIds = payload.nonCoursewareIds;
state.units = payload.units;
state.archivedTopics = payload.archivedTopics;
},
fetchCourseTopicsFailed: (state) => {
state.status = RequestStatus.FAILED;
},
fetchCourseTopicsDenied: (state) => {
state.status = RequestStatus.DENIED;
},
setFilter: (state, { payload }) => {
state.filter = payload;
},
},
});
export const {
fetchCourseTopicsRequest,
fetchCourseTopicsSuccess,
fetchCourseTopicsFailed,
setFilter,
setSortBy,
} = topicsSlice.actions;
export const inContextTopicsReducer = topicsSlice.reducer;

View File

@@ -1,64 +0,0 @@
/* eslint-disable import/prefer-default-export */
import { reduce } from 'lodash';
import { logError } from '@edx/frontend-platform/logging';
import { getCourseTopicsV3 } from './api';
import { fetchCourseTopicsFailed, fetchCourseTopicsRequest, fetchCourseTopicsSuccess } from './slices';
function normalizeTopicsV3(topics) {
const coursewareUnits = reduce(topics, (arrayOfUnits, chapter) => {
if (chapter?.children) {
return [
...arrayOfUnits,
...reduce(chapter.children, (units, sequential) => {
if (sequential?.children) {
return [
...units,
...sequential.children.map((unit) => ({
...unit,
parentId: sequential.id,
parentTitle: sequential.displayName,
})),
];
}
return units;
}, []),
];
}
return arrayOfUnits;
}, []);
const archivedTopics = reduce(topics, (arrayOfArchivedTopics, topic) => {
if (topic.id === 'archived') {
return topic.children;
}
return arrayOfArchivedTopics;
}, []);
const coursewareTopics = topics.filter((topic) => topic.courseware);
const nonCoursewareTopics = topics.filter((topic) => !topic.courseware && topic.enabledInContext);
const nonCoursewareIds = nonCoursewareTopics?.map((topic) => topic.id);
return {
topics,
units: coursewareUnits,
coursewareTopics,
nonCoursewareTopics,
nonCoursewareIds,
archivedTopics,
};
}
export function fetchCourseTopicsV3(courseId) {
return async (dispatch) => {
try {
dispatch(fetchCourseTopicsRequest({ courseId }));
const data = await getCourseTopicsV3(courseId);
dispatch(fetchCourseTopicsSuccess(normalizeTopicsV3(data)));
} catch (error) {
dispatch(fetchCourseTopicsFailed());
logError(error);
}
};
}

View File

@@ -1,3 +0,0 @@
/* eslint-disable import/prefer-default-export */
export { default as TopicPostsView } from './TopicPostsView';
export { default as TopicsView } from './TopicsView';

View File

@@ -1,79 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
backAlt: {
id: 'discussions.topics.backAlt',
defaultMessage: 'Back to topics list',
description: 'Display back button text used to navigate back to topics list',
},
discussions: {
id: 'discussions.topics.discussions',
defaultMessage: `{count, plural,
=0 {Discussion}
one {# Discussion}
other {# Discussions}
}`,
description: 'Display tooltip text used to indicate how many posts type are discussion',
},
questions: {
id: 'discussions.topics.questions',
defaultMessage: `{count, plural,
=0 {Question}
one {# Question}
other {# Questions}
}`,
description: 'Display tooltip text used to indicate how many posts type are questions',
},
reported: {
id: 'discussions.topics.reported',
defaultMessage: '{reported} reported',
description: 'Display tooltip text used to indicate how many posts are reported',
},
previouslyReported: {
id: 'discussions.topics.previouslyReported',
defaultMessage: '{previouslyReported} previously reported',
description: 'Display tooltip text used to indicate how many posts are previously reported',
},
searchTopics: {
id: 'discussions.topics.find.label',
defaultMessage: 'Search topics',
description: 'Placeholder text in search bar',
},
unnamedSection: {
id: 'discussions.topics.unnamed.section.label',
defaultMessage: 'Unnamed Section',
description: 'Text to display in place of section name if section name is empty',
},
unnamedSubsection: {
id: 'discussions.topics.unnamed.subsection.label',
defaultMessage: 'Unnamed Subsection',
description: 'Text to display in place of subsection name if subsection name is empty',
},
unnamedTopic: {
id: 'discussions.subtopics.unnamed.topic.label',
defaultMessage: 'Unnamed Topic',
description: 'Text to display in place of topic name if topic name is empty',
},
noTopicExists: {
id: 'discussions.topics.title',
defaultMessage: 'No topic exists',
description: 'Text to display in place of topic list if topic does not exist',
},
createTopic: {
id: 'discussions.topics.createTopic',
defaultMessage: 'Please contact you admin to create a topic',
description: 'Helping Text to display in place of topic list if topic does not exist',
},
nothingHere: {
id: 'discussions.topics.nothing',
defaultMessage: 'Nothing here yet',
description: 'Helping Text to display if nothing here yet',
},
archivedTopics: {
id: 'discussions.topics.archived.label',
defaultMessage: 'Archived',
description: 'Heading for displaying topics that are archived.',
},
});
export default messages;

View File

@@ -1,64 +0,0 @@
import React, { useContext, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Icon, SearchField } from '@edx/paragon';
import { Search as SearchIcon } from '@edx/paragon/icons';
import { DiscussionContext } from '../../common/context';
import postsMessages from '../../posts/post-actions-bar/messages';
import { setFilter as setTopicFilter } from '../data/slices';
function TopicSearchBar({ intl }) {
const dispatch = useDispatch();
const { page } = useContext(DiscussionContext);
const topicSearch = useSelector(({ inContextTopics }) => inContextTopics.filter);
let searchValue = '';
const onClear = () => {
dispatch(setTopicFilter(''));
};
const onChange = (query) => {
searchValue = query;
};
const onSubmit = (query) => {
if (query === '') {
return;
}
dispatch(setTopicFilter(query));
};
useEffect(() => onClear(), [page]);
return (
<>
<SearchField.Advanced
onClear={onClear}
onChange={onChange}
onSubmit={onSubmit}
value={topicSearch}
>
<SearchField.Label />
<SearchField.Input
style={{ paddingRight: '1rem' }}
placeholder={intl.formatMessage(postsMessages.search, { page: 'topics' })}
/>
<span className="mt-auto mb-auto mr-2.5 pointer-cursor-hover">
<Icon
src={SearchIcon}
onClick={() => onSubmit(searchValue)}
/>
</span>
</SearchField.Advanced>
</>
);
}
TopicSearchBar.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(TopicSearchBar);

View File

@@ -1,30 +0,0 @@
import React from 'react';
import { useDispatch } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { SearchField } from '@edx/paragon';
import { setFilter } from '../data';
import messages from '../messages';
function TopicSearchResultBar({ intl }) {
const dispatch = useDispatch();
return (
<div className="d-flex flex-row p-1 align-items-center">
<SearchField
className="flex-fill m-1 border-0"
placeholder={intl.formatMessage(messages.searchTopics)}
onSubmit={(query) => dispatch(setFilter(query))}
onChange={(query) => dispatch(setFilter(query))}
/>
</div>
);
}
TopicSearchResultBar.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(TopicSearchResultBar);

View File

@@ -1,3 +0,0 @@
/* eslint-disable import/prefer-default-export */
export { default as TopicSearchBar } from './TopicSearchBar';
export { default as TopicSearchResultBar } from './TopicSearchResultBar';

View File

@@ -1,48 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import messages from '../messages';
import Topic, { topicShape } from './Topic';
function ArchivedBaseGroup({
archivedTopics,
showDivider,
intl,
}) {
return (
<>
{showDivider && (
<>
<div className="divider border-top border-light-500" />
<div className="divider pt-1 bg-light-300" />
</>
)}
<div
className="discussion-topic-group d-flex flex-column text-primary-500"
data-testid="archived-group"
>
<div className="pt-3 px-4 font-weight-bold">{intl.formatMessage(messages.archivedTopics)}</div>
{archivedTopics?.map((topic, index) => (
<Topic
key={topic.id}
topic={topic}
showDivider={(archivedTopics.length - 1) !== index}
/>
))}
</div>
</>
);
}
ArchivedBaseGroup.propTypes = {
archivedTopics: PropTypes.arrayOf(topicShape).isRequired,
showDivider: PropTypes.bool,
intl: intlShape.isRequired,
};
ArchivedBaseGroup.defaultProps = {
showDivider: false,
};
export default injectIntl(ArchivedBaseGroup);

View File

@@ -1,90 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useParams } from 'react-router';
import { Link } from 'react-router-dom';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Routes } from '../../../data/constants';
import { discussionsPath } from '../../utils';
import messages from '../messages';
import { topicShape } from './Topic';
function SectionBaseGroup({
section,
sectionTitle,
sectionId,
showDivider,
intl,
}) {
const { courseId } = useParams();
const isSelected = (id) => window.location.pathname.includes(id);
const sectionUrl = (id) => discussionsPath(Routes.TOPICS.CATEGORY, {
courseId,
category: id,
});
return (
<div
className="discussion-topic-group d-flex flex-column text-primary-500"
data-section-id={sectionId}
data-testid="section-group"
>
<div className="pt-3 px-4 font-weight-bold">
{sectionTitle || intl.formatMessage(messages.unnamedSection)}
</div>
{section.map((subsection, index) => (
<Link
className={classNames('subsection p-0 text-decoration-none text-primary-500', {
'border-bottom border-light-400': (section.length - 1 !== index),
})}
key={subsection.id}
role="option"
data-subsection-id={subsection.id}
data-testid="subsection-group"
to={sectionUrl(subsection.id)}
onClick={() => isSelected(subsection.id)}
aria-current={isSelected(section.id) ? 'page' : undefined}
tabIndex={(isSelected(subsection.id) || index === 0) ? 0 : -1}
>
<div className="d-flex flex-row py-3.5 px-4">
<div className="d-flex flex-column flex-fill" style={{ minWidth: 0 }}>
<div className="d-flex flex-column justify-content-start mw-100 flex-fill">
<div className="topic-name text-truncate">
{subsection?.displayName || intl.formatMessage(messages.unnamedSubsection)}
</div>
</div>
</div>
</div>
</Link>
))}
{showDivider && (
<>
<div className="divider border-top border-light-500" />
<div className="divider pt-1 bg-light-300" />
</>
)}
</div>
);
}
SectionBaseGroup.propTypes = {
section: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.string,
blockId: PropTypes.string,
lmsWebUrl: PropTypes.string,
legacyWebUrl: PropTypes.string,
studentViewUrl: PropTypes.string,
type: PropTypes.string,
displayName: PropTypes.string,
children: PropTypes.arrayOf(topicShape),
})).isRequired,
sectionTitle: PropTypes.string.isRequired,
sectionId: PropTypes.string.isRequired,
showDivider: PropTypes.bool.isRequired,
intl: intlShape.isRequired,
};
export default injectIntl(SectionBaseGroup);

View File

@@ -1,155 +0,0 @@
/* eslint-disable no-unused-vars, react/forbid-prop-types */
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useSelector } from 'react-redux';
import { useParams } from 'react-router';
import { Link } from 'react-router-dom';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Icon, OverlayTrigger, Tooltip } from '@edx/paragon';
import { HelpOutline, PostOutline, Report } from '@edx/paragon/icons';
import { Routes } from '../../../data/constants';
import { selectUserHasModerationPrivileges, selectUserIsGroupTa } from '../../data/selectors';
import { discussionsPath } from '../../utils';
import messages from '../messages';
function Topic({
topic,
showDivider,
index,
intl,
}) {
const { courseId } = useParams();
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
const userIsGroupTa = useSelector(selectUserIsGroupTa);
const { inactiveFlags, activeFlags } = topic;
const canSeeReportedStats = (activeFlags || inactiveFlags) && (userHasModerationPrivileges || userIsGroupTa);
const isSelected = (id) => window.location.pathname.includes(id);
const topicUrl = discussionsPath(Routes.TOPICS.TOPIC, {
courseId,
topicId: topic.id,
});
return (
<>
<Link
className={classNames('discussion-topic p-0 text-decoration-none text-primary-500', {
'border-light-400 border-bottom': showDivider,
})}
data-topic-id={topic.id}
to={topicUrl}
onClick={() => isSelected(topic.id)}
aria-current={isSelected(topic.id) ? 'page' : undefined}
role="option"
tabIndex={(isSelected(topic.id) || index === 0) ? 0 : -1}
>
<div className="d-flex flex-row pt-2.5 pb-2 px-4">
<div className="d-flex flex-column flex-fill" style={{ minWidth: 0 }}>
<div className="d-flex flex-column justify-content-start mw-100 flex-fill">
<div className="topic-name text-truncate">
{topic?.name || topic?.displayName || intl.formatMessage(messages.unnamedTopicSubCategories)}
</div>
</div>
<div className="d-flex align-items-center mt-2.5" style={{ marginBottom: '2px' }}>
<OverlayTrigger
overlay={(
<Tooltip>
<div className="d-flex flex-column align-items-start">
{intl.formatMessage(messages.discussions, {
count: topic.threadCounts?.discussion || 0,
})}
</div>
</Tooltip>
)}
>
<div className="d-flex align-items-center mr-3.5">
<Icon src={PostOutline} className="icon-size mr-2" />
{topic.threadCounts?.discussion || 0}
</div>
</OverlayTrigger>
<OverlayTrigger
overlay={(
<Tooltip>
<div className="d-flex flex-column align-items-start">
{intl.formatMessage(messages.questions, {
count: topic.threadCounts?.question || 0,
})}
</div>
</Tooltip>
)}
>
<div className="d-flex align-items-center mr-3.5">
<Icon src={HelpOutline} className="icon-size mr-2" />
{topic.threadCounts?.question || 0}
</div>
</OverlayTrigger>
{Boolean(canSeeReportedStats) && (
<OverlayTrigger
overlay={(
<Tooltip>
<div className="d-flex flex-column align-items-start">
{Boolean(activeFlags) && (
<span>
{intl.formatMessage(messages.reported, { reported: activeFlags })}
</span>
)}
{Boolean(inactiveFlags) && (
<span>
{intl.formatMessage(messages.previouslyReported, { previouslyReported: inactiveFlags })}
</span>
)}
</div>
</Tooltip>
)}
>
<div className="d-flex align-items-center">
<Icon src={Report} className="icon-size mr-2 text-danger" />
{activeFlags}{Boolean(inactiveFlags) && `/${inactiveFlags}`}
</div>
</OverlayTrigger>
)}
</div>
</div>
</div>
</Link>
{!showDivider && (
<>
<div className="divider border-top border-light-500" />
<div className="divider pt-1 bg-light-300" />
</>
)}
</>
);
}
export const topicShape = PropTypes.shape({
id: PropTypes.string,
usage_key: PropTypes.string,
name: PropTypes.string,
thread_counts: PropTypes.shape({
discussions: PropTypes.number,
questions: PropTypes.number,
}),
enabled_in_context: PropTypes.bool,
flags: PropTypes.number,
});
Topic.propTypes = {
intl: intlShape.isRequired,
topic: topicShape,
showDivider: PropTypes.bool,
index: PropTypes.number,
};
Topic.defaultProps = {
showDivider: true,
index: -1,
topic: {
usage_key: '',
},
};
export default injectIntl(Topic);

View File

@@ -1,4 +0,0 @@
/* eslint-disable import/prefer-default-export */
export { default as ArchivedBaseGroup } from './ArchivedBaseGroup';
export { default as SectionBaseGroup } from './SectionBaseGroup';
export { default as Topic } from './Topic';

View File

@@ -4,8 +4,6 @@ import { isEmpty } from 'lodash';
import { useDispatch, useSelector } from 'react-redux';
import { useParams } from 'react-router-dom';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import FilterBar from '../../../components/FilterBar';
import { selectCourseCohorts } from '../../cohorts/data/selectors';
import { fetchCourseCohorts } from '../../cohorts/data/thunks';
@@ -41,20 +39,12 @@ function LearnerPostFilterBar() {
const handleFilterChange = (event) => {
const { name, value } = event.currentTarget;
const filterContentEventProperties = {
statusFilter: postFilter.status,
threadTypeFilter: postFilter.postType,
sortFilter: postFilter.orderBy,
cohortFilter: postFilter.cohort,
triggeredBy: name,
};
if (name === 'postType') {
if (postFilter.postType !== value) {
dispatch(setPostFilter({
...postFilter,
postType: value,
}));
filterContentEventProperties.threadTypeFilter = value;
}
} else if (name === 'status') {
if (postFilter.status !== value) {
@@ -62,7 +52,6 @@ function LearnerPostFilterBar() {
...postFilter,
status: value,
}));
filterContentEventProperties.statusFilter = value;
}
} else if (name === 'orderBy') {
if (postFilter.orderBy !== value) {
@@ -70,7 +59,6 @@ function LearnerPostFilterBar() {
...postFilter,
orderBy: value,
}));
filterContentEventProperties.sortFilter = value;
}
} else if (name === 'cohort') {
if (postFilter.cohort !== value) {
@@ -78,10 +66,8 @@ function LearnerPostFilterBar() {
...postFilter,
cohort: value,
}));
filterContentEventProperties.cohortFilter = value;
}
}
sendTrackEvent('edx.forum.filter.content', filterContentEventProperties);
};
useEffect(() => {

View File

@@ -16,9 +16,9 @@ function LearnerCard({
learner,
courseId,
}) {
const { enableInContextSidebar, learnerUsername } = useContext(DiscussionContext);
const { inContext, learnerUsername } = useContext(DiscussionContext);
const linkUrl = discussionsPath(Routes.LEARNERS.POSTS, {
0: enableInContextSidebar ? 'in-context' : undefined,
0: inContext ? 'in-context' : undefined,
learnerUsername: learner.username,
courseId,
});

View File

@@ -4,7 +4,6 @@ import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useDispatch, useSelector } from 'react-redux';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Collapsible, Form, Icon } from '@edx/paragon';
import { Check, Tune } from '@edx/paragon/icons';
@@ -59,12 +58,6 @@ function LearnerFilterBar({
if (name === 'sort') {
dispatch(setSortedBy(value));
sendTrackEvent(
'edx.forum.sort.user',
{
sort: value,
},
);
}
};

View File

@@ -31,11 +31,6 @@ const messages = defineMessages({
defaultMessage: 'Delete',
description: 'Action to delete a post or comment',
},
confirmationConfirm: {
id: 'discussions.confirmation.button.confirm',
defaultMessage: 'Confirm',
description: 'Confirm button shown on confirmation dialog',
},
closeAction: {
id: 'discussions.actions.close',
defaultMessage: 'Close',
@@ -76,11 +71,16 @@ const messages = defineMessages({
defaultMessage: 'Unmark as answered',
description: 'Action to unmark a comment as answering a post',
},
confirmationCancel: {
id: 'discussions.modal.confirmation.button.cancel',
deleteConfirmationCancel: {
id: 'discussions.delete.confirmation.button.cancel',
defaultMessage: 'Cancel',
description: 'Cancel button shown on delete confirmation dialog',
},
deleteConfirmationDelete: {
id: 'discussions.delete.confirmation.button.delete',
defaultMessage: 'Delete',
description: 'Delete button shown on delete confirmation dialog',
},
emptyAllTopics: {
id: 'discussions.empty.allTopics',
defaultMessage:
@@ -185,8 +185,8 @@ const messages = defineMessages({
},
blackoutDiscussionInformation: {
id: 'discussion.blackoutBanner.information',
defaultMessage: 'Posting in discussions is temporarily disabled by the course team',
description: 'Informative text when discussion posting is disabled',
defaultMessage: 'Blackout dates are currently active. Posting in discussions is unavailable at this time.',
description: 'Informative text when discussions blackout is active',
},
imageWarningMessage: {
id: 'discussions.editor.image.warning.message',

View File

@@ -0,0 +1,90 @@
import React, { useContext } from 'react';
import { useSelector } from 'react-redux';
import { Routes } from '../../../data/constants';
import { selectBlocks, selectChapters } from '../../../data/selectors';
import { DiscussionContext } from '../../common/context';
import { selectTopic } from '../../topics/data/selectors';
import { discussionsPath } from '../../utils';
import BreadcrumbDropdown from './BreadcrumbDropdown';
function BreadcrumbMenu() {
const {
courseId,
topicId,
category,
} = useContext(DiscussionContext);
const blocks = useSelector(selectBlocks);
const chapters = useSelector(selectChapters);
const blockKey = useSelector(selectTopic(topicId))?.usageKey || category;
let currentChapter = null;
let currentVertical = null;
let currentSequential = null;
if (!blocks[blockKey]) {
// Data is still loading
return null;
}
if (blocks[blockKey].type === 'chapter') {
currentChapter = blockKey;
} else if (blocks[blockKey].type === 'sequential') {
currentSequential = blockKey;
currentChapter = blocks[currentSequential].parent;
} else if (blocks[blockKey].type === 'vertical') {
currentVertical = blockKey;
currentSequential = blocks[currentVertical].parent;
currentChapter = blocks[currentSequential].parent;
}
const getItemDisplayName = itemId => blocks[itemId]?.displayName;
const getItemPath = itemId => discussionsPath(Routes.TOPICS.CATEGORY, {
courseId,
category: itemId,
});
return (
<div className="breadcrumb-menu d-flex flex-row bg-light-200 box-shadow-down-1 px-2.5 py-1">
<BreadcrumbDropdown
currentItem={currentChapter}
showAllPath={discussionsPath(Routes.TOPICS.ALL, { courseId })}
items={chapters}
itemPathFunc={getItemPath}
itemActiveFunc={item => item === currentChapter}
itemLabelFunc={getItemDisplayName}
/>
{currentChapter
&& (
<>
<div className="d-flex py-2">/</div>
<BreadcrumbDropdown
currentItem={currentSequential}
showAllPath={getItemPath(currentChapter)}
items={blocks[currentChapter].children}
itemPathFunc={getItemPath}
itemActiveFunc={seqId => seqId === currentChapter}
itemLabelFunc={getItemDisplayName}
/>
</>
)}
{currentSequential
&& (
<>
<div className="d-flex py-2">/</div>
<BreadcrumbDropdown
currentItem={currentVertical}
showAllPath={getItemPath(currentSequential)}
items={blocks[currentSequential].children}
itemPathFunc={getItemPath}
itemActiveFunc={vertId => vertId === currentChapter}
itemLabelFunc={getItemDisplayName}
/>
</>
)}
</div>
);
}
BreadcrumbMenu.propTypes = {};
export default BreadcrumbMenu;

View File

@@ -0,0 +1,151 @@
import React from 'react';
import {
act, fireEvent, render, screen, waitFor,
} from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import { IntlProvider } from 'react-intl';
import { MemoryRouter, Route } from 'react-router';
import { Factory } from 'rosie';
import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
import { getBlocksAPIResponse } from '../../../data/__factories__';
import { getBlocksAPIURL } from '../../../data/api';
import { getApiBaseUrl, Routes } from '../../../data/constants';
import { fetchCourseBlocks } from '../../../data/thunks';
import { initializeStore } from '../../../store';
import { executeThunk } from '../../../test-utils';
import { DiscussionContext } from '../../common/context';
import { fetchCourseTopics } from '../../topics/data/thunks';
import { BreadcrumbMenu } from '../index';
import '../../topics/data/__factories__';
const courseId = 'course-v1:edX+DemoX+Demo_Course';
const topicsApiUrl = `${getApiBaseUrl()}/api/discussion/v2/course_topics/${courseId}`;
let store;
let axiosMock;
function renderComponent(path, topicId = null, category = null) {
render(
<IntlProvider locale="en">
<AppProvider store={store}>
<DiscussionContext.Provider
value={{
courseId,
topicId,
category,
}}
>
<MemoryRouter initialEntries={[path]}>
<Route
path={[
Routes.POSTS.PATH,
Routes.TOPICS.CATEGORY,
]}
component={BreadcrumbMenu}
/>
</MemoryRouter>
</DiscussionContext.Provider>
</AppProvider>
</IntlProvider>,
);
}
describe('BreadcrumbMenu', () => {
let blocksAPIResponse;
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore({
config: {
provider: 'openedx',
},
blocks: {
topics: {},
},
});
Factory.resetAll();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
blocksAPIResponse = getBlocksAPIResponse();
axiosMock.onGet(getBlocksAPIURL())
.reply(200, blocksAPIResponse);
await executeThunk(fetchCourseBlocks(courseId, 'test-user'), store.dispatch, store.getState);
const data = [
...Factory.buildList('topic.v2', 3, { usage_key: null }, { topicPrefix: 'ncw' }),
Factory.build('topic.v2', { id: 'vertical_0270f6de40fc' }),
Factory.build('topic.v2', { id: '867dddb6f55d410caaa9c1eb9c6743ec' }),
Factory.build('topic.v2', { id: '4f6c1b4e316a419ab5b6bf30e6c708e9' }),
];
axiosMock
.onGet(topicsApiUrl)
.reply(200, data);
await executeThunk(fetchCourseTopics(courseId), store.dispatch, store.getState);
});
it('shows the category dropdown with a category selected', async () => {
const chapterKey = 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@d8a6192ade314473a78242dfeedfbf5b';
const sectionKey = 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@edx_introduction';
renderComponent(`/${courseId}/category/${chapterKey}`, null, chapterKey);
await waitFor(() => screen.findByText(blocksAPIResponse.blocks[chapterKey].display_name));
const chapterDropdown = screen.queryByText(blocksAPIResponse.blocks[chapterKey].display_name);
// Since a category is selected a subcategory dropdown should also be visible with "show all" selected by default
const sectionDropdown = screen.queryByRole('button', { name: 'Show all' });
// A show all button should show up that lists topics in the current category
expect(sectionDropdown)
.toBeInTheDocument();
// Other categories should not be visible.
expect(screen.queryByText(blocksAPIResponse.blocks[sectionKey].display_name))
.not
.toBeInTheDocument();
// Click on the category dropdown.
act(() => {
fireEvent.click(chapterDropdown);
});
// Now other categories should be visible in the dropdown.
expect(screen.queryByText(blocksAPIResponse.blocks[chapterKey].display_name))
.toBeInTheDocument();
// There are 4 categories but this has a length of 5 since there is also a link to show all.
expect(screen.queryAllByRole('link', { exact: false }))
.toHaveLength(5);
// Now click on the topics dropdown
act(() => {
fireEvent.click(sectionDropdown);
});
// Topics in the category should be visible.
expect(screen.queryByRole('link', { name: 'Demo Course Overview' }))
.toBeInTheDocument();
});
it('shows the category correct dropdown labels with a topic selected', async () => {
const topicId = 'vertical_0270f6de40fc';
renderComponent(`/${courseId}/topics/${topicId}`, topicId);
// Since a topic is selected, we have both a category and topic, so "show all shouldn't be visible"
expect(screen.queryByText('Show all'))
.not
.toBeInTheDocument();
// The name of the category and topic should be visible.
expect(await screen.findByRole('button', { name: 'Introduction' }))
.toBeInTheDocument();
expect(await screen.findByRole('button', { name: 'Demo Course Overview' }))
.toBeInTheDocument();
expect(await screen.findByRole('button', { name: 'Introduction: Video and Sequences' }))
.toBeInTheDocument();
});
});

View File

@@ -1,3 +1,4 @@
/* eslint-disable import/prefer-default-export */
export { default as BreadcrumbMenu } from './breadcrumb-menu/BreadcrumbMenu';
export { default as LegacyBreadcrumbMenu } from './breadcrumb-menu/LegacyBreadcrumbMenu';
export { default as NavigationBar } from './navigation-bar/NavigationBar';

View File

@@ -4,24 +4,21 @@ import { useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { selectAreThreadsFiltered } from '../data/selectors';
import { selectTopicFilter } from '../in-context-topics/data/selectors';
import messages from '../messages';
function NoResults({ intl }) {
const postsFiltered = useSelector(selectAreThreadsFiltered);
const inContextTopicsFilter = useSelector(selectTopicFilter);
const topicsFilter = useSelector(({ topics }) => topics.filter);
const filters = useSelector((state) => state.threads.filters);
const learnersFilter = useSelector(({ learners }) => learners.usernameSearch);
const isFiltered = postsFiltered || (topicsFilter !== '')
|| (learnersFilter !== null) || (inContextTopicsFilter !== '');
const isFiltered = postsFiltered || (topicsFilter !== '') || (learnersFilter !== null);
let helpMessage = messages.removeFilters;
if (!isFiltered) {
return null;
} if (filters.search || learnersFilter) {
helpMessage = messages.removeKeywords;
} if (topicsFilter || inContextTopicsFilter) {
} if (topicsFilter) {
helpMessage = messages.removeKeywordsOnly;
}
const titleCssClasses = classNames(

View File

@@ -22,9 +22,7 @@ import { fetchThreads } from './data/thunks';
import NoResults from './NoResults';
import { PostLink } from './post';
function PostsList({
posts, topics, intl, isTopicTab,
}) {
function PostsList({ posts, topics, intl }) {
const dispatch = useDispatch();
const {
courseId,
@@ -40,7 +38,7 @@ function PostsList({
const userIsStaff = useSelector(selectUserIsStaff);
const configStatus = useSelector(selectconfigLoadingStatus);
const loadThreads = (topicIds, pageNum = undefined, isFilterChanged = false) => {
const loadThreads = (topicIds, pageNum = undefined) => {
const params = {
orderBy,
filters,
@@ -48,10 +46,9 @@ function PostsList({
author: showOwnPosts ? authenticatedUser.username : null,
countFlagged: (userHasModerationPrivileges || userIsStaff) || undefined,
topicIds,
isFilterChanged,
};
if (showOwnPosts) {
if (showOwnPosts && filters.search === '') {
dispatch(fetchUserPosts(courseId, params));
} else {
dispatch(fetchThreads(courseId, params));
@@ -62,11 +59,7 @@ function PostsList({
if (topics !== undefined && configStatus === RequestStatus.SUCCESSFUL) {
loadThreads(topics);
}
}, [courseId, filters, orderBy, page, JSON.stringify(topics), configStatus]);
useEffect(() => {
if (isTopicTab) { loadThreads(topics, 1, true); }
}, [filters]);
}, [courseId, orderBy, filters, page, JSON.stringify(topics), configStatus]);
const checkIsSelected = (id) => window.location.pathname.includes(id);
const pinnedPosts = useMemo(() => filterPosts(posts, 'pinned'), [posts]);
@@ -90,7 +83,7 @@ function PostsList({
{postInstances(unpinnedPosts)}
{posts?.length === 0 && loadingStatus === RequestStatus.SUCCESSFUL && <NoResults />}
{loadingStatus === RequestStatus.IN_PROGRESS ? (
<div className="d-flex justify-content-center p-4 mx-auto my-auto">
<div className="d-flex justify-content-center p-4">
<Spinner animation="border" variant="primary" size="lg" />
</div>
) : (
@@ -110,14 +103,12 @@ PostsList.propTypes = {
id: PropTypes.string.isRequired,
})),
topics: PropTypes.arrayOf(PropTypes.string),
isTopicTab: PropTypes.bool,
intl: intlShape.isRequired,
};
PostsList.defaultProps = {
posts: [],
topics: undefined,
isTopicTab: false,
};
export default injectIntl(PostsList);

View File

@@ -1,18 +1,11 @@
import React, { useContext, useEffect } from 'react';
import React, { useContext } from 'react';
import PropTypes from 'prop-types';
import isEmpty from 'lodash/isEmpty';
import { useDispatch, useSelector } from 'react-redux';
import SearchInfo from '../../components/SearchInfo';
import { selectCurrentCategoryGrouping, selectTopicsUnderCategory } from '../../data/selectors';
import { DiscussionContext } from '../common/context';
import { selectEnableInContext } from '../data/selectors';
import { selectTopics as selectInContextTopics } from '../in-context-topics/data/selectors';
import { fetchCourseTopicsV3 } from '../in-context-topics/data/thunks';
import { selectTopics } from '../topics/data/selectors';
import { fetchCourseTopics } from '../topics/data/thunks';
import { handleKeyDown } from '../utils';
import {
selectAllThreads,
selectTopicThreads,
@@ -28,7 +21,7 @@ function AllPostsList() {
function TopicPostsList({ topicId }) {
const posts = useSelector(selectTopicThreads([topicId]));
return <PostsList posts={posts} topics={[topicId]} isTopicTab />;
return <PostsList posts={posts} topics={[topicId]} />;
}
TopicPostsList.propTypes = {
@@ -36,10 +29,10 @@ TopicPostsList.propTypes = {
};
function CategoryPostsList({ category }) {
const { enableInContextSidebar } = useContext(DiscussionContext);
const { inContext } = useContext(DiscussionContext);
const groupedCategory = useSelector(selectCurrentCategoryGrouping)(category);
// If grouping at subsection is enabled, only apply it when browsing discussions in context in the learning MFE.
const topicIds = useSelector(selectTopicsUnderCategory)(enableInContextSidebar ? groupedCategory : category);
const topicIds = useSelector(selectTopicsUnderCategory)(inContext ? groupedCategory : category);
const posts = useSelector(selectTopicThreads(topicIds));
return <PostsList posts={posts} topics={topicIds} />;
}
@@ -52,24 +45,12 @@ function PostsView() {
const {
topicId,
category,
courseId,
enableInContextSidebar,
} = useContext(DiscussionContext);
const dispatch = useDispatch();
const enableInContext = useSelector(selectEnableInContext);
const searchString = useSelector(({ threads }) => threads.filters.search);
const resultsFound = useSelector(({ threads }) => threads.totalThreads);
const textSearchRewrite = useSelector(({ threads }) => threads.textSearchRewrite);
const loadingStatus = useSelector(({ threads }) => threads.status);
const topics = useSelector(enableInContext ? selectInContextTopics : selectTopics);
useEffect(() => {
if (isEmpty(topics)) {
dispatch((enableInContext || enableInContextSidebar)
? fetchCourseTopicsV3(courseId)
: fetchCourseTopics(courseId));
}
}, [topics]);
let postsListComponent;
@@ -81,6 +62,20 @@ function PostsView() {
postsListComponent = <AllPostsList />;
}
const handleKeyDown = (event) => {
const { key } = event;
if (key !== 'ArrowDown' && key !== 'ArrowUp') { return; }
const option = event.target;
let selectedOption;
if (key === 'ArrowDown') { selectedOption = option.nextElementSibling; }
if (key === 'ArrowUp') { selectedOption = option.previousElementSibling; }
if (selectedOption) {
selectedOption.focus();
}
};
return (
<div className="discussion-posts d-flex flex-column h-100">
{searchString && (

View File

@@ -13,31 +13,27 @@ import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
import { getApiBaseUrl, Routes, ThreadType } from '../../data/constants';
import { Routes, ThreadType } from '../../data/constants';
import { initializeStore } from '../../store';
import { executeThunk } from '../../test-utils';
import { getCohortsApiUrl } from '../cohorts/data/api';
import { DiscussionContext } from '../common/context';
import { fetchConfigSuccess } from '../data/slices';
import { getCoursesApiUrl } from '../learners/data/api';
import { fetchCourseTopics } from '../topics/data/thunks';
import { getThreadsApiUrl } from './data/api';
import { PostsView } from './index';
import './data/__factories__';
import '../cohorts/data/__factories__';
import '../topics/data/__factories__';
const courseId = 'course-v1:edX+TestX+Test_Course';
const coursesApiUrl = getCoursesApiUrl();
const threadsApiUrl = getThreadsApiUrl();
const topicsApiUrl = `${getApiBaseUrl()}/api/discussion/v1/course_topics/${courseId}`;
let store;
let axiosMock;
const username = 'abc123';
async function renderComponent({
postId, topicId, category, myPosts, enableInContextSidebar = false,
postId, topicId, category, myPosts, inContext = false,
} = { myPosts: false }) {
let path = generatePath(Routes.POSTS.ALL_POSTS, { courseId });
let page;
@@ -64,7 +60,7 @@ async function renderComponent({
topicId,
category,
page,
enableInContextSidebar,
inContext,
}}
>
<Switch>
@@ -110,12 +106,6 @@ describe('PostsView', () => {
pageSize: 6,
})];
});
axiosMock
.onGet(topicsApiUrl)
.reply(200, {
courseware_topics: Factory.buildList('category', 2),
non_courseware_topics: Factory.buildList('topic', 3, {}, { topicPrefix: 'ncw' }),
});
});
function setupStore(data = {}) {
@@ -124,6 +114,7 @@ describe('PostsView', () => {
config: { hasModerationPrivileges: true },
...data,
};
// console.log(storeData);
store = initializeStore(storeData);
store.dispatch(fetchConfigSuccess({}));
}
@@ -182,7 +173,7 @@ describe('PostsView', () => {
config: { groupAtSubsection: grouping, hasModerationPrivileges: true, provider: 'openedx' },
});
await act(async () => {
await renderComponent({ category: 'test-usage-key', enableInContextSidebar: true, p: true });
await renderComponent({ category: 'test-usage-key', inContext: true, p: true });
});
const topicThreadCount = Math.ceil(threadCount / 3);
expect(screen.queryAllByText(/this is thread-\d+ in topic some-topic-2/i))
@@ -206,8 +197,6 @@ describe('PostsView', () => {
beforeEach(async () => {
setupStore();
await executeThunk(fetchCourseTopics(courseId), store.dispatch, store.getState);
await act(async () => {
await renderComponent();
});

View File

@@ -6,8 +6,6 @@ const selectThreads = state => state.threads.threadsById;
const mapIdsToThreads = (ids, threads) => ids.map(id => threads?.[id]);
export const selectPostEditorVisible = state => state.threads.postEditorVisible;
export const selectTopicThreads = topicIds => createSelector(
[
state => (topicIds || []).flatMap(topicId => state.threads.threadsInTopic[topicId] || []),

View File

@@ -1,6 +1,5 @@
/* eslint-disable no-param-reassign,import/prefer-default-export */
import { createSlice } from '@reduxjs/toolkit';
import omitBy from 'lodash/omitBy';
import {
PostsStatusFilter, RequestStatus, ThreadOrdering, ThreadType,
@@ -58,6 +57,7 @@ const threadsSlice = createSlice({
if (state.author !== payload.author) {
state.pages = [];
state.author = payload.author;
state.totalThreads = null;
}
state.status = RequestStatus.IN_PROGRESS;
},
@@ -79,13 +79,7 @@ const threadsSlice = createSlice({
}
state.status = RequestStatus.SUCCESSFUL;
state.threadsById = { ...state.threadsById, ...payload.threadsById };
// filter
if (payload.isFilterChanged) {
state.threadsInTopic = { ...payload.threadsInTopic };
} else {
state.threadsInTopic = mergeThreadsInTopics(state.threadsInTopic, payload.threadsInTopic);
}
state.threadsInTopic = mergeThreadsInTopics(state.threadsInTopic, payload.threadsInTopic);
state.avatars = { ...state.avatars, ...payload.avatars };
state.nextPage = (payload.page < payload.pagination.numPages) ? payload.page + 1 : null;
state.totalPages = payload.pagination.numPages;
@@ -161,11 +155,9 @@ const threadsSlice = createSlice({
},
updateThreadFailed: (state) => {
state.postStatus = RequestStatus.FAILED;
state.totalThreads = 0;
},
updateThreadDenied: (state) => {
state.postStatus = RequestStatus.DENIED;
state.totalThreads = 0;
},
deleteThreadRequest: (state) => {
state.postStatus = RequestStatus.IN_PROGRESS;
@@ -176,7 +168,7 @@ const threadsSlice = createSlice({
state.postStatus = RequestStatus.SUCCESSFUL;
state.threadsInTopic[topicId] = state.threadsInTopic[topicId].filter(item => item !== threadId);
state.pages = state.pages.map(page => page?.filter(item => item !== threadId));
state.threadsById = omitBy(state.threadsById, (thread) => thread.id === threadId);
delete state.threadsById[threadId];
},
deleteThreadFailed: (state) => {
state.postStatus = RequestStatus.FAILED;
@@ -218,19 +210,6 @@ const threadsSlice = createSlice({
clearRedirect: (state) => {
state.redirectToThread = null;
},
clearFilter: (state) => {
state.filters = {
status: PostsStatusFilter.ALL,
postType: ThreadType.ALL,
cohort: '',
search: '',
};
state.pages = [];
},
clearSort: (state) => {
state.sortedBy = ThreadOrdering.BY_LAST_ACTIVITY;
state.pages = [];
},
},
});
@@ -267,8 +246,6 @@ export const {
hidePostEditor,
clearRedirect,
clearPostsPages,
clearFilter,
clearSort,
} = threadsSlice.actions;
export const threadsReducer = threadsSlice.reducer;

View File

@@ -102,7 +102,6 @@ export function fetchThreads(courseId, {
author = null,
filters = {},
page = 1,
isFilterChanged,
countFlagged,
} = {}) {
const options = {
@@ -142,7 +141,7 @@ export function fetchThreads(courseId, {
const data = await getThreads(courseId, options);
const normalisedData = normaliseThreads(camelCaseObject(data), topicIds);
dispatch(fetchThreadsSuccess({
...normalisedData, page, author, textSearchRewrite: data.text_search_rewrite, isFilterChanged,
...normalisedData, page, author, textSearchRewrite: data.text_search_rewrite,
}));
} catch (error) {
if (getHttpErrorStatus(error) === 403) {

View File

@@ -1,4 +1,5 @@
import React, { useContext } from 'react';
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useDispatch, useSelector } from 'react-redux';
@@ -11,11 +12,8 @@ import { Close } from '@edx/paragon/icons';
import Search from '../../../components/Search';
import { RequestStatus } from '../../../data/constants';
import { DiscussionContext } from '../../common/context';
import { useUserCanAddThreadInBlackoutDate } from '../../data/hooks';
import { selectconfigLoadingStatus, selectEnableInContext } from '../../data/selectors';
import { TopicSearchBar as IncontextSearch } from '../../in-context-topics/topic-search';
import { postMessageToParent } from '../../utils';
import { selectBlackoutDate, selectconfigLoadingStatus } from '../../data/selectors';
import { inBlackoutDateRange, postMessageToParent } from '../../utils';
import { showPostEditor } from '../data';
import messages from './messages';
@@ -23,44 +21,38 @@ import './actionBar.scss';
function PostActionsBar({
intl,
inContext,
}) {
const dispatch = useDispatch();
const loadingStatus = useSelector(selectconfigLoadingStatus);
const enableInContext = useSelector(selectEnableInContext);
const userCanAddThreadInBlackoutDate = useUserCanAddThreadInBlackoutDate();
const { enableInContextSidebar, page } = useContext(DiscussionContext);
const blackoutDateRange = useSelector(selectBlackoutDate);
const handleCloseInContext = () => {
postMessageToParent('learning.events.sidebar.close');
};
return (
<div className={classNames('d-flex justify-content-end flex-grow-1', { 'py-1': !enableInContextSidebar })}>
{!enableInContextSidebar && (
(enableInContext && ['topics', 'category'].includes(page))
? <IncontextSearch />
: <Search />
)}
{enableInContextSidebar && (
<div className={classNames('d-flex justify-content-end flex-grow-1', { 'py-1': !inContext })}>
{!inContext && <Search />}
{inContext && (
<h4 className="d-flex flex-grow-1 font-weight-bold my-0 py-0 align-self-center">
{intl.formatMessage(messages.title)}
</h4>
)}
{loadingStatus === RequestStatus.SUCCESSFUL && userCanAddThreadInBlackoutDate
&& (
{(!inBlackoutDateRange(blackoutDateRange) && loadingStatus === RequestStatus.SUCCESSFUL) && (
<>
{!enableInContextSidebar && <div className="border-right border-light-400 mx-3" />}
{!inContext && <div className="border-right border-light-400 mx-3" />}
<Button
variant={enableInContextSidebar ? 'plain' : 'brand'}
className={classNames('my-0', { 'p-0': enableInContextSidebar })}
variant={inContext ? 'plain' : 'brand'}
className={classNames('my-0', { 'p-0': inContext })}
onClick={() => dispatch(showPostEditor())}
size={enableInContextSidebar ? 'md' : 'sm'}
size={inContext ? 'md' : 'sm'}
>
{intl.formatMessage(messages.addAPost)}
</Button>
</>
)}
{enableInContextSidebar && (
{inContext && (
<>
<div className="border-right border-light-300 mr-2 ml-3.5 my-2" />
<IconButton
@@ -77,6 +69,7 @@ function PostActionsBar({
PostActionsBar.propTypes = {
intl: intlShape.isRequired,
inContext: PropTypes.bool.isRequired,
};
export default injectIntl(PostActionsBar);

View File

@@ -28,19 +28,12 @@ import { useCurrentDiscussionTopic } from '../../data/hooks';
import {
selectAnonymousPostingConfig,
selectDivisionSettings,
selectEnableInContext,
selectModerationSettings,
selectUserHasModerationPrivileges,
selectUserIsGroupTa,
selectUserIsStaff,
} from '../../data/selectors';
import { EmptyPage } from '../../empty-posts';
import {
selectArchivedTopics,
selectCoursewareTopics as inContextCourseware,
selectNonCoursewareIds as inContextCoursewareIds,
selectNonCoursewareTopics as inContextNonCourseware,
} from '../../in-context-topics/data/selectors';
import { selectCoursewareTopics, selectNonCoursewareIds, selectNonCoursewareTopics } from '../../topics/data/selectors';
import {
discussionsPath, formikCompatibleHandler, isFormikFieldInvalid, useCommentsPagePath,
@@ -59,7 +52,7 @@ function DiscussionPostType({
}) {
// Need to use regular label since Form.Label doesn't support overriding htmlFor
return (
<label htmlFor={`post-type-${value}`} className="d-flex p-0 my-0 mr-3">
<label htmlFor={`post-type-${value}`} className="d-flex p-0 my-2 mr-3">
<Form.Radio value={value} id={`post-type-${value}`} className="sr-only">{type}</Form.Radio>
<Card
className={classNames('border-2 shadow-none', {
@@ -101,12 +94,10 @@ function PostEditor({
courseId,
postId,
} = useParams();
const { category, enableInContextSidebar } = useContext(DiscussionContext);
const topicId = useCurrentDiscussionTopic();
const enableInContext = useSelector(selectEnableInContext);
const nonCoursewareTopics = useSelector(enableInContext ? inContextNonCourseware : selectNonCoursewareTopics);
const nonCoursewareIds = useSelector(enableInContext ? inContextCoursewareIds : selectNonCoursewareIds);
const coursewareTopics = useSelector(enableInContext ? inContextCourseware : selectCoursewareTopics);
const nonCoursewareTopics = useSelector(selectNonCoursewareTopics);
const nonCoursewareIds = useSelector(selectNonCoursewareIds);
const coursewareTopics = useSelector(selectCoursewareTopics);
const cohorts = useSelector(selectCourseCohorts);
const post = useSelector(selectThread(postId));
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
@@ -115,7 +106,7 @@ function PostEditor({
const { allowAnonymous, allowAnonymousToPeers } = useSelector(selectAnonymousPostingConfig);
const { reasonCodesEnabled, editReasons } = useSelector(selectModerationSettings);
const userIsStaff = useSelector(selectUserIsStaff);
const archivedTopics = useSelector(selectArchivedTopics);
const { category, inContext } = useContext(DiscussionContext);
const canDisplayEditReason = (reasonCodesEnabled && editExisting
&& (userHasModerationPrivileges || userIsGroupTa || userIsStaff)
@@ -147,7 +138,7 @@ function PostEditor({
follow: isEmpty(post?.following) ? true : post?.following,
anonymous: allowAnonymous ? false : undefined,
anonymousToPeers: allowAnonymousToPeers ? false : undefined,
editReasonCode: post?.lastEdit?.reasonCode || (userIsStaff && canDisplayEditReason ? 'violates-guidelines' : undefined),
editReasonCode: post?.lastEdit?.reasonCode || (userIsStaff ? 'violates-guidelines' : ''),
cohort: post?.cohort || 'default',
};
@@ -249,10 +240,6 @@ function PostEditor({
const postEditorId = `post-editor-${editExisting ? postId : 'new'}`;
const handleInContextSelectLabel = (section, subsection) => (
`${section.displayName} / ${subsection.displayName}` || intl.formatMessage(messages.unnamedTopics)
);
return (
<Formik
enableReinitialize
@@ -270,11 +257,11 @@ function PostEditor({
resetForm,
}) => (
<Form className="m-4 card p-4 post-form" onSubmit={handleSubmit}>
<h4 className="mb-4" style={{ lineHeight: '16px' }}>
<h3 className="mb-3">
{editExisting
? intl.formatMessage(messages.editPostHeading)
: intl.formatMessage(messages.addPostHeading)}
</h4>
</h3>
<Form.RadioSet
name="postType"
className="d-flex flex-row flex-wrap"
@@ -288,12 +275,14 @@ function PostEditor({
selected={values.postType === 'discussion'}
type={intl.formatMessage(messages.discussionType)}
icon={<Post />}
description={intl.formatMessage(messages.discussionDescription)}
/>
<DiscussionPostType
value="question"
selected={values.postType === 'question'}
type={intl.formatMessage(messages.questionType)}
icon={<Help />}
description={intl.formatMessage(messages.questionDescription)}
/>
</Form.RadioSet>
<div className="d-flex flex-row my-4.5 justify-content-between">
@@ -307,7 +296,7 @@ function PostEditor({
onBlur={handleBlur}
aria-describedby="topicAreaInput"
floatingLabel={intl.formatMessage(messages.topicArea)}
disabled={enableInContextSidebar}
disabled={inContext}
>
{nonCoursewareTopics.map(topic => (
<option
@@ -316,46 +305,15 @@ function PostEditor({
>{topic.name || intl.formatMessage(messages.unnamedSubTopics)}
</option>
))}
{enableInContext ? (
<>
{coursewareTopics?.map(section => (
section?.children?.map(subsection => (
<optgroup
label={handleInContextSelectLabel(section, subsection)}
key={subsection.id}
>
{subsection?.children?.map(unit => (
<option key={unit.id} value={unit.id}>
{unit.name || intl.formatMessage(messages.unnamedSubTopics)}
</option>
))}
</optgroup>
))
{coursewareTopics.map(categoryObj => (
<optgroup label={categoryObj.name || intl.formatMessage(messages.unnamedTopics)} key={categoryObj.id}>
{categoryObj.topics.map(subtopic => (
<option key={subtopic.id} value={subtopic.id}>
{subtopic.name || intl.formatMessage(messages.unnamedSubTopics)}
</option>
))}
{(userIsStaff || userIsGroupTa || userHasModerationPrivileges) && (
<optgroup label={intl.formatMessage(messages.archivedTopics)}>
{archivedTopics.map(topic => (
<option key={topic.id} value={topic.id}>
{topic.name || intl.formatMessage(messages.unnamedSubTopics)}
</option>
))}
</optgroup>
)}
</>
) : (
coursewareTopics.map(categoryObj => (
<optgroup
label={categoryObj.name || intl.formatMessage(messages.unnamedTopics)}
key={categoryObj.id}
>
{categoryObj.topics.map(subtopic => (
<option key={subtopic.id} value={subtopic.id}>
{subtopic.name || intl.formatMessage(messages.unnamedSubTopics)}
</option>
))}
</optgroup>
))
)}
</optgroup>
))}
</Form.Control>
</Form.Group>
{canSelectCohort(values.topic) && (
@@ -378,8 +336,8 @@ function PostEditor({
</Form.Group>
)}
</div>
<div className="d-flex flex-row mb-4.5 justify-content-between">
<div className="border-bottom border-light-400" />
<div className="d-flex flex-row my-4.5 justify-content-between">
<Form.Group
className="w-100 m-0"
isInvalid={isFormikFieldInvalid('title', {
@@ -401,7 +359,7 @@ function PostEditor({
</Form.Group>
{canDisplayEditReason && (
<Form.Group
className="w-100 ml-4 mb-0"
className="w-100 ml-3 mb-0"
isInvalid={isFormikFieldInvalid('editReasonCode', {
errors,
touched,
@@ -426,7 +384,7 @@ function PostEditor({
</Form.Group>
)}
</div>
<div className="mb-3">
<div className="mb-2">
<TinyMCEEditor
onInit={
/* istanbul ignore next: TinyMCE is mocked so this cannot be easily tested */
@@ -444,7 +402,7 @@ function PostEditor({
<PostPreviewPane htmlNode={values.comment} isPost editExisting={editExisting} />
<div className="d-flex flex-row mt-n4 w-75 text-primary">
<div className="d-flex flex-row mt-n4.5 w-75 text-primary">
{!editExisting && (
<>
<Form.Group>
@@ -455,9 +413,7 @@ function PostEditor({
onBlur={handleBlur}
className="mr-4.5"
>
<span className="font-size-14">
{intl.formatMessage(messages.followPost)}
</span>
{intl.formatMessage(messages.followPost)}
</Form.Checkbox>
</Form.Group>
{allowAnonymousToPeers && (
@@ -468,9 +424,7 @@ function PostEditor({
onChange={handleChange}
onBlur={handleBlur}
>
<span className="font-size-14">
{intl.formatMessage(messages.anonymousToPeersPost)}
</span>
{intl.formatMessage(messages.anonymousToPeersPost)}
</Form.Checkbox>
</Form.Group>
)}
@@ -478,7 +432,7 @@ function PostEditor({
)}
</div>
<div className="d-flex justify-content-end">
<div className="d-flex justify-content-end mt-2.5">
<Button
variant="outline-primary"
onClick={() => hideEditor(resetForm)}

View File

@@ -113,7 +113,7 @@ const messages = defineMessages({
},
showPreviewButton: {
id: 'discussions.editor.posts.showPreview.button',
defaultMessage: 'Show preview',
defaultMessage: 'Show Preview',
description: 'show preview button text to allow user to see their post content.',
},
actionsAlt: {
@@ -136,11 +136,6 @@ const messages = defineMessages({
defaultMessage: 'Thread not found',
description: 'message to show on screen if the request thread is not found in course',
},
archivedTopics: {
id: 'discussions.topics.archived.label',
defaultMessage: 'Archived',
description: 'Heading for displaying topics that are archived.',
},
});
export default messages;

View File

@@ -8,7 +8,6 @@ import { capitalize, isEmpty, toString } from 'lodash';
import { useDispatch, useSelector } from 'react-redux';
import { useParams } from 'react-router-dom';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import {
Collapsible, Form, Icon, Spinner,
@@ -85,13 +84,6 @@ function PostFilterBar({
name,
value,
} = event.currentTarget;
const filterContentEventProperties = {
statusFilter: currentStatus,
threadTypeFilter: currentType,
sortFilter: currentSorting,
cohortFilter: selectedCohort,
triggeredBy: name,
};
if (name === 'type') {
dispatch(setPostsTypeFilter(value));
if (
@@ -100,7 +92,6 @@ function PostFilterBar({
// You can't filter discussions by unanswered
dispatch(setStatusFilter(PostsStatusFilter.ALL));
}
filterContentEventProperties.threadTypeFilter = value;
}
if (name === 'status') {
dispatch(setStatusFilter(value));
@@ -112,17 +103,13 @@ function PostFilterBar({
// You can't filter questions by not responded so switch type to discussion
dispatch(setPostsTypeFilter(ThreadType.DISCUSSION));
}
filterContentEventProperties.statusFilter = value;
}
if (name === 'sort') {
dispatch(setSortedBy(value));
filterContentEventProperties.sortFilter = value;
}
if (name === 'cohort') {
dispatch(setCohortFilter(value));
filterContentEventProperties.cohortFilter = value;
}
sendTrackEvent('edx.forum.filter.content', filterContentEventProperties);
};
useEffect(() => {
@@ -148,15 +135,12 @@ function PostFilterBar({
cohort: capitalize(selectedCohort?.name),
})}
</span>
<span id="icon-tune">
<Collapsible.Visible whenClosed>
<Icon src={Tune} />
</Collapsible.Visible>
<Collapsible.Visible whenOpen>
<Icon src={Tune} />
</Collapsible.Visible>
</span>
<Collapsible.Visible whenClosed>
<Icon src={Tune} />
</Collapsible.Visible>
<Collapsible.Visible whenOpen>
<Icon src={Tune} />
</Collapsible.Visible>
</Collapsible.Trigger>
<Collapsible.Body className="collapsible-body px-4 pb-3 pt-0">
<Form>

View File

@@ -2,9 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import {
Icon, IconButton, OverlayTrigger, Tooltip,
} from '@edx/paragon';
import { Icon, IconButtonWithTooltip } from '@edx/paragon';
import { ThumbUpFilled, ThumbUpOutline } from '../../../components/icons';
import messages from './messages';
@@ -14,6 +12,7 @@ function LikeButton({
intl,
onClick,
voted,
preview,
}) {
const handleClick = (e) => {
e.preventDefault();
@@ -24,27 +23,20 @@ function LikeButton({
};
return (
<div className="d-flex align-items-center mr-36px text-primary-500">
<OverlayTrigger
overlay={(
<Tooltip id={`liked-${count}-tooltip`}>
{intl.formatMessage(voted ? messages.removeLike : messages.like)}
</Tooltip>
)}
>
<IconButton
src={voted ? ThumbUpFilled : ThumbUpOutline}
onClick={handleClick}
className="post-footer-icon-dimentions"
alt="Like"
iconAs={Icon}
iconClassNames="like-icon-dimentions"
/>
</OverlayTrigger>
<div className="font-family-inter font-style-normal">
{(count && count > 0) ? count : null}
</div>
<div className="d-flex align-items-center mr-4 text-primary-500">
<IconButtonWithTooltip
id={`like-${count}-tooltip`}
tooltipPlacement="top"
tooltipContent={intl.formatMessage(voted ? messages.removeLike : messages.like)}
src={voted ? ThumbUpFilled : ThumbUpOutline}
iconAs={Icon}
alt="Like"
onClick={handleClick}
size={preview ? 'inline' : 'sm'}
className={`mr-0.5 ${preview && 'p-3'}`}
iconClassNames={preview && 'icon-size'}
/>
{(count && count > 0) ? count : null}
</div>
);
}
@@ -54,11 +46,13 @@ LikeButton.propTypes = {
intl: intlShape.isRequired,
onClick: PropTypes.func,
voted: PropTypes.bool,
preview: PropTypes.bool,
};
LikeButton.defaultProps = {
voted: false,
onClick: undefined,
preview: false,
};
export default injectIntl(LikeButton);

View File

@@ -1,19 +1,17 @@
import React, { useContext, useState } from 'react';
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useDispatch, useSelector } from 'react-redux';
import { useHistory, useLocation } from 'react-router-dom';
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Hyperlink, useToggle } from '@edx/paragon';
import HTMLLoader from '../../../components/HTMLLoader';
import { ContentActions } from '../../../data/constants';
import { selectorForUnitSubsection, selectTopicContext } from '../../../data/selectors';
import { AlertBanner, Confirmation } from '../../common';
import { DiscussionContext } from '../../common/context';
import HoverCard from '../../common/HoverCard';
import { AlertBanner, DeleteConfirmation } from '../../common';
import { selectModerationSettings } from '../../data/selectors';
import { selectTopic } from '../../topics/data/selectors';
import { removeThread, updateExistingThread } from '../data/thunks';
@@ -27,48 +25,19 @@ function Post({
post,
preview,
intl,
handleAddResponseButton,
}) {
const location = useLocation();
const history = useHistory();
const dispatch = useDispatch();
const { enableInContextSidebar } = useContext(DiscussionContext);
const { courseId } = useSelector((state) => state.courseTabs);
const topic = useSelector(selectTopic(post.topicId));
const getTopicSubsection = useSelector(selectorForUnitSubsection);
const topicContext = useSelector(selectTopicContext(post.topicId));
const { reasonCodesEnabled } = useSelector(selectModerationSettings);
const [isDeleting, showDeleteConfirmation, hideDeleteConfirmation] = useToggle(false);
const [isReporting, showReportConfirmation, hideReportConfirmation] = useToggle(false);
const [isClosing, showClosePostModal, hideClosePostModal] = useToggle(false);
const [showHoverCard, setShowHoverCard] = useState(false);
const handleAbusedFlag = () => {
if (post.abuseFlagged) {
dispatch(updateExistingThread(post.id, { flagged: !post.abuseFlagged }));
} else {
showReportConfirmation();
}
};
const handleDeleteConfirmation = async () => {
await dispatch(removeThread(post.id));
history.push({
pathname: '.',
search: enableInContextSidebar && '?inContextSidebar',
});
hideDeleteConfirmation();
};
const handleReportConfirmation = () => {
dispatch(updateExistingThread(post.id, { flagged: !post.abuseFlagged }));
hideReportConfirmation();
};
const handleBlurEvent = (e) => {
if (!e.currentTarget.contains(e.relatedTarget)) {
setShowHoverCard(false);
}
};
const postURL = new URL(`${getConfig().PUBLIC_PATH}${courseId}/posts/${post.id}`, window.location.origin);
const actionHandlers = {
[ContentActions.EDIT_CONTENT]: () => history.push({
@@ -85,9 +54,9 @@ function Post({
dispatch(updateExistingThread(post.id, { closed: true }));
}
},
[ContentActions.COPY_LINK]: () => { navigator.clipboard.writeText(`${window.location.origin}/${courseId}/posts/${post.id}`); },
[ContentActions.COPY_LINK]: () => { navigator.clipboard.writeText(postURL.href); },
[ContentActions.PIN]: () => dispatch(updateExistingThread(post.id, { pinned: !post.pinned })),
[ContentActions.REPORT]: () => handleAbusedFlag(),
[ContentActions.REPORT]: () => dispatch(updateExistingThread(post.id, { flagged: !post.abuseFlagged })),
};
const getTopicCategoryName = topicData => (
@@ -95,76 +64,37 @@ function Post({
);
return (
<div
className="d-flex flex-column w-100 mw-100 post-card-comment"
aria-level={5}
data-testid={`post-${post.id}`}
onMouseEnter={() => setShowHoverCard(true)}
onMouseLeave={() => setShowHoverCard(false)}
onFocus={() => setShowHoverCard(true)}
onBlur={(e) => handleBlurEvent(e)}
>
<Confirmation
<div className="d-flex flex-column w-100 mw-100" data-testid={`post-${post.id}`}>
<DeleteConfirmation
isOpen={isDeleting}
title={intl.formatMessage(messages.deletePostTitle)}
description={intl.formatMessage(messages.deletePostDescription)}
onClose={hideDeleteConfirmation}
comfirmAction={handleDeleteConfirmation}
closeButtonVaraint="tertiary"
confirmButtonText={intl.formatMessage(messages.deleteConfirmationDelete)}
onDelete={() => {
dispatch(removeThread(post.id));
history.push('.');
hideDeleteConfirmation();
}}
/>
{!post.abuseFlagged && (
<Confirmation
isOpen={isReporting}
title={intl.formatMessage(messages.reportPostTitle)}
description={intl.formatMessage(messages.reportPostDescription)}
onClose={hideReportConfirmation}
comfirmAction={handleReportConfirmation}
confirmButtonVariant="danger"
/>
)}
{showHoverCard && (
<HoverCard
commentOrPost={post}
actionHandlers={actionHandlers}
handleResponseCommentButton={handleAddResponseButton}
addResponseCommentButtonMessage={intl.formatMessage(messages.addResponse)}
onLike={() => dispatch(updateExistingThread(post.id, { voted: !post.voted }))}
onFollow={() => dispatch(updateExistingThread(post.id, { following: !post.following }))}
isClosedPost={post.closed}
/>
)}
<AlertBanner content={post} />
<PostHeader post={post} />
<div className="d-flex mt-14px text-break font-style-normal font-family-inter text-primary-500">
<HTMLLoader htmlNode={post.renderedBody} componentId="post" cssClassName="html-loader" testId={post.id} />
<PostHeader post={post} actionHandlers={actionHandlers} />
<div className="d-flex mt-4 mb-2 text-break font-style-normal text-primary-500">
<HTMLLoader htmlNode={post.renderedBody} id="post" />
</div>
{topicContext && topic && (
<div
className={classNames('mt-14px mb-1 font-style-normal font-family-inter font-size-12',
{ 'w-100': enableInContextSidebar })}
style={{ lineHeight: '20px' }}
>
<span className="text-gray-500" style={{ lineHeight: '20px' }}>{intl.formatMessage(messages.relatedTo)}{' '}</span>
<div className="border px-3 rounded mb-4 border-light-400 align-self-start py-2.5">
<span className="text-gray-500">{intl.formatMessage(messages.relatedTo)}{' '}</span>
<Hyperlink
destination={topicContext.unitLink}
target="_top"
>
{enableInContextSidebar
? (
<>
<span className="w-auto">{topicContext.chapterName}</span>
<span className="mx-1">/</span>
<span className="w-auto">{topicContext.verticalName}</span>
<span className="mx-1">/</span>
<span className="w-auto">{topicContext.unitName}</span>
</>
)
: `${getTopicCategoryName(topic)} / ${topic.name}`}
{`${getTopicCategoryName(topic)} / ${topic.name}`}
</Hyperlink>
</div>
)}
<PostFooter post={post} preview={preview} />
<div className="mb-3">
<PostFooter post={post} preview={preview} />
</div>
<ClosePostReasonModal
isOpen={isClosing}
onCancel={hideClosePostModal}
@@ -181,7 +111,6 @@ Post.propTypes = {
intl: intlShape.isRequired,
post: postShape.isRequired,
preview: PropTypes.bool,
handleAddResponseButton: PropTypes.func.isRequired,
};
Post.defaultProps = {

View File

@@ -1,10 +1,12 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import * as timeago from 'timeago.js';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import {
Icon, IconButton, OverlayTrigger, Tooltip,
Badge, Icon, IconButtonWithTooltip, OverlayTrigger, Tooltip,
} from '@edx/paragon';
import {
Locked,
@@ -12,9 +14,12 @@ import {
import {
People,
QuestionAnswer,
QuestionAnswerOutline,
StarFilled,
StarOutline,
} from '../../../components/icons';
import timeLocale from '../../common/time-locale';
import { selectUserHasModerationPrivileges } from '../../data/selectors';
import { updateExistingThread } from '../data/thunks';
import LikeButton from './LikeButton';
@@ -24,39 +29,56 @@ import { postShape } from './proptypes';
function PostFooter({
post,
intl,
preview,
showNewCountLabel,
}) {
const dispatch = useDispatch();
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
timeago.register('time-locale', timeLocale);
return (
<div className="d-flex align-items-center ml-n1.5 mt-10px" style={{ lineHeight: '32px' }}>
{post.voteCount !== 0 && (
<LikeButton
count={post.voteCount}
onClick={() => dispatch(updateExistingThread(post.id, { voted: !post.voted }))}
voted={post.voted}
/>
)}
{post.following && (
<OverlayTrigger
overlay={(
<Tooltip id={`follow-${post.id}-tooltip`}>
{intl.formatMessage(post.following ? messages.unFollow : messages.follow)}
</Tooltip>
)}
>
<IconButton
src={post.following ? StarFilled : StarOutline}
onClick={(e) => {
e.preventDefault();
dispatch(updateExistingThread(post.id, { following: !post.following }));
return true;
}}
<div className="d-flex align-items-center">
<LikeButton
count={post.voteCount}
onClick={() => dispatch(updateExistingThread(post.id, { voted: !post.voted }))}
voted={post.voted}
preview={preview}
/>
<IconButtonWithTooltip
id={`follow-${post.id}-tooltip`}
tooltipPlacement="top"
tooltipContent={intl.formatMessage(post.following ? messages.unFollow : messages.follow)}
src={post.following ? StarFilled : StarOutline}
iconAs={Icon}
alt="Follow"
onClick={(e) => {
e.preventDefault();
dispatch(updateExistingThread(post.id, { following: !post.following }));
return true;
}}
size={preview ? 'inline' : 'sm'}
className={preview && 'p-3'}
iconClassNames={preview && 'icon-size'}
/>
{preview && post.commentCount > 1 && (
<div className="d-flex align-items-center ml-4">
<IconButtonWithTooltip
tooltipPlacement="top"
tooltipContent={intl.formatMessage(messages.viewActivity)}
src={post.unreadCommentCount ? QuestionAnswer : QuestionAnswerOutline}
iconAs={Icon}
iconClassNames="follow-icon-dimentions"
className="post-footer-icon-dimentions"
alt="Follow"
alt="Comment Count"
size="inline"
className="p-3 mr-0.5"
iconClassNames="icon-size"
/>
</OverlayTrigger>
{post.commentCount}
</div>
)}
{showNewCountLabel && preview && post?.unreadCommentCount > 0 && post.commentCount > 1 && (
<Badge variant="light" className="ml-2">
{intl.formatMessage(messages.newLabel, { count: post.unreadCommentCount })}
</Badge>
)}
<div className="d-flex flex-fill justify-content-end align-items-center">
{post.groupId && userHasModerationPrivileges && (
@@ -78,8 +100,10 @@ function PostFooter({
</span>
</>
)}
{post.closed
<span title={post.createdAt} className="text-gray-700">
{timeago.format(post.createdAt, 'time-locale')}
</span>
{!preview && post.closed
&& (
<OverlayTrigger
overlay={(
@@ -106,7 +130,13 @@ function PostFooter({
PostFooter.propTypes = {
intl: intlShape.isRequired,
post: postShape.isRequired,
preview: PropTypes.bool,
showNewCountLabel: PropTypes.bool,
};
PostFooter.defaultProps = {
preview: false,
showNewCountLabel: false,
};
export default injectIntl(PostFooter);

View File

@@ -64,6 +64,11 @@ describe('PostFooter', () => {
});
});
it("shows 'x new' badge for new comments in case of read post only", () => {
renderComponent(mockPost, true, true);
expect(screen.getByText('2 New')).toBeTruthy();
});
it("doesn't have 'new' badge when there are 0 new comments", () => {
renderComponent({ ...mockPost, unreadCommentCount: 0 });
expect(screen.queryByText('2 New')).toBeFalsy();
@@ -84,24 +89,18 @@ describe('PostFooter', () => {
expect(screen.getByTestId('cohort-icon')).toBeTruthy();
});
it('test follow button when following=true', async () => {
renderComponent({ ...mockPost, following: true });
it.each([[true, /unfollow/i], [false, /follow/i]])('test follow button when following=%s', async (following, message) => {
renderComponent({ ...mockPost, following });
const followButton = screen.getByRole('button', { name: /follow/i });
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
await act(async () => {
fireEvent.mouseEnter(followButton);
});
expect(screen.getByRole('tooltip')).toHaveTextContent(/unfollow/i);
expect(screen.getByRole('tooltip')).toHaveTextContent(message);
await act(async () => {
fireEvent.click(followButton);
});
// clicking on the button triggers thread update.
expect(store.getState().threads.status === RequestStatus.IN_PROGRESS).toBeTruthy();
});
it('test follow button when following=false', async () => {
renderComponent({ ...mockPost, following: false });
expect(screen.queryByRole('button', { name: /follow/i })).not.toBeInTheDocument();
});
});

View File

@@ -9,7 +9,7 @@ import { Avatar, Badge, Icon } from '@edx/paragon';
import { Issue, Question } from '../../../components/icons';
import { AvatarOutlineAndLabelColors, ThreadType } from '../../../data/constants';
import { AuthorLabel } from '../../common';
import { ActionsDropdown, AuthorLabel } from '../../common';
import { useAlertBannerVisible } from '../../data/hooks';
import { selectAuthorAvatars } from '../data/selectors';
import messages from './messages';
@@ -22,11 +22,11 @@ export function PostAvatar({
const outlineColor = AvatarOutlineAndLabelColors[authorLabel];
const avatarSize = useMemo(() => {
let size = '2rem';
let size = '1.75rem';
if (post.type === ThreadType.DISCUSSION && !fromPostLink) {
size = '2rem';
size = '2.375rem';
} else if (post.type === ThreadType.QUESTION) {
size = '1.5rem';
size = '1.375rem';
}
return size;
}, [post.type]);
@@ -52,11 +52,11 @@ export function PostAvatar({
/>
)}
<Avatar
className={classNames('border-0 mt-1', {
className={classNames('border-0', {
[`outline-${outlineColor}`]: outlineColor,
'outline-anonymous': !outlineColor,
'mt-3 ml-2': post.type === ThreadType.QUESTION && fromPostLink,
'avarat-img-position mt-17px': post.type === ThreadType.QUESTION,
'avarat-img-position': post.type === ThreadType.QUESTION,
})}
style={{
height: avatarSize,
@@ -86,13 +86,14 @@ function PostHeader({
intl,
post,
preview,
actionHandlers,
}) {
const showAnsweredBadge = preview && post.hasEndorsed && post.type === ThreadType.QUESTION;
const authorLabelColor = AvatarOutlineAndLabelColors[post.authorLabel];
const hasAnyAlert = useAlertBannerVisible(post);
return (
<div className={classNames('d-flex flex-fill mw-100', { 'mt-10px': hasAnyAlert && !preview })}>
<div className={classNames('d-flex flex-fill mw-100', { 'mt-2': hasAnyAlert && !preview })}>
<div className="flex-shrink-0">
<PostAvatar post={post} authorLabel={post.authorLabel} />
</div>
@@ -108,17 +109,21 @@ function PostHeader({
&& <Badge variant="success">{intl.formatMessage(messages.answered)}</Badge>}
</div>
)
: <h5 className="mb-0 font-style-normal font-family-inter text-primary-500" style={{ lineHeight: '21px' }} aria-level="1" tabIndex="-1" accessKey="h">{post.title}</h5>}
: <h4 className="mb-0" style={{ lineHeight: '28px' }} aria-level="1" tabIndex="-1" accessKey="h">{post.title}</h4>}
<AuthorLabel
author={post.author || intl.formatMessage(messages.anonymous)}
authorLabel={post.authorLabel}
labelColor={authorLabelColor && `text-${authorLabelColor}`}
linkToProfile
postCreatedAt={post.createdAt}
postOrComment
/>
</div>
</div>
{!preview
&& (
<div className="ml-auto d-flex">
<ActionsDropdown commentOrPost={post} actionHandlers={actionHandlers} />
</div>
)}
</div>
);
}
@@ -127,6 +132,7 @@ PostHeader.propTypes = {
intl: intlShape.isRequired,
post: postShape.isRequired,
preview: PropTypes.bool,
actionHandlers: PropTypes.objectOf(PropTypes.func).isRequired,
};
PostHeader.defaultProps = {

View File

@@ -15,8 +15,8 @@ import AuthorLabel from '../../common/AuthorLabel';
import { DiscussionContext } from '../../common/context';
import { discussionsPath, isPostPreviewAvailable } from '../../utils';
import messages from './messages';
import PostFooter from './PostFooter';
import { PostAvatar } from './PostHeader';
import PostSummaryFooter from './PostSummaryFooter';
import { postShape } from './proptypes';
function PostLink({
@@ -29,12 +29,12 @@ function PostLink({
const {
page,
postId,
enableInContextSidebar,
inContext,
category,
learnerUsername,
} = useContext(DiscussionContext);
const linkUrl = discussionsPath(Routes.COMMENTS.PAGES[page], {
0: enableInContextSidebar ? 'in-context' : undefined,
0: inContext ? 'in-context' : undefined,
courseId: post.courseId,
topicId: post.topicId,
postId: post.id,
@@ -56,20 +56,24 @@ function PostLink({
}
to={linkUrl}
onClick={() => isSelected(post.id)}
style={{ lineHeight: '22px' }}
aria-current={isSelected(post.id) ? 'page' : undefined}
role="option"
tabIndex={(isSelected(post.id) || idx === 0) ? 0 : -1}
>
<div
className={
classNames('d-flex flex-row pt-2 pb-2 px-4 border-primary-500 position-relative',
{ 'bg-light-300': read },
{ 'post-summary-card-selected': post.id === postId })
classNames('d-flex flex-row pt-2.5 pb-2 px-4 border-primary-500 position-relative',
{ 'bg-light-300': read })
}
style={post.id === postId ? {
borderRightWidth: '4px',
borderRightStyle: 'solid',
} : null}
>
<PostAvatar post={post} authorLabel={post.authorLabel} fromPostLink read={read} />
<div className="d-flex flex-column flex-fill" style={{ minWidth: 0 }}>
<div className="d-flex flex-column justify-content-start mw-100 flex-fill" style={{ marginBottom: '-3px' }}>
<div className="d-flex flex-column justify-content-start mw-100 flex-fill">
<div className="d-flex align-items-center pb-0 mb-0 flex-fill font-weight-500">
<Truncate lines={1} className="mr-1.5" whiteSpace>
<span
@@ -96,20 +100,20 @@ function PostLink({
)}
{canSeeReportedBadge && (
<Badge
variant="danger"
data-testid="reported-post"
className={`font-weight-500 badge-padding ${showAnsweredBadge ? 'ml-2' : 'ml-auto'}`}
>
{intl.formatMessage(messages.contentReported)}
<span className="sr-only">{' '}reported</span>
</Badge>
<Badge
variant="danger"
data-testid="reported-post"
className={`font-weight-500 badge-padding ${showAnsweredBadge ? 'ml-2' : 'ml-auto'}`}
>
{intl.formatMessage(messages.contentReported)}
<span className="sr-only">{' '}reported</span>
</Badge>
)}
{post.pinned && (
<Icon
src={PushPin}
className={`post-summary-icons-dimensions text-gray-700 ${canSeeReportedBadge || showAnsweredBadge ? 'ml-2' : 'ml-auto'}`}
className={`icon-size ${canSeeReportedBadge || showAnsweredBadge ? 'ml-2' : 'ml-auto'}`}
/>
)}
</div>
@@ -119,7 +123,7 @@ function PostLink({
authorLabel={post.authorLabel}
labelColor={authorLabelColor && `text-${authorLabelColor}`}
/>
<PostSummaryFooter post={post} preview showNewCountLabel={read} />
<PostFooter post={post} preview intl={intl} showNewCountLabel={read} />
</div>
</div>
{!showDivider && post.pinned && <div className="pt-1 bg-light-500 border-top border-light-700" />}

View File

@@ -1,147 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import * as timeago from 'timeago.js';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import {
Badge, Icon, OverlayTrigger, Tooltip,
} from '@edx/paragon';
import {
Locked,
} from '@edx/paragon/icons';
import {
People,
QuestionAnswer,
QuestionAnswerOutline,
StarFilled,
StarOutline, ThumbUpFilled, ThumbUpOutline,
} from '../../../components/icons';
import timeLocale from '../../common/time-locale';
import { selectUserHasModerationPrivileges } from '../../data/selectors';
import messages from './messages';
import { postShape } from './proptypes';
function PostSummaryFooter({
post,
intl,
preview,
showNewCountLabel,
}) {
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
timeago.register('time-locale', timeLocale);
return (
<div className="d-flex align-items-center text-gray-700">
<div className="d-flex align-items-center mr-4.5">
<OverlayTrigger
overlay={(
<Tooltip id={`liked-${post.id}-tooltip`}>
{intl.formatMessage(post.voted ? messages.likedPost : messages.postLikes)}
</Tooltip>
)}
>
<Icon src={post.voted ? ThumbUpFilled : ThumbUpOutline} className="post-summary-icons-dimensions mr-0.5">
<span className="sr-only">{' '}{intl.formatMessage(post.voted ? messages.likedPost : messages.postLikes)}</span>
</Icon>
</OverlayTrigger>
<div className="font-family-inter font-style-normal">
{(post.voteCount && post.voteCount > 0) ? post.voteCount : null}
</div>
</div>
<OverlayTrigger
overlay={(
<Tooltip id={`follow-${post.id}-tooltip`}>
{intl.formatMessage(post.following ? messages.followed : messages.notFollowed)}
</Tooltip>
)}
>
<Icon src={post.following ? StarFilled : StarOutline} className="post-summary-icons-dimensions mr-0.5">
<span className="sr-only">{' '}{ intl.formatMessage(post.following ? messages.srOnlyFollowDescription : messages.srOnlyUnFollowDescription)}</span>
</Icon>
</OverlayTrigger>
{preview && post.commentCount > 1 && (
<div className="d-flex align-items-center ml-4.5">
<OverlayTrigger
overlay={(
<Tooltip id={`follow-${post.id}-tooltip`}>
{intl.formatMessage(messages.activity)}
</Tooltip>
)}
>
<Icon src={post.unreadCommentCount ? QuestionAnswer : QuestionAnswerOutline} className="post-summary-icons-dimensions mr-0.5">
<span className="sr-only">{' '} {intl.formatMessage(messages.activity)}</span>
</Icon>
</OverlayTrigger>
{post.commentCount}
</div>
)}
{showNewCountLabel && preview && post?.unreadCommentCount > 0 && post.commentCount > 1 && (
<Badge variant="light" className="ml-2">
{intl.formatMessage(messages.newLabel, { count: post.unreadCommentCount })}
</Badge>
)}
<div className="d-flex flex-fill justify-content-end align-items-center">
{post.groupId && userHasModerationPrivileges && (
<>
<OverlayTrigger
overlay={(
<Tooltip id={`visibility-${post.id}-tooltip`}>{post.groupName}</Tooltip>
)}
>
<span data-testid="cohort-icon" className="post-summary-icons-dimensions">
<People />
</span>
</OverlayTrigger>
<span
className="text-gray-700 mx-1.5 font-weight-500"
style={{ fontSize: '16px' }}
>
·
</span>
</>
)}
<span title={post.createdAt} className="text-gray-700 post-summary-timestamp">
{timeago.format(post.createdAt, 'time-locale')}
</span>
{!preview && post.closed
&& (
<OverlayTrigger
overlay={(
<Tooltip id={`closed-${post.id}-tooltip`}>
{intl.formatMessage(messages.postClosed)}
</Tooltip>
)}
>
<Icon
src={Locked}
style={{
width: '1rem',
height: '1rem',
}}
className="ml-3 post-summary-icons-dimensions"
/>
</OverlayTrigger>
)}
</div>
</div>
);
}
PostSummaryFooter.propTypes = {
intl: intlShape.isRequired,
post: postShape.isRequired,
preview: PropTypes.bool,
showNewCountLabel: PropTypes.bool,
};
PostSummaryFooter.defaultProps = {
preview: false,
showNewCountLabel: false,
};
export default injectIntl(PostSummaryFooter);

View File

@@ -6,11 +6,6 @@ const messages = defineMessages({
defaultMessage: 'anonymous',
description: 'Author name displayed when a post is anonymous',
},
addResponse: {
id: 'discussions.post.addResponse',
defaultMessage: 'Add response',
description: 'Button to add a response in a thread of forum posts',
},
lastResponse: {
id: 'discussions.post.lastResponse',
defaultMessage: 'Last response {time}',
@@ -33,16 +28,6 @@ const messages = defineMessages({
defaultMessage: 'Follow',
description: 'Tooltip/alttext for button to follow a discussion post',
},
followed: {
id: 'discussions.post.followed',
defaultMessage: 'Followed',
description: 'Tooltip/alttext for follow icon showing user followed a post',
},
notFollowed: {
id: 'discussions.post.notFollowed',
defaultMessage: 'Not Followed',
description: 'Tooltip/alttext for follow icon showing user not following a post',
},
answered: {
id: 'discussions.post.answered',
defaultMessage: 'Answered',
@@ -63,26 +48,11 @@ const messages = defineMessages({
defaultMessage: 'Unlike',
description: 'Tooltip/alttext for button to remove the like applied to a discussion post',
},
likedPost: {
id: 'discussions.post.liked',
defaultMessage: 'liked',
description: 'Tooltip/alttext for like icon to tell user this post is liked by user',
},
postLikes: {
id: 'discussions.post.likes',
defaultMessage: 'likes',
description: 'Tooltip/alttext for like icon to tell user about like on the post',
},
viewActivity: {
id: 'discussions.post.viewActivity',
defaultMessage: 'View activity',
description: 'Tooltip/alttext for button to view the activity of a discussion post',
},
activity: {
id: 'discussions.post.activity',
defaultMessage: 'Activity',
description: 'Tooltip/alttext for icon for showing icon represents activity on a post',
},
postClosed: {
id: 'discussions.post.closed',
defaultMessage: 'Post closed for responses and comments',
@@ -101,21 +71,6 @@ const messages = defineMessages({
id: 'discussions.editor.delete.post.description',
defaultMessage: 'Are you sure you want to permanently delete this post?',
},
deleteConfirmationDelete: {
id: 'discussions.post.delete.confirmation.button.delete',
defaultMessage: 'Delete',
description: 'Delete button shown on delete confirmation dialog',
},
reportPostTitle: {
id: 'discussions.editor.report.post.title',
defaultMessage: 'Report inappropriate content?',
description: 'Title of confirmation dialog shown when reporting a post',
},
reportPostDescription: {
id: 'discussions.editor.report.post.description',
defaultMessage: 'The discussion moderation team will review this content and take appropriate action.',
description: 'Text displayed in confirmation dialog when deleting a post',
},
closePostModalTitle: {
id: 'discussions.post.closePostModal.title',
defaultMessage: 'Close post',
@@ -161,16 +116,6 @@ const messages = defineMessages({
defaultMessage: 'No preview available',
description: 'No preview available',
},
srOnlyFollowDescription: {
id: 'discussions.post.follow.description',
defaultMessage: 'you are following this post',
description: 'tell screen readers if user is following a post',
},
srOnlyUnFollowDescription: {
id: 'discussions.post.unfollow.description',
defaultMessage: 'you are not following this post',
description: 'tell screen readers if user is not following a post',
},
});
export default messages;

View File

@@ -4,15 +4,17 @@ import { useDispatch, useSelector } from 'react-redux';
import { useParams } from 'react-router';
import SearchInfo from '../../components/SearchInfo';
import { RequestStatus } from '../../data/constants';
import { DiscussionProvider, RequestStatus } from '../../data/constants';
import { selectSequences } from '../../data/selectors';
import { DiscussionContext } from '../common/context';
import { selectDiscussionProvider } from '../data/selectors';
import NoResults from '../posts/NoResults';
import { handleKeyDown } from '../utils';
import { selectCategories, selectNonCoursewareTopics, selectTopicFilter } from './data/selectors';
import { setFilter, setTopicsCount } from './data/slices';
import { fetchCourseTopics } from './data/thunks';
import ArchivedTopicGroup from './topic-group/ArchivedTopicGroup';
import LegacyTopicGroup from './topic-group/LegacyTopicGroup';
import SequenceTopicGroup from './topic-group/SequenceTopicGroup';
import Topic from './topic-group/topic/Topic';
import countFilteredTopics from './utils';
@@ -36,6 +38,24 @@ function CourseWideTopics() {
));
}
function CoursewareTopics() {
const sequences = useSelector(selectSequences);
return (
<>
{ sequences?.map(
sequence => (
<SequenceTopicGroup
sequence={sequence}
key={sequence.id}
/>
),
)}
<ArchivedTopicGroup />
</>
);
}
function LegacyCoursewareTopics() {
const { category } = useParams();
const categories = useSelector(selectCategories)
@@ -60,6 +80,20 @@ function TopicsView() {
const { courseId } = useContext(DiscussionContext);
const dispatch = useDispatch();
const handleKeyDown = (event) => {
const { key } = event;
if (key !== 'ArrowDown' && key !== 'ArrowUp') { return; }
const option = event.target;
let selectedOption;
if (key === 'ArrowDown') { selectedOption = option.nextElementSibling; }
if (key === 'ArrowUp') { selectedOption = option.previousElementSibling; }
if (selectedOption) {
selectedOption.focus();
}
};
useEffect(() => {
// Don't load till the provider information is available
if (provider) {
@@ -84,7 +118,8 @@ function TopicsView() {
)}
<div className="list-group list-group-flush flex-fill" role="list" onKeyDown={e => handleKeyDown(e)}>
<CourseWideTopics />
<LegacyCoursewareTopics />
{provider === DiscussionProvider.OPEN_EDX && <CoursewareTopics />}
{provider === DiscussionProvider.LEGACY && <LegacyCoursewareTopics />}
</div>
{
filteredTopicsCount === 0

View File

@@ -1,5 +1,5 @@
import {
fireEvent, render, screen,
fireEvent, render, screen, within,
} from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import { IntlProvider } from 'react-intl';
@@ -10,7 +10,11 @@ import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
import { getApiBaseUrl } from '../../data/constants';
import { getBlocksAPIResponse } from '../../data/__factories__';
import { getBlocksAPIURL } from '../../data/api';
import { DiscussionProvider, getApiBaseUrl } from '../../data/constants';
import { selectSequences } from '../../data/selectors';
import { fetchCourseBlocks } from '../../data/thunks';
import { initializeStore } from '../../store';
import { executeThunk } from '../../test-utils';
import { DiscussionContext } from '../common/context';
@@ -23,6 +27,7 @@ import './data/__factories__';
const courseId = 'course-v1:edX+DemoX+Demo_Course';
const topicsApiUrl = `${getApiBaseUrl()}/api/discussion/v1/course_topics/${courseId}`;
const topicsv2ApiUrl = `${getApiBaseUrl()}/api/discussion/v2/course_topics/${courseId}`;
let store;
let axiosMock;
let lastLocation;
@@ -52,86 +57,131 @@ function renderComponent() {
);
}
describe('Legacy Topics View', () => {
let inContextTopics;
let globalTopics;
let categories;
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore({
config: { provider: 'legacy' },
blocks: {
topics: {},
},
});
Factory.resetAll();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
lastLocation = undefined;
});
async function setupMockResponse() {
axiosMock
.onGet(topicsApiUrl)
.reply(200, {
courseware_topics: Factory.buildList('category', 2),
non_courseware_topics: Factory.buildList('topic', 3, {}, { topicPrefix: 'ncw' }),
describe('TopicsView', () => {
describe.each(['legacy', 'openedx'])('%s provider', (provider) => {
let inContextTopics;
let globalTopics;
let categories;
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
await executeThunk(fetchCourseTopics(courseId), store.dispatch, store.getState);
const state = store.getState();
categories = state.topics.categoryIds;
globalTopics = selectNonCoursewareTopics(state);
inContextTopics = selectCoursewareTopics(state);
}
it('displays non-courseware topics', async () => {
await setupMockResponse();
renderComponent();
globalTopics.forEach(topic => {
expect(screen.queryByText(topic.name)).toBeInTheDocument();
});
});
it('displays non-courseware outside of a topic group', async () => {
await setupMockResponse();
renderComponent();
categories.forEach(category => {
// For the new provider categories are blocks so use the display name
// otherwise use the category itself which is a string
expect(screen.queryByText(category.displayName || category)).toBeInTheDocument();
store = initializeStore({
config: { provider },
blocks: {
topics: {},
},
});
Factory.resetAll();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
lastLocation = undefined;
});
const topicGroups = screen.queryAllByTestId('topic-group');
// For the new provider there should be a section for archived topics
expect(topicGroups).toHaveLength(categories.length);
});
async function setupMockResponse() {
if (provider === 'legacy') {
axiosMock
.onGet(topicsApiUrl)
.reply(200, {
courseware_topics: Factory.buildList('category', 2),
non_courseware_topics: Factory.buildList('topic', 3, {}, { topicPrefix: 'ncw' }),
});
await executeThunk(fetchCourseTopics(courseId), store.dispatch, store.getState);
const state = store.getState();
categories = state.topics.categoryIds;
globalTopics = selectNonCoursewareTopics(state);
inContextTopics = selectCoursewareTopics(state);
} else {
const blocksAPIResponse = getBlocksAPIResponse(true);
const ids = Object.values(blocksAPIResponse.blocks).filter(block => block.type === 'vertical')
.map(block => block.block_id);
const deletedIds = [
'block-v1:edX+DemoX+Demo_Course+type@vertical+block@deleted-vertical-1',
'block-v1:edX+DemoX+Demo_Course+type@vertical+block@deleted-vertical-2',
];
const data = [
...Factory.buildList('topic.v2', 2, { usage_key: null }, { topicPrefix: 'ncw' }),
...ids.map(id => Factory.build('topic.v2', { id })),
...deletedIds.map(id => Factory.build('topic.v2', { id, enabled_in_context: false }, { topicPrefix: 'archived ' })),
];
it('displays courseware topics', async () => {
await setupMockResponse();
renderComponent();
axiosMock
.onGet(topicsv2ApiUrl)
.reply(200, data);
axiosMock.onGet(getBlocksAPIURL())
.reply(200, getBlocksAPIResponse(true));
axiosMock.onAny().networkError();
await executeThunk(fetchCourseBlocks(courseId, 'abc123'), store.dispatch, store.getState);
await executeThunk(fetchCourseTopics(courseId), store.dispatch, store.getState);
const state = store.getState();
categories = selectSequences(state);
globalTopics = selectNonCoursewareTopics(state);
inContextTopics = selectCoursewareTopics(state);
}
}
inContextTopics.forEach(topic => {
expect(screen.queryByText(topic.name)).toBeInTheDocument();
it('displays non-courseware topics', async () => {
await setupMockResponse();
renderComponent();
globalTopics.forEach(topic => {
expect(screen.queryByText(topic.name)).toBeInTheDocument();
});
});
});
it('clicking on courseware topic (category) takes to category page', async () => {
await setupMockResponse();
renderComponent();
it('displays non-courseware outside of a topic group', async () => {
await setupMockResponse();
renderComponent();
const categoryName = categories[0].displayName || categories[0];
const categoryPath = categoryName;
const topic = await screen.findByText(categoryName);
fireEvent.click(topic);
expect(lastLocation.pathname.endsWith(`/category/${categoryPath}`)).toBeTruthy();
categories.forEach(category => {
// For the new provider categories are blocks so use the display name
// otherwise use the category itself which is a string
expect(screen.queryByText(category.displayName || category)).toBeInTheDocument();
});
const topicGroups = screen.queryAllByTestId('topic-group');
// For the new provider there should be a section for archived topics
expect(topicGroups).toHaveLength(
provider === DiscussionProvider.LEGACY
? categories.length
: categories.length + 1,
);
});
if (provider === DiscussionProvider.OPEN_EDX) {
it('displays archived topics', async () => {
await setupMockResponse();
renderComponent();
const archivedTopicGroup = screen.queryAllByTestId('topic-group').pop();
expect(archivedTopicGroup).toHaveTextContent(/archived/i);
const archivedTopicLinks = within(archivedTopicGroup).queryAllByRole('option');
expect(archivedTopicLinks).toHaveLength(2);
});
}
it('displays courseware topics', async () => {
await setupMockResponse();
renderComponent();
inContextTopics.forEach(topic => {
expect(screen.queryByText(topic.name)).toBeInTheDocument();
});
});
it('clicking on courseware topic (category) takes to category page', async () => {
await setupMockResponse();
renderComponent();
const categoryName = categories[0].displayName || categories[0];
const categoryPath = provider === 'legacy' ? categoryName : categories[0].id;
const topic = await screen.findByText(categoryName);
fireEvent.click(topic);
expect(lastLocation.pathname.endsWith(`/category/${categoryPath}`)).toBeTruthy();
});
});
});

View File

@@ -13,3 +13,14 @@ export async function getCourseTopics(courseId, topicIds) {
.get(url);
return data;
}
export async function getCourseTopicsV2(courseId, topicIds) {
const url = `${getApiBaseUrl()}/api/discussion/v2/course_topics/${courseId}`;
const params = {};
if (topicIds) {
params.topic_id = topicIds.join(',');
}
const { data } = await getAuthenticatedHttpClient()
.get(url);
return data;
}

View File

@@ -2,6 +2,10 @@
import { createSelector } from '@reduxjs/toolkit';
import { DiscussionProvider } from '../../../data/constants';
import { selectSequences } from '../../../data/selectors';
import { selectDiscussionProvider } from '../../data/selectors';
export const selectTopicFilter = state => state.topics.filter.trim()
.toLowerCase();
@@ -15,22 +19,29 @@ export const selectTopicsInCategory = (categoryId) => state => (
export const selectTopics = state => state.topics.topics;
export const selectCoursewareTopics = createSelector(
selectDiscussionProvider,
selectCategories,
selectTopicCategoryMap,
selectTopics,
(categoryIds, topicsInCategory, topics) => (
categoryIds.map(category => ({
id: category,
name: category,
topics: topicsInCategory[category].map(id => topics[id]),
}))
selectSequences,
(provider, categoryIds, topicsInCategory, topics, sequences) => (
provider === DiscussionProvider.LEGACY
? categoryIds.map(category => ({
id: category,
name: category,
topics: topicsInCategory[category].map(id => topics[id]),
}))
: sequences.map(sequence => ({
id: sequence.id,
name: sequence.displayName,
topics: sequence.topics.map(topicId => ({ id: topicId, name: topics[topicId]?.name || 'unnamed' })),
}))
),
);
export const selectNonCoursewareIds = state => state.topics.nonCoursewareIds;
export const selectNonCoursewareTopics = state => state.topics.nonCoursewareIds?.map(id => state.topics.topics[id])
|| [];
export const selectNonCoursewareTopics = state => state.topics.nonCoursewareIds.map(id => state.topics.topics[id]);
export const selectTopic = topicId => state => state.topics.topics[topicId];

View File

@@ -11,6 +11,8 @@ const topicsSlice = createSlice({
categoryIds: [],
// List of all non-courseware topics
nonCoursewareIds: [],
// Topics that have been archived
archivedIds: [],
// Mapping of all topics in each category
topicsInCategory: {},
// Map of topics ids to topic data
@@ -30,6 +32,7 @@ const topicsSlice = createSlice({
state.topics = payload.topics;
state.nonCoursewareIds = payload.nonCoursewareIds;
state.categoryIds = payload.categoryIds;
state.archivedIds = payload.archivedIds;
state.topicsInCategory = payload.topicsInCategory;
},
fetchCourseTopicsFailed: (state) => {

View File

@@ -2,7 +2,8 @@
import { camelCaseObject } from '@edx/frontend-platform';
import { logError } from '@edx/frontend-platform/logging';
import { getCourseTopics } from './api';
import { DiscussionProvider } from '../../../data/constants';
import { getCourseTopics, getCourseTopicsV2 } from './api';
import { fetchCourseTopicsFailed, fetchCourseTopicsRequest, fetchCourseTopicsSuccess } from './slices';
function normaliseTopics(data) {
@@ -25,12 +26,34 @@ function normaliseTopics(data) {
};
}
export function fetchCourseTopics(courseId) {
return async (dispatch) => {
try {
dispatch(fetchCourseTopicsRequest({ courseId }));
function normaliseTopicsV2(data) {
const nonCoursewareIds = [];
const topics = {};
const archivedIds = [];
data.forEach(topic => {
if (!topic.enabledInContext) {
archivedIds.push(topic.id);
} else if (topic.usageKey === null) {
nonCoursewareIds.push(topic.id);
}
topics[topic.id] = topic;
});
return {
topics, nonCoursewareIds, archivedIds,
};
}
const data = normaliseTopics(camelCaseObject(await getCourseTopics(courseId)));
export function fetchCourseTopics(courseId) {
return async (dispatch, getState) => {
try {
const { config } = getState();
dispatch(fetchCourseTopicsRequest({ courseId }));
let data = {};
if (config.provider === DiscussionProvider.LEGACY) {
data = normaliseTopics(camelCaseObject(await getCourseTopics(courseId)));
} else if (config.provider === DiscussionProvider.OPEN_EDX) {
data = normaliseTopicsV2(camelCaseObject(await getCourseTopicsV2(courseId)));
}
dispatch(fetchCourseTopicsSuccess(data));
} catch (error) {
dispatch(fetchCourseTopicsFailed());
@@ -38,3 +61,16 @@ export function fetchCourseTopics(courseId) {
}
};
}
export function fetchCourseTopicsV2(courseId) {
return async (dispatch) => {
try {
dispatch(fetchCourseTopicsRequest({ courseId }));
const data = await getCourseTopicsV2(courseId);
dispatch(fetchCourseTopicsSuccess(normaliseTopicsV2(camelCaseObject(data))));
} catch (error) {
dispatch(fetchCourseTopicsFailed());
logError(error);
}
};
}

View File

@@ -10,7 +10,7 @@ export default function countFilteredTopics(topicsSelector, provider) {
? item.name.toLowerCase().includes(query)
: true
));
count += nonCoursewareTopicsList?.length;
count += nonCoursewareTopicsList.length;
// Counting legacy topics
if (provider === DiscussionProvider.LEGACY) {
const categories = topicsSelector?.categoryIds;

View File

@@ -1,34 +0,0 @@
import { useEffect } from 'react';
import isEmpty from 'lodash/isEmpty';
import { useDispatch } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { ProductTour } from '@edx/paragon';
import { useTourConfiguration } from '../data/hooks';
import { fetchDiscussionTours } from './data/thunks';
function DiscussionsProductTour({ intl }) {
const dispatch = useDispatch();
const config = useTourConfiguration(intl);
useEffect(() => {
dispatch(fetchDiscussionTours());
}, []);
return (
<>
{!isEmpty(config) && (
<ProductTour
tours={config}
/>
)}
</>
);
}
DiscussionsProductTour.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(DiscussionsProductTour);

View File

@@ -1,14 +0,0 @@
import messages from './messages';
export default function tourCheckpoints(intl) {
return {
NOT_RESPONDED_FILTER: [
{
body: intl.formatMessage(messages.notRespondedFilterTourBody),
placement: 'right',
target: '#icon-tune',
title: intl.formatMessage(messages.notRespondedFilterTourTitle),
},
],
};
}

View File

@@ -1,30 +0,0 @@
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
// create constant for the API URL
export const getDiscussionTourUrl = () => `${getConfig().LMS_BASE_URL}/api/user_tours/discussion_tours/`;
/**
* getDiscussionTours
* This function makes an HTTP GET request to the API to retrieve a list of tours for the authenticated user.
* @returns {Promise} - A promise that resolves to the API response data.
*/
export async function getDiscssionTours() {
const { data } = await getAuthenticatedHttpClient()
.get(getDiscussionTourUrl());
return data;
}
/**
* updateDiscussionTour
* This function makes an HTTP PUT request to the API to update a specific tour for the authenticated user.
* @param {number} tourId - The ID of the tour to be updated.
* @returns {Promise} - A promise that resolves to the API response data.
*/
export async function updateDiscussionTour(tourId) {
const { data } = await getAuthenticatedHttpClient()
.put(`${getDiscussionTourUrl()}${tourId}`, {
show_tour: false,
});
return data;
}

View File

@@ -1 +0,0 @@
export * from './slices';

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