Compare commits
95 Commits
open-relea
...
inf-763
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8cefa7269 | ||
|
|
1c2da56e3b | ||
|
|
e99c30f213 | ||
|
|
eacc16b7f1 | ||
|
|
f8800de766 | ||
|
|
f7740de54a | ||
|
|
4ca25c14d9 | ||
|
|
8389d09f22 | ||
|
|
3233d7044d | ||
|
|
22474f4b1e | ||
|
|
b5d4213074 | ||
|
|
71931c83de | ||
|
|
f9ca375853 | ||
|
|
3d8353dc87 | ||
|
|
21bec39c0f | ||
|
|
a479ba5955 | ||
|
|
bafb55afa9 | ||
|
|
62ebd4450f | ||
|
|
04e0bb3264 | ||
|
|
2a187ca1df | ||
|
|
7de274a73e | ||
|
|
ad640611a1 | ||
|
|
42fa6a62c6 | ||
|
|
4a49c13f14 | ||
|
|
19e775737c | ||
|
|
b7e5dd0e28 | ||
|
|
f33e3c3e3d | ||
|
|
4e659f8293 | ||
|
|
02cf34a467 | ||
|
|
722da9616f | ||
|
|
6948a9fa5e | ||
|
|
e1d8af4498 | ||
|
|
2a50edc334 | ||
|
|
a218c29831 | ||
|
|
6e03d5bfe8 | ||
|
|
fd4dbef2e0 | ||
|
|
f614c42cc5 | ||
|
|
928108e96c | ||
|
|
aade749d54 | ||
|
|
1494741fae | ||
|
|
01547730a8 | ||
|
|
58e724d724 | ||
|
|
9c576ff3dc | ||
|
|
3f890401e8 | ||
|
|
3622d46538 | ||
|
|
4eaac1eb03 | ||
|
|
2a5e643562 | ||
|
|
b47fc9b3e9 | ||
|
|
bb6e47ce70 | ||
|
|
f378b21e32 | ||
|
|
c6a81e6d15 | ||
|
|
283e16a477 | ||
|
|
0c71e8b5b7 | ||
|
|
49d6fbed3c | ||
|
|
b1c1f1c024 | ||
|
|
af029b43a2 | ||
|
|
b7aff94513 | ||
|
|
c26c7d34e6 | ||
|
|
2eee6c3ca2 | ||
|
|
e7a41b2391 | ||
|
|
ad42959e56 | ||
|
|
67b0b33a81 | ||
|
|
da108a2054 | ||
|
|
1005752bf1 | ||
|
|
31a66f6832 | ||
|
|
37053e9bd3 | ||
|
|
9f84230c17 | ||
|
|
d60f1afa6b | ||
|
|
df6a0d4293 | ||
|
|
e8bd91b418 | ||
|
|
bdaa13a7ad | ||
|
|
5ab324c9ca | ||
|
|
00ab8283e2 | ||
|
|
1719315681 | ||
|
|
8e19eed468 | ||
|
|
11460a3d26 | ||
|
|
a3d0273de6 | ||
|
|
f1d2de6694 | ||
|
|
1169de04f6 | ||
|
|
aa7a5a8cc1 | ||
|
|
9d9377bb8c | ||
|
|
f45f47f2e0 | ||
|
|
bea247f6e5 | ||
|
|
9e878fc916 | ||
|
|
21176131a7 | ||
|
|
b23846b1e4 | ||
|
|
7912d70388 | ||
|
|
42f1efd0a0 | ||
|
|
8e449acde7 | ||
|
|
5f477cb93f | ||
|
|
cf8f08172f | ||
|
|
b72dbae4f0 | ||
|
|
f805f73447 | ||
|
|
27c4d7e3d6 | ||
|
|
b976e812dc |
24
.github/pull_request_template.md
vendored
Normal file
24
.github/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
### Description
|
||||
|
||||
Include a description of your changes here, along with a link to any relevant Jira tickets and/or GitHub issues.
|
||||
|
||||
#### How Has This Been Tested?
|
||||
|
||||
Please describe in detail how you tested your changes.
|
||||
|
||||
#### Screenshots/sandbox (optional):
|
||||
Include a link to the sandbox for design changes or screenshot for before and after. **Remove this section if it's not applicable.**
|
||||
|
||||
|Before|After|
|
||||
|-------|-----|
|
||||
| | |
|
||||
|
||||
#### Merge Checklist
|
||||
|
||||
* [ ] If your update includes visual changes, have they been reviewed by a designer? Send them a link to the Sandbox, if applicable.
|
||||
* [ ] Is there adequate test coverage for your changes?
|
||||
|
||||
#### Post-merge Checklist
|
||||
|
||||
* [ ] Deploy the changes to prod after verifying on stage or ask **@openedx/edx-infinity** to do it.
|
||||
* [ ] 🎉 🙌 Celebrate! Thanks for your contribution.
|
||||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -34,4 +34,4 @@ jobs:
|
||||
- name: i18n_extract
|
||||
run: npm run i18n_extract
|
||||
- name: Coverage
|
||||
uses: codecov/codecov-action@v2
|
||||
uses: codecov/codecov-action@v3
|
||||
|
||||
51
README.rst
51
README.rst
@@ -1,16 +1,15 @@
|
||||
|Build Status| |Codecov| |license|
|
||||
|
||||
frontend-app-discussions
|
||||
========================
|
||||
|
||||
Please tag **@edx/fedx-team** on any PRs or issues. Thanks.
|
||||
|Build Status| |Codecov| |license|
|
||||
|
||||
Introduction
|
||||
------------
|
||||
Purpose
|
||||
-------
|
||||
|
||||
This repository is a React-based micro frontend for the Open edX discussion forums.
|
||||
|
||||
**Installation and Startup**
|
||||
Getting Started
|
||||
---------------
|
||||
|
||||
1. Clone your new repo:
|
||||
|
||||
@@ -26,6 +25,44 @@ This repository is a React-based micro frontend for the Open edX discussion foru
|
||||
|
||||
The dev server is running at `http://localhost:2002 <http://localhost:2002>`_.
|
||||
|
||||
Getting Help
|
||||
------------
|
||||
Please tag **@openedx/edx-infinity ** 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://edx.readthedocs.io/projects/edx-developer-guide/en/latest/process/index.html
|
||||
|
||||
PR description template should be automatically applied if you are sending PR from github interface; otherwise you
|
||||
can find it it at `PULL_REQUEST_TEMPLATE.md <https://github.com/openedx/frontend-app-discussions/blob/master/.github/pull_request_template.md>`_
|
||||
|
||||
This project is currently accepting all types of contributions, bug fixes and security fixes
|
||||
|
||||
The Open edX Code of Conduct
|
||||
----------------------------
|
||||
All community members should familiarize 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 from inspecting catalog-info.yaml.
|
||||
|
||||
Reporting Security Issues
|
||||
-------------------------
|
||||
Please do not report security issues in public. Please email security@edx.org.
|
||||
|
||||
Project Structure
|
||||
-----------------
|
||||
|
||||
@@ -48,4 +85,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
|
||||
18
catalog-info.yaml
Normal file
18
catalog-info.yaml
Normal file
@@ -0,0 +1,18 @@
|
||||
# This file records information about this repo. Its use is described in OEP-55:
|
||||
# https://open-edx-proposals.readthedocs.io/en/latest/processes/oep-0055-proc-project-maintainers.html
|
||||
|
||||
apiVersion: backstage.io/v1alpha1
|
||||
kind: Component
|
||||
metadata:
|
||||
name: 'frontend-app-discussions'
|
||||
description: "The discussion forum for openEdx discussions"
|
||||
links:
|
||||
- url: "https://github.com/openedx/frontend-app-discussions"
|
||||
title: "Frontend app discussions"
|
||||
icon: "Web"
|
||||
annotations:
|
||||
openedx.org/arch-interest-groups: ""
|
||||
spec:
|
||||
owner: group:edx-infinity
|
||||
type: 'website'
|
||||
lifecycle: 'production'
|
||||
1261
package-lock.json
generated
1261
package-lock.json
generated
@@ -19,6 +19,7 @@
|
||||
"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",
|
||||
@@ -44,8 +45,6 @@
|
||||
"@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",
|
||||
@@ -1830,109 +1829,6 @@
|
||||
"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",
|
||||
@@ -1949,15 +1845,6 @@
|
||||
"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",
|
||||
@@ -6549,12 +6436,6 @@
|
||||
"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",
|
||||
@@ -6706,21 +6587,6 @@
|
||||
"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",
|
||||
@@ -6802,12 +6668,6 @@
|
||||
"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",
|
||||
@@ -7310,15 +7170,6 @@
|
||||
"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",
|
||||
@@ -9298,26 +9149,6 @@
|
||||
"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",
|
||||
@@ -9337,16 +9168,6 @@
|
||||
"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",
|
||||
@@ -9362,16 +9183,6 @@
|
||||
"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",
|
||||
@@ -9384,22 +9195,6 @@
|
||||
"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",
|
||||
@@ -10595,17 +10390,6 @@
|
||||
"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",
|
||||
@@ -10760,9 +10544,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.3.8.tgz",
|
||||
"integrity": "sha512-eVhaWoVibIzqdGYjwsBWodIQIaXFSB+cKDf4cfxLMsK0xiud6SE+/WCVx/Xw/UwQsa4cS3T2eITcdtmTg2UKcw=="
|
||||
"version": "2.4.3",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.4.3.tgz",
|
||||
"integrity": "sha512-q6QaLcakcRjebxjg8/+NP+h0rPfatOgOzc46Fst9VAA3jF2ApfKBNKMzdP4DYTqtUMXSCd5pRS/8Po/OmoCHZQ=="
|
||||
},
|
||||
"node_modules/domutils": {
|
||||
"version": "2.8.0",
|
||||
@@ -10969,15 +10753,6 @@
|
||||
"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",
|
||||
@@ -11020,12 +10795,6 @@
|
||||
"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",
|
||||
@@ -11093,23 +10862,6 @@
|
||||
"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",
|
||||
@@ -11142,12 +10894,6 @@
|
||||
"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",
|
||||
@@ -12376,21 +12122,6 @@
|
||||
"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",
|
||||
@@ -12454,12 +12185,6 @@
|
||||
"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",
|
||||
@@ -14125,15 +13850,6 @@
|
||||
"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",
|
||||
@@ -19362,15 +19078,6 @@
|
||||
"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",
|
||||
@@ -19560,19 +19267,6 @@
|
||||
"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",
|
||||
@@ -20053,48 +19747,6 @@
|
||||
"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",
|
||||
@@ -20550,12 +20202,6 @@
|
||||
"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",
|
||||
@@ -23613,15 +23259,6 @@
|
||||
"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",
|
||||
@@ -24249,21 +23886,6 @@
|
||||
"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",
|
||||
@@ -24759,15 +24381,6 @@
|
||||
"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",
|
||||
@@ -24906,15 +24519,6 @@
|
||||
"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",
|
||||
@@ -25140,12 +24744,6 @@
|
||||
"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",
|
||||
@@ -25370,166 +24968,6 @@
|
||||
"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",
|
||||
@@ -25557,22 +24995,6 @@
|
||||
"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",
|
||||
@@ -25693,12 +25115,6 @@
|
||||
"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",
|
||||
@@ -25906,12 +25322,6 @@
|
||||
"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",
|
||||
@@ -26222,15 +25632,6 @@
|
||||
"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",
|
||||
@@ -26296,15 +25697,6 @@
|
||||
"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",
|
||||
@@ -27263,68 +26655,6 @@
|
||||
"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",
|
||||
@@ -28790,90 +28120,6 @@
|
||||
"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",
|
||||
@@ -28884,12 +28130,6 @@
|
||||
"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",
|
||||
@@ -32471,12 +31711,6 @@
|
||||
"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",
|
||||
@@ -32627,21 +31861,6 @@
|
||||
"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",
|
||||
@@ -32720,12 +31939,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"@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",
|
||||
@@ -33128,12 +32341,6 @@
|
||||
"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",
|
||||
@@ -34682,19 +33889,6 @@
|
||||
"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",
|
||||
@@ -34711,16 +33905,6 @@
|
||||
"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",
|
||||
@@ -34736,16 +33920,6 @@
|
||||
"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",
|
||||
@@ -34758,22 +33932,6 @@
|
||||
"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",
|
||||
@@ -35689,17 +34847,6 @@
|
||||
"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",
|
||||
@@ -35825,9 +34972,9 @@
|
||||
}
|
||||
},
|
||||
"dompurify": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.3.8.tgz",
|
||||
"integrity": "sha512-eVhaWoVibIzqdGYjwsBWodIQIaXFSB+cKDf4cfxLMsK0xiud6SE+/WCVx/Xw/UwQsa4cS3T2eITcdtmTg2UKcw=="
|
||||
"version": "2.4.3",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.4.3.tgz",
|
||||
"integrity": "sha512-q6QaLcakcRjebxjg8/+NP+h0rPfatOgOzc46Fst9VAA3jF2ApfKBNKMzdP4DYTqtUMXSCd5pRS/8Po/OmoCHZQ=="
|
||||
},
|
||||
"domutils": {
|
||||
"version": "2.8.0",
|
||||
@@ -35995,15 +35142,6 @@
|
||||
"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",
|
||||
@@ -36034,12 +35172,6 @@
|
||||
"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",
|
||||
@@ -36095,17 +35227,6 @@
|
||||
"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",
|
||||
@@ -36132,12 +35253,6 @@
|
||||
"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",
|
||||
@@ -37111,23 +36226,6 @@
|
||||
"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",
|
||||
@@ -37181,12 +36279,6 @@
|
||||
"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",
|
||||
@@ -38401,15 +37493,6 @@
|
||||
"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",
|
||||
@@ -42369,15 +41452,6 @@
|
||||
"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",
|
||||
@@ -42536,19 +41610,6 @@
|
||||
"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",
|
||||
@@ -42932,39 +41993,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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",
|
||||
@@ -43316,12 +42344,6 @@
|
||||
"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",
|
||||
@@ -45537,12 +44559,6 @@
|
||||
"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",
|
||||
@@ -46051,23 +45067,6 @@
|
||||
"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",
|
||||
@@ -46491,12 +45490,6 @@
|
||||
"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",
|
||||
@@ -46610,15 +45603,6 @@
|
||||
"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",
|
||||
@@ -46799,12 +45783,6 @@
|
||||
"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",
|
||||
@@ -46978,137 +45956,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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",
|
||||
@@ -47130,19 +45977,6 @@
|
||||
"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",
|
||||
@@ -47221,12 +46055,6 @@
|
||||
"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",
|
||||
@@ -47402,12 +46230,6 @@
|
||||
"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",
|
||||
@@ -47653,12 +46475,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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",
|
||||
@@ -47701,15 +46517,6 @@
|
||||
"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",
|
||||
@@ -48410,60 +47217,6 @@
|
||||
"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",
|
||||
|
||||
@@ -43,13 +43,13 @@
|
||||
"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,8 +68,6 @@
|
||||
"@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",
|
||||
|
||||
@@ -9,6 +9,43 @@
|
||||
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>
|
||||
|
||||
@@ -120,28 +120,27 @@ 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
|
||||
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
|
||||
key={element.id}
|
||||
id={element.id}
|
||||
label={element.label}
|
||||
value={element.value}
|
||||
selected={selectedFilters[value.name]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return false;
|
||||
})}
|
||||
</Form.RadioSet>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,53 +1,42 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import MathJax from 'react-mathjax-preview';
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
const baseConfig = {
|
||||
showMathMenu: true,
|
||||
tex2jax: {
|
||||
inlineMath: [
|
||||
['$', '$'],
|
||||
['\\\\(', '\\\\)'],
|
||||
['\\(', '\\)'],
|
||||
['[mathjaxinline]', '[/mathjaxinline]'],
|
||||
['\\begin{math}', '\\end{math}'],
|
||||
],
|
||||
displayMath: [
|
||||
['[mathjax]', '[/mathjax]'],
|
||||
['$$', '$$'],
|
||||
['\\\\[', '\\\\]'],
|
||||
['\\[', '\\]'],
|
||||
['\\begin{displaymath}', '\\end{displaymath}'],
|
||||
['\\begin{equation}', '\\end{equation}'],
|
||||
],
|
||||
},
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
|
||||
skipStartupTypeset: true,
|
||||
import { useDebounce } from '../discussions/data/hooks';
|
||||
|
||||
const defaultSanitizeOptions = {
|
||||
USE_PROFILES: { html: true },
|
||||
ADD_ATTR: ['columnalign'],
|
||||
};
|
||||
|
||||
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(/(\\\((.+?)\\\))+/);
|
||||
function HTMLLoader({
|
||||
htmlNode, componentId, cssClassName, testId,
|
||||
}) {
|
||||
const sanitizedMath = DOMPurify.sanitize(htmlNode, { ...defaultSanitizeOptions });
|
||||
const previewRef = useRef();
|
||||
|
||||
const debouncedPostContent = useDebounce(htmlNode, 500);
|
||||
|
||||
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;
|
||||
}
|
||||
if (debouncedPostContent) {
|
||||
typeset(() => {
|
||||
previewRef.current.innerHTML = sanitizedMath;
|
||||
});
|
||||
}
|
||||
}, [debouncedPostContent]);
|
||||
|
||||
return (
|
||||
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 }} />
|
||||
<div ref={previewRef} className={cssClassName} id={componentId} data-testid={testId} />
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
@@ -55,12 +44,14 @@ HTMLLoader.propTypes = {
|
||||
htmlNode: PropTypes.node,
|
||||
componentId: PropTypes.string,
|
||||
cssClassName: PropTypes.string,
|
||||
testId: PropTypes.string,
|
||||
};
|
||||
|
||||
HTMLLoader.defaultProps = {
|
||||
htmlNode: '',
|
||||
componentId: null,
|
||||
cssClassName: '',
|
||||
testId: '',
|
||||
};
|
||||
|
||||
export default HTMLLoader;
|
||||
|
||||
3
src/components/NavigationBar/data/selectors.js
Normal file
3
src/components/NavigationBar/data/selectors.js
Normal file
@@ -0,0 +1,3 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
|
||||
export const selectCourseTabs = state => state.courseTabs;
|
||||
@@ -29,17 +29,17 @@ function PostPreviewPane({
|
||||
className="float-right p-3"
|
||||
iconClassNames="icon-size"
|
||||
/>
|
||||
<HTMLLoader htmlNode={htmlNode} cssClassName="text-primary" />
|
||||
<HTMLLoader htmlNode={htmlNode} cssClassName="text-primary" componentId="post-preview" testId="post-preview" />
|
||||
</div>
|
||||
)}
|
||||
<div className="d-flex justify-content-end">
|
||||
{!showPreviewPane
|
||||
&& (
|
||||
{!showPreviewPane && (
|
||||
<Button
|
||||
variant="link"
|
||||
size="md"
|
||||
size="sm"
|
||||
onClick={() => setShowPreviewPane(true)}
|
||||
className={`text-primary-500 px-0 ${editExisting && 'mb-4.5'}`}
|
||||
className={`text-primary-500 font-style p-0 ${editExisting && 'mb-4.5'}`}
|
||||
style={{ lineHeight: '26px' }}
|
||||
>
|
||||
{intl.formatMessage(messages.showPreviewButton)}
|
||||
</Button>
|
||||
|
||||
@@ -32,6 +32,7 @@ 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';
|
||||
@@ -100,12 +101,13 @@ 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',
|
||||
plugins: 'autoresize autosave codesample link lists image imagetools code emoticons charmap paste',
|
||||
toolbar: 'undo redo'
|
||||
+ ' | formatselect | bold italic underline'
|
||||
+ ' | link blockquote openedx_code image'
|
||||
@@ -117,6 +119,8 @@ export default function TinyMCEEditor(props) {
|
||||
content_css: false,
|
||||
content_style: contentStyle,
|
||||
body_class: 'm-2 text-editor',
|
||||
convert_urls: false,
|
||||
relative_urls: false,
|
||||
default_link_target: '_blank',
|
||||
target_list: false,
|
||||
images_upload_handler: uploadHandler,
|
||||
|
||||
108
src/components/TopicStats.jsx
Normal file
108
src/components/TopicStats.jsx
Normal file
@@ -0,0 +1,108 @@
|
||||
/* eslint react/prop-types: 0 */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Icon, OverlayTrigger, Tooltip } from '@edx/paragon';
|
||||
import { HelpOutline, PostOutline, Report } from '@edx/paragon/icons';
|
||||
|
||||
import {
|
||||
selectUserHasModerationPrivileges,
|
||||
selectUserIsGroupTa,
|
||||
} from '../discussions/data/selectors';
|
||||
import messages from '../discussions/in-context-topics/messages';
|
||||
|
||||
function TopicStats({
|
||||
threadCounts,
|
||||
activeFlags,
|
||||
inactiveFlags,
|
||||
intl,
|
||||
}) {
|
||||
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
|
||||
const userIsGroupTa = useSelector(selectUserIsGroupTa);
|
||||
const canSeeReportedStats = (activeFlags || inactiveFlags) && (userHasModerationPrivileges || userIsGroupTa);
|
||||
return (
|
||||
<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: threadCounts?.discussion || 0,
|
||||
})}
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
>
|
||||
<div className="d-flex align-items-center mr-3.5">
|
||||
<Icon src={PostOutline} className="icon-size mr-2" />
|
||||
{threadCounts?.discussion || 0}
|
||||
</div>
|
||||
</OverlayTrigger>
|
||||
<OverlayTrigger
|
||||
overlay={(
|
||||
<Tooltip>
|
||||
<div className="d-flex flex-column align-items-start">
|
||||
{intl.formatMessage(messages.questions, {
|
||||
count: threadCounts?.question || 0,
|
||||
})}
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
>
|
||||
<div className="d-flex align-items-center mr-3.5">
|
||||
<Icon src={HelpOutline} className="icon-size mr-2" />
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
TopicStats.propTypes = {
|
||||
threadCounts: PropTypes.shape({
|
||||
discussions: PropTypes.number,
|
||||
questions: PropTypes.number,
|
||||
}),
|
||||
activeFlags: PropTypes.number,
|
||||
inactiveFlags: PropTypes.number,
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
TopicStats.defaultProps = {
|
||||
threadCounts: {
|
||||
discussions: 0,
|
||||
questions: 0,
|
||||
},
|
||||
activeFlags: null,
|
||||
inactiveFlags: null,
|
||||
};
|
||||
|
||||
export default injectIntl(TopicStats);
|
||||
@@ -1,3 +1,4 @@
|
||||
export { default as PostActionsBar } from '../discussions/posts/post-actions-bar/PostActionsBar';
|
||||
export { default as Search } from './Search';
|
||||
export { default as TinyMCEEditor } from './TinyMCEEditor';
|
||||
export { default as TopicStats } from './TopicStats';
|
||||
|
||||
@@ -63,6 +63,7 @@ export const ContentActions = {
|
||||
* @enum {string}
|
||||
*/
|
||||
export const RequestStatus = {
|
||||
IDLE: 'idle',
|
||||
IN_PROGRESS: 'in-progress',
|
||||
SUCCESSFUL: 'successful',
|
||||
FAILED: 'failed',
|
||||
@@ -190,6 +191,8 @@ 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`,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -38,12 +38,6 @@ 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 || [],
|
||||
|
||||
@@ -1,239 +0,0 @@
|
||||
import React, { useContext, useEffect, useMemo } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useParams } from 'react-router';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Button, Icon, IconButton, Spinner,
|
||||
} from '@edx/paragon';
|
||||
import { ArrowBack } from '@edx/paragon/icons';
|
||||
|
||||
import {
|
||||
EndorsementStatus, PostsPages, ThreadType,
|
||||
} from '../../data/constants';
|
||||
import { useDispatchWithState } from '../../data/hooks';
|
||||
import { DiscussionContext } from '../common/context';
|
||||
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 } from '../utils';
|
||||
import { selectThreadComments, selectThreadCurrentPage, selectThreadHasMorePages } from './data/selectors';
|
||||
import { fetchThreadComments } from './data/thunks';
|
||||
import { Comment, ResponseEditor } from './comment';
|
||||
import messages from './messages';
|
||||
|
||||
function usePost(postId) {
|
||||
const dispatch = useDispatch();
|
||||
const thread = useSelector(selectThread(postId));
|
||||
|
||||
useEffect(() => {
|
||||
if (thread && !thread.read) {
|
||||
dispatch(markThreadAsRead(postId));
|
||||
}
|
||||
}, [postId]);
|
||||
|
||||
return thread;
|
||||
}
|
||||
|
||||
function usePostComments(postId, endorsed = null) {
|
||||
const [isLoading, dispatch] = useDispatchWithState();
|
||||
const comments = useSelector(selectThreadComments(postId, endorsed));
|
||||
const hasMorePages = useSelector(selectThreadHasMorePages(postId, endorsed));
|
||||
const currentPage = useSelector(selectThreadCurrentPage(postId, endorsed));
|
||||
const handleLoadMoreResponses = async () => dispatch(fetchThreadComments(postId, {
|
||||
endorsed,
|
||||
page: currentPage + 1,
|
||||
}));
|
||||
useEffect(() => {
|
||||
dispatch(fetchThreadComments(postId, {
|
||||
endorsed,
|
||||
page: 1,
|
||||
}));
|
||||
}, [postId]);
|
||||
return {
|
||||
comments,
|
||||
hasMorePages,
|
||||
isLoading,
|
||||
handleLoadMoreResponses,
|
||||
};
|
||||
}
|
||||
|
||||
function DiscussionCommentsView({
|
||||
postType,
|
||||
postId,
|
||||
intl,
|
||||
endorsed,
|
||||
isClosed,
|
||||
}) {
|
||||
const {
|
||||
comments,
|
||||
hasMorePages,
|
||||
isLoading,
|
||||
handleLoadMoreResponses,
|
||||
} = usePostComments(postId, endorsed);
|
||||
const sortedComments = useMemo(() => [...filterPosts(comments, 'endorsed'),
|
||||
...filterPosts(comments, 'unendorsed')], [comments]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{((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>
|
||||
</>
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
DiscussionCommentsView.propTypes = {
|
||||
postId: PropTypes.string.isRequired,
|
||||
postType: PropTypes.string.isRequired,
|
||||
isClosed: PropTypes.bool.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
endorsed: PropTypes.oneOf([
|
||||
EndorsementStatus.ENDORSED, EndorsementStatus.UNENDORSED, EndorsementStatus.DISCUSSION,
|
||||
]).isRequired,
|
||||
};
|
||||
|
||||
function CommentsView({ intl }) {
|
||||
const [isLoading, submitDispatch] = useDispatchWithState();
|
||||
const { postId } = useParams();
|
||||
const thread = usePost(postId);
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
const isOnDesktop = useIsOnDesktop();
|
||||
const {
|
||||
courseId, learnerUsername, category, topicId, page, inContext,
|
||||
} = useContext(DiscussionContext);
|
||||
|
||||
useEffect(() => {
|
||||
if (!thread) { submitDispatch(fetchThread(postId, courseId, true)); }
|
||||
}, [postId]);
|
||||
|
||||
if (!thread) {
|
||||
if (!isLoading) {
|
||||
return (
|
||||
<EmptyPage title={intl.formatMessage(messages.noThreadFound)} />
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Spinner animation="border" variant="primary" data-testid="loading-indicator" />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isOnDesktop && (
|
||||
inContext ? (
|
||||
<>
|
||||
<div className="px-4 py-1.5 bg-white">
|
||||
<Button
|
||||
variant="plain"
|
||||
className="px-0 font-weight-light text-primary-500"
|
||||
iconBefore={ArrowBack}
|
||||
onClick={() => history.push(discussionsPath(PostsPages[page], {
|
||||
courseId, learnerUsername, category, topicId,
|
||||
})(location))}
|
||||
size="sm"
|
||||
>
|
||||
{intl.formatMessage(messages.backAlt)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="border-bottom border-light-400" />
|
||||
</>
|
||||
) : (
|
||||
<IconButton
|
||||
src={ArrowBack}
|
||||
iconAs={Icon}
|
||||
style={{ padding: '18px' }}
|
||||
size="inline"
|
||||
className="ml-4 mt-4"
|
||||
onClick={() => history.push(discussionsPath(PostsPages[page], {
|
||||
courseId, learnerUsername, category, topicId,
|
||||
})(location))}
|
||||
alt={intl.formatMessage(messages.backAlt)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<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} />
|
||||
{!thread.closed && <ResponseEditor postId={postId} /> }
|
||||
</div>
|
||||
{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.ENDORSED}
|
||||
isClosed={thread.closed}
|
||||
/>
|
||||
<DiscussionCommentsView
|
||||
postId={postId}
|
||||
intl={intl}
|
||||
postType={thread.type}
|
||||
endorsed={EndorsementStatus.UNENDORSED}
|
||||
isClosed={thread.closed}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
CommentsView.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(CommentsView);
|
||||
@@ -1,44 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { useDispatch } from 'react-redux';
|
||||
import * as timeago from 'timeago.js';
|
||||
|
||||
import { injectIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import timeLocale from '../../common/time-locale';
|
||||
import LikeButton from '../../posts/post/LikeButton';
|
||||
import { editComment } from '../data/thunks';
|
||||
|
||||
function CommentIcons({
|
||||
comment,
|
||||
}) {
|
||||
const dispatch = useDispatch();
|
||||
timeago.register('time-locale', timeLocale);
|
||||
|
||||
const handleLike = () => dispatch(editComment(comment.id, { voted: !comment.voted }));
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
CommentIcons.propTypes = {
|
||||
comment: PropTypes.shape({
|
||||
id: PropTypes.string,
|
||||
voteCount: PropTypes.number,
|
||||
following: PropTypes.bool,
|
||||
voted: PropTypes.bool,
|
||||
createdAt: PropTypes.string,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(CommentIcons);
|
||||
@@ -1,169 +0,0 @@
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Button, useToggle } from '@edx/paragon';
|
||||
|
||||
import HTMLLoader from '../../../components/HTMLLoader';
|
||||
import { ContentActions } from '../../../data/constants';
|
||||
import { AlertBanner, DeleteConfirmation, EndorsedAlertBanner } from '../../common';
|
||||
import { DiscussionContext } from '../../common/context';
|
||||
import { selectBlackoutDate } from '../../data/selectors';
|
||||
import { fetchThread } from '../../posts/data/thunks';
|
||||
import { inBlackoutDateRange } from '../../utils';
|
||||
import CommentIcons from '../comment-icons/CommentIcons';
|
||||
import { selectCommentCurrentPage, selectCommentHasMorePages, selectCommentResponses } from '../data/selectors';
|
||||
import { editComment, fetchCommentResponses, removeComment } from '../data/thunks';
|
||||
import messages from '../messages';
|
||||
import CommentEditor from './CommentEditor';
|
||||
import CommentHeader from './CommentHeader';
|
||||
import { commentShape } from './proptypes';
|
||||
import Reply from './Reply';
|
||||
|
||||
function Comment({
|
||||
postType,
|
||||
comment,
|
||||
showFullThread = true,
|
||||
isClosedPost,
|
||||
intl,
|
||||
}) {
|
||||
const dispatch = useDispatch();
|
||||
const hasChildren = comment.childCount > 0;
|
||||
const isNested = Boolean(comment.parentId);
|
||||
const inlineReplies = useSelector(selectCommentResponses(comment.id));
|
||||
const [isEditing, setEditing] = useState(false);
|
||||
const [isDeleting, showDeleteConfirmation, hideDeleteConfirmation] = useToggle(false);
|
||||
const [isReplying, setReplying] = useState(false);
|
||||
const hasMorePages = useSelector(selectCommentHasMorePages(comment.id));
|
||||
const currentPage = useSelector(selectCommentCurrentPage(comment.id));
|
||||
const blackoutDateRange = useSelector(selectBlackoutDate);
|
||||
const {
|
||||
courseId,
|
||||
} = useContext(DiscussionContext);
|
||||
useEffect(() => {
|
||||
// If the comment has a parent comment, it won't have any children, so don't fetch them.
|
||||
if (hasChildren && !currentPage && showFullThread) {
|
||||
dispatch(fetchCommentResponses(comment.id, { page: 1 }));
|
||||
}
|
||||
}, [comment.id]);
|
||||
|
||||
const actionHandlers = {
|
||||
[ContentActions.EDIT_CONTENT]: () => setEditing(true),
|
||||
[ContentActions.ENDORSE]: async () => {
|
||||
await dispatch(editComment(comment.id, { endorsed: !comment.endorsed }, ContentActions.ENDORSE));
|
||||
await dispatch(fetchThread(comment.threadId, courseId));
|
||||
},
|
||||
[ContentActions.DELETE]: showDeleteConfirmation,
|
||||
[ContentActions.REPORT]: () => dispatch(editComment(comment.id, { flagged: !comment.abuseFlagged })),
|
||||
};
|
||||
|
||||
const handleLoadMoreComments = () => (
|
||||
dispatch(fetchCommentResponses(comment.id, { page: currentPage + 1 }))
|
||||
);
|
||||
|
||||
return (
|
||||
<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}
|
||||
onDelete={() => {
|
||||
dispatch(removeComment(comment.id));
|
||||
hideDeleteConfirmation();
|
||||
}}
|
||||
/>
|
||||
<EndorsedAlertBanner postType={postType} content={comment} />
|
||||
<div className="d-flex flex-column p-4.5">
|
||||
<AlertBanner content={comment} />
|
||||
<CommentHeader comment={comment} actionHandlers={actionHandlers} postType={postType} />
|
||||
{isEditing
|
||||
? (
|
||||
<CommentEditor comment={comment} onCloseEditor={() => setEditing(false)} formClasses="pt-3" />
|
||||
)
|
||||
: <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}
|
||||
/>
|
||||
<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="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 ? (
|
||||
<CommentEditor
|
||||
comment={{
|
||||
threadId: comment.threadId,
|
||||
parentId: comment.id,
|
||||
}}
|
||||
edit={false}
|
||||
onCloseEditor={() => setReplying(false)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{(!isClosedPost && !inBlackoutDateRange(blackoutDateRange))
|
||||
&& (
|
||||
<Button
|
||||
className="d-flex flex-grow mt-3 py-2 font-size-14"
|
||||
variant="outline-primary"
|
||||
style={{
|
||||
lineHeight: '20px',
|
||||
}}
|
||||
onClick={() => setReplying(true)}
|
||||
>
|
||||
{intl.formatMessage(messages.addComment)}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Comment.propTypes = {
|
||||
postType: PropTypes.oneOf(['discussion', 'question']).isRequired,
|
||||
comment: commentShape.isRequired,
|
||||
showFullThread: PropTypes.bool,
|
||||
isClosedPost: PropTypes.bool,
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
Comment.defaultProps = {
|
||||
showFullThread: true,
|
||||
isClosedPost: false,
|
||||
};
|
||||
|
||||
export default injectIntl(Comment);
|
||||
@@ -1,102 +0,0 @@
|
||||
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 { logError } from '@edx/frontend-platform/logging';
|
||||
import {
|
||||
Avatar, Icon,
|
||||
} from '@edx/paragon';
|
||||
|
||||
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,
|
||||
})}
|
||||
>
|
||||
<div className="align-items-center d-flex flex-row">
|
||||
<Avatar
|
||||
className={`border-0 ml-0.5 mr-2.5 ${colorClass ? `outline-${colorClass}` : 'outline-anonymous'}`}
|
||||
alt={comment.author}
|
||||
src={authorAvatars?.imageUrlSmall}
|
||||
style={{
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
}}
|
||||
/>
|
||||
<AuthorLabel
|
||||
author={comment.author}
|
||||
authorLabel={comment.authorLabel}
|
||||
labelColor={colorClass && `text-${colorClass}`}
|
||||
linkToProfile
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
CommentHeader.propTypes = {
|
||||
comment: commentShape.isRequired,
|
||||
actionHandlers: PropTypes.objectOf(PropTypes.func).isRequired,
|
||||
postType: PropTypes.oneOf([ThreadType.QUESTION, ThreadType.DISCUSSION]).isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(CommentHeader);
|
||||
@@ -1,57 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,112 +0,0 @@
|
||||
import React, { useState } 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 { Avatar, useToggle } from '@edx/paragon';
|
||||
|
||||
import HTMLLoader from '../../../components/HTMLLoader';
|
||||
import { AvatarOutlineAndLabelColors, ContentActions } from '../../../data/constants';
|
||||
import {
|
||||
ActionsDropdown, AlertBanner, AuthorLabel, DeleteConfirmation,
|
||||
} from '../../common';
|
||||
import timeLocale from '../../common/time-locale';
|
||||
import { useAlertBannerVisible } from '../../data/hooks';
|
||||
import { selectAuthorAvatars } from '../../posts/data/selectors';
|
||||
import { editComment, removeComment } from '../data/thunks';
|
||||
import messages from '../messages';
|
||||
import CommentEditor from './CommentEditor';
|
||||
import { commentShape } from './proptypes';
|
||||
|
||||
function Reply({
|
||||
reply,
|
||||
postType,
|
||||
intl,
|
||||
}) {
|
||||
timeago.register('time-locale', timeLocale);
|
||||
const dispatch = useDispatch();
|
||||
const [isEditing, setEditing] = useState(false);
|
||||
const [isDeleting, showDeleteConfirmation, hideDeleteConfirmation] = useToggle(false);
|
||||
const actionHandlers = {
|
||||
[ContentActions.EDIT_CONTENT]: () => setEditing(true),
|
||||
[ContentActions.ENDORSE]: () => dispatch(editComment(
|
||||
reply.id,
|
||||
{ endorsed: !reply.endorsed },
|
||||
ContentActions.ENDORSE,
|
||||
)),
|
||||
[ContentActions.DELETE]: showDeleteConfirmation,
|
||||
[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-4.5" data-testid={`reply-${reply.id}`} role="listitem">
|
||||
<DeleteConfirmation
|
||||
isOpen={isDeleting}
|
||||
title={intl.formatMessage(messages.deleteCommentTitle)}
|
||||
description={intl.formatMessage(messages.deleteCommentDescription)}
|
||||
onClose={hideDeleteConfirmation}
|
||||
onDelete={() => {
|
||||
dispatch(removeComment(reply.id));
|
||||
hideDeleteConfirmation();
|
||||
}}
|
||||
/>
|
||||
|
||||
{hasAnyAlert && (
|
||||
<div className="d-flex">
|
||||
<div className="d-flex invisible">
|
||||
<Avatar />
|
||||
</div>
|
||||
<div className="w-100">
|
||||
<AlertBanner content={reply} intl={intl} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="d-flex">
|
||||
<div className="d-flex mr-3 mt-2.5">
|
||||
<Avatar
|
||||
className={`ml-0.5 mt-0.5 border-0 ${colorClass ? `outline-${colorClass}` : 'outline-anonymous'}`}
|
||||
alt={reply.author}
|
||||
src={authorAvatars?.imageUrlSmall}
|
||||
style={{
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
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 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>
|
||||
{isEditing
|
||||
? <CommentEditor comment={reply} onCloseEditor={() => setEditing(false)} />
|
||||
: <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>
|
||||
);
|
||||
}
|
||||
Reply.propTypes = {
|
||||
postType: PropTypes.oneOf(['discussion', 'question']).isRequired,
|
||||
reply: commentShape.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
export default injectIntl(Reply);
|
||||
@@ -1,66 +0,0 @@
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
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,
|
||||
}) {
|
||||
const { inContext } = useContext(DiscussionContext);
|
||||
const [addingResponse, setAddingResponse] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
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={() => 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,
|
||||
};
|
||||
|
||||
ResponseEditor.defaultProps = {
|
||||
addWrappingDiv: false,
|
||||
};
|
||||
|
||||
export default injectIntl(ResponseEditor);
|
||||
@@ -1,2 +0,0 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export { default as CommentsView } from './CommentsView';
|
||||
@@ -11,9 +11,9 @@ import {
|
||||
import { MoreHoriz } from '@edx/paragon/icons';
|
||||
|
||||
import { ContentActions } from '../../data/constants';
|
||||
import { commentShape } from '../comments/comment/proptypes';
|
||||
import { selectBlackoutDate } from '../data/selectors';
|
||||
import messages from '../messages';
|
||||
import { commentShape } from '../post-comments/comments/comment/proptypes';
|
||||
import { postShape } from '../posts/post/proptypes';
|
||||
import { inBlackoutDateRange, useActions } from '../utils';
|
||||
import { DiscussionContext } from './context';
|
||||
@@ -23,11 +23,13 @@ function ActionsDropdown({
|
||||
commentOrPost,
|
||||
disabled,
|
||||
actionHandlers,
|
||||
iconSize,
|
||||
dropDownIconSize,
|
||||
}) {
|
||||
const [isOpen, open, close] = useToggle(false);
|
||||
const [target, setTarget] = useState(null);
|
||||
const actions = useActions(commentOrPost);
|
||||
const { inContext } = useContext(DiscussionContext);
|
||||
const { enableInContextSidebar } = useContext(DiscussionContext);
|
||||
const handleActions = (action) => {
|
||||
const actionFunction = actionHandlers[action];
|
||||
if (actionFunction) {
|
||||
@@ -49,40 +51,43 @@ function ActionsDropdown({
|
||||
src={MoreHoriz}
|
||||
iconAs={Icon}
|
||||
disabled={disabled}
|
||||
size="sm"
|
||||
size={iconSize}
|
||||
ref={setTarget}
|
||||
iconClassNames={dropDownIconSize ? 'dropdown-icon-dimentions' : ''}
|
||||
/>
|
||||
<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="actions-dropdown">
|
||||
<ModalPopup
|
||||
onClose={close}
|
||||
positionRef={target}
|
||||
isOpen={isOpen}
|
||||
placement={enableInContextSidebar ? 'left' : 'auto-start'}
|
||||
>
|
||||
{actions.map(action => (
|
||||
<React.Fragment key={action.id}>
|
||||
{action.action === ContentActions.DELETE
|
||||
&& <Dropdown.Divider />}
|
||||
<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 />}
|
||||
|
||||
<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>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -92,10 +97,14 @@ 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);
|
||||
|
||||
@@ -14,7 +14,7 @@ import messages from '../messages';
|
||||
import { ACTIONS_LIST } from '../utils';
|
||||
import ActionsDropdown from './ActionsDropdown';
|
||||
|
||||
import '../comments/data/__factories__';
|
||||
import '../post-comments/data/__factories__';
|
||||
import '../posts/data/__factories__';
|
||||
|
||||
let store;
|
||||
|
||||
@@ -6,11 +6,13 @@ 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 { Error } from '@edx/paragon/icons';
|
||||
import { Report } from '@edx/paragon/icons';
|
||||
|
||||
import { commentShape } from '../comments/comment/proptypes';
|
||||
import messages from '../comments/messages';
|
||||
import { selectModerationSettings, selectUserHasModerationPrivileges, selectUserIsGroupTa } from '../data/selectors';
|
||||
import {
|
||||
selectModerationSettings, selectUserHasModerationPrivileges, selectUserIsGroupTa, selectUserIsStaff,
|
||||
} from '../data/selectors';
|
||||
import { commentShape } from '../post-comments/comments/comment/proptypes';
|
||||
import messages from '../post-comments/messages';
|
||||
import { postShape } from '../posts/post/proptypes';
|
||||
import AuthorLabel from './AuthorLabel';
|
||||
|
||||
@@ -20,40 +22,56 @@ 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={Error} variant="danger" className="px-3 mb-2 py-10px shadow-none flex-fill">
|
||||
<Alert icon={Report} variant="danger" className="px-3 mb-1 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-2 py-10px bg-light-200">
|
||||
<div className="d-flex align-items-center flex-wrap">
|
||||
<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-style">
|
||||
{intl.formatMessage(messages.editedBy)}
|
||||
<span className="ml-1 mr-3">
|
||||
<AuthorLabel author={content.lastEdit.editorUsername} linkToProfile />
|
||||
<AuthorLabel author={content.lastEdit.editorUsername} linkToProfile postOrComment />
|
||||
</span>
|
||||
<span
|
||||
className="mx-1.5 font-size-8 font-style text-light-700"
|
||||
style={{ lineHeight: '15px' }}
|
||||
>
|
||||
{intl.formatMessage(messages.fullStop)}
|
||||
</span>
|
||||
{intl.formatMessage(messages.reason)}: {content.lastEdit.reason}
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
{content.closed && (
|
||||
<Alert variant="info" className="px-3 shadow-none mb-2 py-10px bg-light-200">
|
||||
<div className="d-flex align-items-center flex-wrap">
|
||||
<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-style">
|
||||
{intl.formatMessage(messages.closedBy)}
|
||||
<span className="ml-1 ">
|
||||
<AuthorLabel author={content.closedBy} linkToProfile />
|
||||
<AuthorLabel author={content.closedBy} linkToProfile postOrComment />
|
||||
</span>
|
||||
<span className="mx-1" />
|
||||
<span
|
||||
className="mx-1.5 font-size-8 font-style text-light-700"
|
||||
style={{ lineHeight: '15px' }}
|
||||
>
|
||||
{intl.formatMessage(messages.fullStop)}
|
||||
</span>
|
||||
|
||||
{content.closeReason && (`${intl.formatMessage(messages.reason)}: ${content.closeReason}`)}
|
||||
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
@@ -7,11 +7,11 @@ import { AppProvider } from '@edx/frontend-platform/react';
|
||||
|
||||
import { ThreadType } from '../../data/constants';
|
||||
import { initializeStore } from '../../store';
|
||||
import messages from '../comments/messages';
|
||||
import messages from '../post-comments/messages';
|
||||
import AlertBanner from './AlertBanner';
|
||||
import { DiscussionContext } from './context';
|
||||
|
||||
import '../comments/data/__factories__';
|
||||
import '../post-comments/data/__factories__';
|
||||
import '../posts/data/__factories__';
|
||||
|
||||
let store;
|
||||
|
||||
@@ -3,9 +3,10 @@ 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 } from '@edx/paragon';
|
||||
import { Icon, OverlayTrigger, Tooltip } from '@edx/paragon';
|
||||
import { Institution, School } from '@edx/paragon/icons';
|
||||
|
||||
import { Routes } from '../../data/constants';
|
||||
@@ -13,6 +14,7 @@ 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,
|
||||
@@ -21,11 +23,15 @@ 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;
|
||||
@@ -37,37 +43,56 @@ function AuthorLabel({
|
||||
}
|
||||
|
||||
const isRetiredUser = author ? author.startsWith('retired__user') : false;
|
||||
const showTextPrimary = !authorLabelMessage && !isRetiredUser && !alert;
|
||||
|
||||
const className = classNames('d-flex align-items-center', labelColor);
|
||||
const className = classNames('d-flex align-items-center', { 'mb-0.5': !postOrComment }, labelColor);
|
||||
|
||||
const showUserNameAsLink = useShowLearnersTab()
|
||||
&& linkToProfile && author && author !== intl.formatMessage(messages.anonymous);
|
||||
&& linkToProfile && author && author !== intl.formatMessage(messages.anonymous);
|
||||
|
||||
const labelContents = (
|
||||
<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,
|
||||
})}
|
||||
role="heading"
|
||||
aria-level="2"
|
||||
>
|
||||
{isRetiredUser ? '[Deactivated]' : author }
|
||||
</span>
|
||||
{icon && (
|
||||
<Icon
|
||||
style={{
|
||||
width: '1rem',
|
||||
height: '1rem',
|
||||
}}
|
||||
src={icon}
|
||||
/>
|
||||
{!alert && (
|
||||
<span
|
||||
className={classNames('mr-1.5 font-size-14 font-style 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,
|
||||
})}
|
||||
>
|
||||
<Icon
|
||||
style={{
|
||||
width: '1rem',
|
||||
height: '1rem',
|
||||
}}
|
||||
src={icon}
|
||||
data-testid="author-icon"
|
||||
/>
|
||||
|
||||
</div>
|
||||
</OverlayTrigger>
|
||||
{authorLabelMessage && (
|
||||
<span
|
||||
className={classNames('mr-1 font-size-14 font-style-normal font-family-inter font-weight-500', {
|
||||
'text-primary-500': !authorLabelMessage && !isRetiredUser && !alert,
|
||||
className={classNames('mr-1.5 font-size-14 font-style font-weight-500', {
|
||||
'text-primary-500': showTextPrimary,
|
||||
'text-gray-700': isRetiredUser,
|
||||
})}
|
||||
style={{ marginLeft: '2px' }}
|
||||
@@ -75,6 +100,19 @@ 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>
|
||||
);
|
||||
|
||||
@@ -100,6 +138,9 @@ AuthorLabel.propTypes = {
|
||||
linkToProfile: PropTypes.bool,
|
||||
labelColor: PropTypes.string,
|
||||
alert: PropTypes.bool,
|
||||
postCreatedAt: PropTypes.string,
|
||||
authorToolTip: PropTypes.bool,
|
||||
postOrComment: PropTypes.bool,
|
||||
};
|
||||
|
||||
AuthorLabel.defaultProps = {
|
||||
@@ -107,6 +148,9 @@ AuthorLabel.defaultProps = {
|
||||
authorLabel: null,
|
||||
labelColor: '',
|
||||
alert: false,
|
||||
postCreatedAt: null,
|
||||
authorToolTip: false,
|
||||
postOrComment: false,
|
||||
};
|
||||
|
||||
export default injectIntl(AuthorLabel);
|
||||
|
||||
@@ -6,13 +6,16 @@ import { ActionRow, Button, ModalDialog } from '@edx/paragon';
|
||||
|
||||
import messages from '../messages';
|
||||
|
||||
function DeleteConfirmation({
|
||||
function Confirmation({
|
||||
intl,
|
||||
isOpen,
|
||||
title,
|
||||
description,
|
||||
onClose,
|
||||
onDelete,
|
||||
comfirmAction,
|
||||
closeButtonVaraint,
|
||||
confirmButtonVariant,
|
||||
confirmButtonText,
|
||||
}) {
|
||||
return (
|
||||
<ModalDialog title={title} isOpen={isOpen} hasCloseButton={false} onClose={onClose} zIndex={5000}>
|
||||
@@ -26,11 +29,11 @@ function DeleteConfirmation({
|
||||
</ModalDialog.Body>
|
||||
<ModalDialog.Footer>
|
||||
<ActionRow>
|
||||
<ModalDialog.CloseButton variant="tertiary">
|
||||
{intl.formatMessage(messages.deleteConfirmationCancel)}
|
||||
<ModalDialog.CloseButton variant={closeButtonVaraint}>
|
||||
{intl.formatMessage(messages.confirmationCancel)}
|
||||
</ModalDialog.CloseButton>
|
||||
<Button variant="primary" onClick={onDelete}>
|
||||
{intl.formatMessage(messages.deleteConfirmationDelete)}
|
||||
<Button variant={confirmButtonVariant} onClick={comfirmAction}>
|
||||
{ confirmButtonText || intl.formatMessage(messages.confirmationConfirm)}
|
||||
</Button>
|
||||
</ActionRow>
|
||||
</ModalDialog.Footer>
|
||||
@@ -38,13 +41,22 @@ function DeleteConfirmation({
|
||||
);
|
||||
}
|
||||
|
||||
DeleteConfirmation.propTypes = {
|
||||
Confirmation.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
onDelete: PropTypes.func.isRequired,
|
||||
comfirmAction: PropTypes.func.isRequired,
|
||||
title: PropTypes.string.isRequired,
|
||||
description: PropTypes.string.isRequired,
|
||||
closeButtonVaraint: PropTypes.string,
|
||||
confirmButtonVariant: PropTypes.string,
|
||||
confirmButtonText: PropTypes.string,
|
||||
};
|
||||
|
||||
export default injectIntl(DeleteConfirmation);
|
||||
Confirmation.defaultProps = {
|
||||
closeButtonVaraint: 'default',
|
||||
confirmButtonVariant: 'primary',
|
||||
confirmButtonText: '',
|
||||
};
|
||||
|
||||
export default injectIntl(Confirmation);
|
||||
@@ -4,12 +4,12 @@ import PropTypes from 'prop-types';
|
||||
import * as timeago from 'timeago.js';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Alert } from '@edx/paragon';
|
||||
import { Alert, Icon } from '@edx/paragon';
|
||||
import { CheckCircle, Verified } from '@edx/paragon/icons';
|
||||
|
||||
import { ThreadType } from '../../data/constants';
|
||||
import { commentShape } from '../comments/comment/proptypes';
|
||||
import messages from '../comments/messages';
|
||||
import { commentShape } from '../post-comments/comments/comment/proptypes';
|
||||
import messages from '../post-comments/messages';
|
||||
import AuthorLabel from './AuthorLabel';
|
||||
import timeLocale from './time-locale';
|
||||
|
||||
@@ -27,32 +27,32 @@ function EndorsedAlertBanner({
|
||||
content.endorsed && (
|
||||
<Alert
|
||||
variant="plain"
|
||||
className={`px-3 mb-0 py-10px align-items-center shadow-none ${classes}`}
|
||||
className={`px-2.5 mb-0 py-8px 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">
|
||||
<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>
|
||||
<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' }}>
|
||||
<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>
|
||||
|
||||
@@ -7,11 +7,11 @@ import { AppProvider } from '@edx/frontend-platform/react';
|
||||
|
||||
import { ThreadType } from '../../data/constants';
|
||||
import { initializeStore } from '../../store';
|
||||
import messages from '../comments/messages';
|
||||
import messages from '../post-comments/messages';
|
||||
import { DiscussionContext } from './context';
|
||||
import EndorsedAlertBanner from './EndorsedAlertBanner';
|
||||
|
||||
import '../comments/data/__factories__';
|
||||
import '../post-comments/data/__factories__';
|
||||
import '../posts/data/__factories__';
|
||||
|
||||
let store;
|
||||
@@ -46,21 +46,21 @@ describe.each([
|
||||
type: 'comment',
|
||||
postType: ThreadType.QUESTION,
|
||||
props: { endorsed: true, endorsedBy: 'test-user', endorsedByLabel: 'Staff' },
|
||||
expectText: [messages.answer.defaultMessage, messages.answeredLabel.defaultMessage, 'test-user', 'Staff'],
|
||||
expectText: [messages.answer.defaultMessage, '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, messages.answeredLabel.defaultMessage, 'test-user', 'TA'],
|
||||
expectText: [messages.answer.defaultMessage, 'TA'],
|
||||
},
|
||||
{
|
||||
label: 'endorsed comment in a discussion thread',
|
||||
type: 'comment',
|
||||
postType: ThreadType.DISCUSSION,
|
||||
props: { endorsed: true, endorsedBy: 'test-user' },
|
||||
expectText: [messages.endorsed.defaultMessage, messages.endorsedLabel.defaultMessage, 'test-user'],
|
||||
expectText: [messages.endorsed.defaultMessage],
|
||||
},
|
||||
])('EndorsedAlertBanner', ({
|
||||
label, type, postType, props, expectText,
|
||||
|
||||
116
src/discussions/common/HoverCard.jsx
Normal file
116
src/discussions/common/HoverCard.jsx
Normal file
@@ -0,0 +1,116 @@
|
||||
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 { useUserCanAddThreadInBlackoutDate } from '../data/hooks';
|
||||
import { commentShape } from '../post-comments/comments/comment/proptypes';
|
||||
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="flex-fill justify-content-end align-items-center hover-card mr-n4 position-absolute"
|
||||
data-testid={`hover-card-${commentOrPost.id}`}
|
||||
id={`hover-card-${commentOrPost.id}`}
|
||||
>
|
||||
{userCanAddThreadInBlackoutDate && (
|
||||
<div className="d-flex">
|
||||
<Button
|
||||
variant="tertiary"
|
||||
className={classNames('px-2.5 py-2 border-0 font-style 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();
|
||||
}}
|
||||
/>
|
||||
</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);
|
||||
186
src/discussions/common/HoverCard.test.jsx
Normal file
186
src/discussions/common/HoverCard.test.jsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import {
|
||||
render, screen, waitFor,
|
||||
within,
|
||||
} 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 { 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 DiscussionContent from '../discussions-home/DiscussionContent';
|
||||
import { getCommentsApiUrl } from '../post-comments/data/api';
|
||||
import { getThreadsApiUrl } from '../posts/data/api';
|
||||
import { fetchThreads } from '../posts/data/thunks';
|
||||
import { DiscussionContext } from './context';
|
||||
|
||||
import '../posts/data/__factories__';
|
||||
import '../post-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';
|
||||
const reverseOrder = false;
|
||||
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,
|
||||
reverse_order: reverseOrder,
|
||||
},
|
||||
})
|
||||
.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 have hover card on post', async () => {
|
||||
renderComponent(discussionPostId);
|
||||
const post = screen.getByTestId('post-thread-1');
|
||||
expect(within(post).getByTestId('hover-card-thread-1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('it should have hover card on comment', async () => {
|
||||
renderComponent(discussionPostId);
|
||||
const comment = await waitFor(() => screen.findByTestId('comment-comment-1'));
|
||||
expect(within(comment).getByTestId('hover-card-comment-1')).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');
|
||||
const view = within(post).getByTestId('hover-card-thread-1');
|
||||
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-comment-3'));
|
||||
const view = within(comment).getByTestId('hover-card-comment-3');
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@ export const DiscussionContext = React.createContext({
|
||||
courseId: null,
|
||||
postId: null,
|
||||
topicId: null,
|
||||
inContext: false,
|
||||
enableInContextSidebar: false,
|
||||
category: null,
|
||||
learnerUsername: null,
|
||||
});
|
||||
|
||||
@@ -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 DeleteConfirmation } from './DeleteConfirmation';
|
||||
export { default as Confirmation } from './Confirmation';
|
||||
export { default as EndorsedAlertBanner } from './EndorsedAlertBanner';
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import {
|
||||
useContext, useEffect, useRef, useState,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
@@ -10,20 +13,30 @@ import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
import { AppContext } from '@edx/frontend-platform/react';
|
||||
import { breakpoints, useWindowSize } from '@edx/paragon';
|
||||
|
||||
import { Routes } from '../../data/constants';
|
||||
import { RequestStatus, 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 { fetchCourseTopics } from '../topics/data/thunks';
|
||||
import { discussionsPath } from '../utils';
|
||||
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 {
|
||||
selectAreThreadsFiltered, selectLearnersTabEnabled,
|
||||
selectAreThreadsFiltered,
|
||||
selectBlackoutDate,
|
||||
selectEnableInContext,
|
||||
selectIsCourseAdmin,
|
||||
selectIsCourseStaff,
|
||||
selectLearnersTabEnabled,
|
||||
selectModerationSettings,
|
||||
selectPostThreadCount,
|
||||
selectUserHasModerationPrivileges,
|
||||
selectUserIsGroupTa,
|
||||
selectUserIsStaff,
|
||||
} from './selectors';
|
||||
import { fetchCourseConfig } from './thunks';
|
||||
|
||||
@@ -34,27 +47,28 @@ 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 isViewingTopics = useRouteMatch(Routes.TOPICS.PATH);
|
||||
const isViewingLearners = useRouteMatch(Routes.LEARNERS.PATH);
|
||||
const isThreadsEmpty = Boolean(useSelector(threadsLoadingStatus()) === RequestStatus.SUCCESSFUL && !totalThreads);
|
||||
const isIncontextTopicsView = Boolean(useRouteMatch(Routes.TOPICS.PATH) && enableInContext);
|
||||
const hideSidebar = Boolean(isThreadsEmpty && !isFiltered && !(isViewingTopics?.isExact || isViewingLearners));
|
||||
|
||||
if (isFiltered) {
|
||||
if (isIncontextTopicsView) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isViewingTopics || isViewingLearners) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return totalThreads > 0;
|
||||
return !hideSidebar;
|
||||
};
|
||||
|
||||
export function useCourseDiscussionData(courseId) {
|
||||
@@ -64,7 +78,6 @@ export function useCourseDiscussionData(courseId) {
|
||||
useEffect(() => {
|
||||
async function fetchBaseData() {
|
||||
await dispatch(fetchCourseConfig(courseId));
|
||||
await dispatch(fetchCourseTopics(courseId));
|
||||
await dispatch(fetchCourseBlocks(courseId, authenticatedUser.username));
|
||||
}
|
||||
|
||||
@@ -72,7 +85,7 @@ export function useCourseDiscussionData(courseId) {
|
||||
}, [courseId]);
|
||||
}
|
||||
|
||||
export function useRedirectToThread(courseId, inContext) {
|
||||
export function useRedirectToThread(courseId, enableInContextSidebar) {
|
||||
const dispatch = useDispatch();
|
||||
const redirectToThread = useSelector(
|
||||
(state) => state.threads.redirectToThread,
|
||||
@@ -85,7 +98,7 @@ export function useRedirectToThread(courseId, inContext) {
|
||||
// stored in redirectToThread
|
||||
if (redirectToThread) {
|
||||
dispatch(clearRedirect());
|
||||
const newLocation = discussionsPath(Routes.COMMENTS.PAGES[inContext ? 'topics' : 'my-posts'], {
|
||||
const newLocation = discussionsPath(Routes.COMMENTS.PAGES[enableInContextSidebar ? 'topics' : 'my-posts'], {
|
||||
courseId,
|
||||
postId: redirectToThread.threadId,
|
||||
topicId: redirectToThread.topicId,
|
||||
@@ -97,7 +110,7 @@ export function useRedirectToThread(courseId, inContext) {
|
||||
|
||||
export function useIsOnDesktop() {
|
||||
const windowSize = useWindowSize();
|
||||
return windowSize.width >= breakpoints.large.minWidth;
|
||||
return windowSize.width >= breakpoints.medium.minWidth;
|
||||
}
|
||||
|
||||
export function useIsOnXLDesktop() {
|
||||
@@ -174,3 +187,61 @@ 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)],
|
||||
}
|
||||
));
|
||||
};
|
||||
|
||||
export const useDebounce = (value, delay) => {
|
||||
// State and setters for debounced value
|
||||
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||
useEffect(
|
||||
() => {
|
||||
// Update debounced value after delay
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
// Cancel the timeout if value changes (also on delay change or unmount)
|
||||
// This is how we prevent debounced value from updating if value is changed ...
|
||||
// .. within the delay period. Timeout gets cleared and restarted.
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
},
|
||||
[value, delay], // Only re-call effect if value or delay changes
|
||||
);
|
||||
return debouncedValue;
|
||||
};
|
||||
|
||||
@@ -1,15 +1,33 @@
|
||||
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 { useCurrentDiscussionTopic } from './hooks';
|
||||
import { getCourseConfigApiUrl } from './api';
|
||||
import { useCurrentDiscussionTopic, useUserCanAddThreadInBlackoutDate } from './hooks';
|
||||
import { fetchCourseConfig } from './thunks';
|
||||
|
||||
const courseId = 'course-v1:edX+TestX+Test_Course';
|
||||
const courseConfigApiUrl = getCourseConfigApiUrl();
|
||||
let store;
|
||||
initializeMockApp();
|
||||
let axiosMock;
|
||||
|
||||
const generateApiResponse = (blackouts = [], isCourseAdmin = false) => ({
|
||||
blackouts,
|
||||
hasModerationPrivileges: false,
|
||||
isGroupTa: false,
|
||||
isCourseAdmin,
|
||||
isCourseStaff: false,
|
||||
isUserAdmin: false,
|
||||
});
|
||||
|
||||
describe('Hooks', () => {
|
||||
describe('useCurrentDiscussionTopic', () => {
|
||||
function ComponentWithHook() {
|
||||
@@ -39,6 +57,7 @@ describe('Hooks', () => {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
initializeMockApp();
|
||||
store = initializeStore({
|
||||
blocks: {
|
||||
blocks: {
|
||||
@@ -82,4 +101,75 @@ 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,6 +24,12 @@ 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,
|
||||
@@ -47,7 +53,7 @@ export function selectAreThreadsFiltered(state) {
|
||||
|
||||
export function selectTopicThreadCount(topicId) {
|
||||
return state => {
|
||||
const topic = state.topics.topics[topicId];
|
||||
const topic = topicId && state.topics?.topics[topicId];
|
||||
if (!topic) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ const configSlice = createSlice({
|
||||
groupAtSubsection: false,
|
||||
hasModerationPrivileges: false,
|
||||
isGroupTa: false,
|
||||
isCourseAdmin: false,
|
||||
isCourseStaff: false,
|
||||
isUserAdmin: false,
|
||||
learnersTabEnabled: false,
|
||||
settings: {
|
||||
@@ -25,6 +27,7 @@ const configSlice = createSlice({
|
||||
reasonCodesEnabled: false,
|
||||
editReasons: [],
|
||||
postCloseReasons: [],
|
||||
enableInContext: false,
|
||||
},
|
||||
reducers: {
|
||||
fetchConfigRequest: (state) => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { camelCaseObject } from '@edx/frontend-platform';
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
|
||||
import {
|
||||
LearnersOrdering,
|
||||
DiscussionProvider, LearnersOrdering,
|
||||
PostsStatusFilter,
|
||||
} from '../../data/constants';
|
||||
import { setSortedBy } from '../learners/data';
|
||||
@@ -36,7 +36,10 @@ export function fetchCourseConfig(courseId) {
|
||||
learnerSort = LearnersOrdering.BY_FLAG;
|
||||
}
|
||||
|
||||
dispatch(fetchConfigSuccess(camelCaseObject(config)));
|
||||
dispatch(fetchConfigSuccess(camelCaseObject({
|
||||
...config,
|
||||
enable_in_context: config.provider === DiscussionProvider.OPEN_EDX,
|
||||
})));
|
||||
dispatch(setSortedBy(learnerSort));
|
||||
dispatch(setStatusFilter(postsFilterStatus));
|
||||
} catch (error) {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Route, Switch } from 'react-router';
|
||||
import { injectIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { Routes } from '../../data/constants';
|
||||
import { CommentsView } from '../comments';
|
||||
import { PostCommentsView } from '../post-comments';
|
||||
import { PostEditor } from '../posts';
|
||||
|
||||
function DiscussionContent() {
|
||||
@@ -25,7 +25,7 @@ function DiscussionContent() {
|
||||
<PostEditor editExisting />
|
||||
</Route>
|
||||
<Route path={Routes.COMMENTS.PATH}>
|
||||
<CommentsView />
|
||||
<PostCommentsView />
|
||||
</Route>
|
||||
</Switch>
|
||||
)}
|
||||
|
||||
@@ -14,51 +14,72 @@ import { DiscussionContext } from '../common/context';
|
||||
import {
|
||||
useContainerSize, useIsOnDesktop, useIsOnXLDesktop, useShowLearnersTab,
|
||||
} from '../data/hooks';
|
||||
import { selectconfigLoadingStatus } from '../data/selectors';
|
||||
import { selectconfigLoadingStatus, selectEnableInContext } from '../data/selectors';
|
||||
import { TopicPostsView, TopicsView as InContextTopicsView } from '../in-context-topics';
|
||||
import { LearnerPostsView, LearnersView } from '../learners';
|
||||
import { PostsView } from '../posts';
|
||||
import { TopicsView } from '../topics';
|
||||
import { TopicsView as LegacyTopicsView } 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 && !inContext) {
|
||||
if (sidebarRef && postActionBarHeight && !enableInContextSidebar) {
|
||||
if (isOnDesktop) {
|
||||
sidebarRef.current.style.maxHeight = `${windowHeight - postActionBarHeight}px`;
|
||||
}
|
||||
sidebarRef.current.style.minHeight = `${windowHeight - postActionBarHeight}px`;
|
||||
sidebarRef.current.style.top = `${postActionBarHeight}px`;
|
||||
}
|
||||
}, [sidebarRef, postActionBarHeight, inContext]);
|
||||
}, [sidebarRef, postActionBarHeight, enableInContextSidebar]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={sidebarRef}
|
||||
className={classNames('flex-column position-sticky', {
|
||||
className={classNames('flex-column position-sticky', {
|
||||
'd-none': !displaySidebar,
|
||||
'd-flex overflow-auto': displaySidebar,
|
||||
'd-flex overflow-auto box-shadow-centered-1': displaySidebar,
|
||||
'w-100': !isOnDesktop,
|
||||
'sidebar-desktop-width': isOnDesktop && !isOnXLDesktop,
|
||||
'w-25 sidebar-XL-width': isOnXLDesktop,
|
||||
'min-content-height': !inContext,
|
||||
'min-content-height': !enableInContextSidebar,
|
||||
})}
|
||||
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.PATH, Routes.POSTS.ALL_POSTS, Routes.TOPICS.CATEGORY, Routes.POSTS.MY_POSTS]}
|
||||
path={[Routes.POSTS.ALL_POSTS, Routes.POSTS.MY_POSTS, Routes.POSTS.PATH, Routes.TOPICS.CATEGORY]}
|
||||
component={PostsView}
|
||||
/>
|
||||
<Route path={Routes.TOPICS.PATH} component={TopicsView} />
|
||||
<Route path={Routes.TOPICS.PATH} component={LegacyTopicsView} />
|
||||
{redirectToLearnersTab && (
|
||||
<Route path={Routes.LEARNERS.POSTS} component={LearnerPostsView} />
|
||||
)}
|
||||
@@ -66,13 +87,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>
|
||||
|
||||
@@ -12,60 +12,53 @@ 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 } from '../data/selectors';
|
||||
import { selectDiscussionProvider, selectEnableInContext } from '../data/selectors';
|
||||
import { EmptyLearners, EmptyPosts, EmptyTopics } from '../empty-posts';
|
||||
import { EmptyTopic as InContextEmptyTopics } from '../in-context-topics/components';
|
||||
import messages from '../messages';
|
||||
import { BreadcrumbMenu, LegacyBreadcrumbMenu, NavigationBar } from '../navigation';
|
||||
import { LegacyBreadcrumbMenu, NavigationBar } from '../navigation';
|
||||
import { selectPostEditorVisible } from '../posts/data/selectors';
|
||||
import DiscussionsProductTour from '../tours/DiscussionsProductTour';
|
||||
import { postMessageToParent } from '../utils';
|
||||
import BlackoutInformationBanner from './BlackoutInformationBanner';
|
||||
import DiscussionContent from './DiscussionContent';
|
||||
import DiscussionSidebar from './DiscussionSidebar';
|
||||
import InformationBanner from './InformationsBanner';
|
||||
import InformationBanner from './InformationBanner';
|
||||
|
||||
export default function DiscussionsHome() {
|
||||
const location = useLocation();
|
||||
const postActionBarRef = useRef(null);
|
||||
const postEditorVisible = useSelector(
|
||||
(state) => state.threads.postEditorVisible,
|
||||
);
|
||||
const {
|
||||
params: { page },
|
||||
} = useRouteMatch(`${Routes.COMMENTS.PAGE}?`);
|
||||
|
||||
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 { params: { path } } = useRouteMatch(`${Routes.DISCUSSIONS.PATH}/:path*`);
|
||||
const { params } = useRouteMatch(ALL_ROUTES);
|
||||
const isRedirectToLearners = useShowLearnersTab();
|
||||
const isFeedbackBannerVisible = getConfig().DISPLAY_FEEDBACK_BANNER === 'true';
|
||||
|
||||
const {
|
||||
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();
|
||||
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,
|
||||
} = params;
|
||||
|
||||
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, inContext);
|
||||
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; }
|
||||
|
||||
useEffect(() => {
|
||||
if (path && path !== 'undefined') {
|
||||
postMessageToParent('discussions.navigate', { path });
|
||||
@@ -78,41 +71,47 @@ export default function DiscussionsHome() {
|
||||
courseId,
|
||||
postId,
|
||||
topicId,
|
||||
inContext,
|
||||
enableInContextSidebar,
|
||||
category,
|
||||
learnerUsername,
|
||||
}}
|
||||
>
|
||||
{!inContext && <Header courseOrg={org} courseNumber={courseNumber} courseTitle={courseTitle} />}
|
||||
{!enableInContextSidebar && <Header courseOrg={org} courseNumber={courseNumber} courseTitle={courseTitle} />}
|
||||
<main className="container-fluid d-flex flex-column p-0 w-100" id="main" tabIndex="-1">
|
||||
{!inContext && <CourseTabsNavigation activeTab="discussion" courseId={courseId} />}
|
||||
{!enableInContextSidebar && <CourseTabsNavigation activeTab="discussion" courseId={courseId} />}
|
||||
<div
|
||||
className={classNames('header-action-bar', { 'shadow-none border-light-300 border-bottom': inContext })}
|
||||
className={classNames('header-action-bar', {
|
||||
'shadow-none border-light-300 border-bottom': enableInContextSidebar,
|
||||
})}
|
||||
ref={postActionBarRef}
|
||||
>
|
||||
<div
|
||||
className={classNames('d-flex flex-row justify-content-between navbar fixed-top', {
|
||||
'pl-4 pr-2.5 py-1.5': inContext,
|
||||
'pl-4 pr-3 py-0': enableInContextSidebar,
|
||||
})}
|
||||
>
|
||||
{!inContext && <Route path={Routes.DISCUSSIONS.PATH} component={NavigationBar} />}
|
||||
<PostActionsBar inContext={inContext} />
|
||||
{!enableInContextSidebar && <Route path={Routes.DISCUSSIONS.PATH} component={NavigationBar} />}
|
||||
<PostActionsBar />
|
||||
</div>
|
||||
{isFeedbackBannerVisible && <InformationBanner />}
|
||||
<BlackoutInformationBanner />
|
||||
</div>
|
||||
{!inContext && (
|
||||
{provider === DiscussionProvider.LEGACY && (
|
||||
<Route
|
||||
path={[Routes.POSTS.PATH, Routes.TOPICS.CATEGORY]}
|
||||
component={provider === DiscussionProvider.LEGACY ? LegacyBreadcrumbMenu : BreadcrumbMenu}
|
||||
component={LegacyBreadcrumbMenu}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="d-flex flex-row">
|
||||
<DiscussionSidebar displaySidebar={displaySidebar} postActionBarRef={postActionBarRef} />
|
||||
{displayContentArea && <DiscussionContent />}
|
||||
{!displayContentArea && (
|
||||
<Switch>
|
||||
<Route path={Routes.TOPICS.PATH} component={EmptyTopics} />
|
||||
<Route
|
||||
path={Routes.TOPICS.PATH}
|
||||
component={(enableInContext || enableInContextSidebar) ? InContextEmptyTopics : EmptyTopics}
|
||||
/>
|
||||
<Route
|
||||
path={Routes.POSTS.MY_POSTS}
|
||||
render={routeProps => <EmptyPosts {...routeProps} subTitleMessage={messages.emptyMyPosts} />}
|
||||
@@ -121,12 +120,13 @@ export default function DiscussionsHome() {
|
||||
path={[Routes.POSTS.PATH, Routes.POSTS.ALL_POSTS, Routes.LEARNERS.POSTS]}
|
||||
render={routeProps => <EmptyPosts {...routeProps} subTitleMessage={messages.emptyAllPosts} />}
|
||||
/>
|
||||
{isRedirectToLearners && <Route path={Routes.LEARNERS.PATH} component={EmptyLearners} /> }
|
||||
{isRedirectToLearners && <Route path={Routes.LEARNERS.PATH} component={EmptyLearners} />}
|
||||
</Switch>
|
||||
)}
|
||||
</div>
|
||||
<DiscussionsProductTour />
|
||||
</main>
|
||||
{!inContext && <Footer />}
|
||||
{!enableInContextSidebar && <Footer />}
|
||||
</DiscussionContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
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';
|
||||
|
||||
@@ -40,7 +47,7 @@ describe('DiscussionsHome', () => {
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
store = initializeStore();
|
||||
});
|
||||
|
||||
@@ -63,7 +70,9 @@ describe('DiscussionsHome', () => {
|
||||
});
|
||||
|
||||
test('in-context view should show close button', async () => {
|
||||
renderComponent(`/${courseId}/topics?inContext`);
|
||||
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`).reply(200, { provider: 'openedx' });
|
||||
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
|
||||
renderComponent(`/${courseId}/topics?inContextSidebar`);
|
||||
|
||||
expect(screen.queryByText(navigationBarMessages.allTopics.defaultMessage))
|
||||
.not
|
||||
@@ -73,10 +82,12 @@ 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?inContext`);
|
||||
renderComponent(`/${courseId}/topics?inContextSidebar`);
|
||||
|
||||
const closeButton = screen.queryByRole('button', { name: 'Close' });
|
||||
|
||||
@@ -88,7 +99,7 @@ describe('DiscussionsHome', () => {
|
||||
window.parent = parent;
|
||||
});
|
||||
|
||||
test('header, course navigation bar and footer are visible', async () => {
|
||||
test('header, course navigation bar and footer are only visible in Discussions MFE', async () => {
|
||||
renderComponent();
|
||||
expect(screen.queryByRole('banner')).toBeInTheDocument();
|
||||
expect(document.getElementById('courseTabsNavigation')).toBeInTheDocument();
|
||||
|
||||
@@ -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 './InformationsBanner';
|
||||
import InformationBanner from './InformationBanner';
|
||||
|
||||
import '../posts/data/__factories__';
|
||||
|
||||
|
||||
@@ -52,7 +52,11 @@ function EmptyPosts({ intl, subTitleMessage }) {
|
||||
}
|
||||
|
||||
EmptyPosts.propTypes = {
|
||||
subTitleMessage: propTypes.string.isRequired,
|
||||
subTitleMessage: propTypes.shape({
|
||||
id: propTypes.string,
|
||||
defaultMessage: propTypes.string,
|
||||
description: propTypes.string,
|
||||
}).isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
|
||||
99
src/discussions/in-context-topics/TopicPostsView.jsx
Normal file
99
src/discussions/in-context-topics/TopicPostsView.jsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import React, { useContext, useEffect } from 'react';
|
||||
|
||||
import { useDispatch, 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 { selectDiscussionProvider } from '../data/selectors';
|
||||
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 { fetchCourseTopicsV3 } from './data/thunks';
|
||||
import { BackButton, NoResults } from './components';
|
||||
import messages from './messages';
|
||||
import { Topic } from './topic';
|
||||
|
||||
function TopicPostsView({ intl }) {
|
||||
const location = useLocation();
|
||||
const dispatch = useDispatch();
|
||||
const { courseId, topicId, category } = useContext(DiscussionContext);
|
||||
const provider = useSelector(selectDiscussionProvider);
|
||||
const topicsStatus = useSelector(selectLoadingStatus);
|
||||
const topicsInProgress = topicsStatus === RequestStatus.IN_PROGRESS;
|
||||
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));
|
||||
|
||||
useEffect(() => {
|
||||
if (provider && topicsStatus === RequestStatus.IDLE) {
|
||||
dispatch(fetchCourseTopicsV3(courseId));
|
||||
}
|
||||
}, [provider]);
|
||||
|
||||
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
|
||||
loading={topicsInProgress}
|
||||
path={backButtonPath()}
|
||||
title={selectedUnit?.name || selectedNonCoursewareTopic?.name || selectedArchivedTopic?.name
|
||||
|| intl.formatMessage(messages.unnamedTopic)}
|
||||
/>
|
||||
) : (
|
||||
<BackButton
|
||||
loading={topicsInProgress}
|
||||
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]}
|
||||
parentIsLoading={topicsInProgress}
|
||||
/>
|
||||
) : (
|
||||
selectedSubsectionUnits?.map((unit) => (
|
||||
<Topic
|
||||
key={unit.id}
|
||||
topic={unit}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
{(category && selectedSubsectionUnits.length === 0 && topicsStatus === RequestStatus.SUCCESSFUL) && (
|
||||
<NoResults />
|
||||
)}
|
||||
{(category && topicsInProgress) && (
|
||||
<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);
|
||||
121
src/discussions/in-context-topics/TopicsView.jsx
Normal file
121
src/discussions/in-context-topics/TopicsView.jsx
Normal file
@@ -0,0 +1,121 @@
|
||||
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;
|
||||
48
src/discussions/in-context-topics/components/BackButton.jsx
Normal file
48
src/discussions/in-context-topics/components/BackButton.jsx
Normal file
@@ -0,0 +1,48 @@
|
||||
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, Spinner } from '@edx/paragon';
|
||||
import { ArrowBack } from '@edx/paragon/icons';
|
||||
|
||||
import messages from '../messages';
|
||||
|
||||
function BackButton({
|
||||
intl, path, title, loading,
|
||||
}) {
|
||||
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">
|
||||
{loading ? <Spinner animation="border" variant="primary" size="sm" /> : title}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-bottom border-light-400" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
BackButton.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
path: PropTypes.shape({}).isRequired,
|
||||
title: PropTypes.string.isRequired,
|
||||
loading: PropTypes.bool,
|
||||
};
|
||||
|
||||
BackButton.defaultProps = {
|
||||
loading: false,
|
||||
};
|
||||
|
||||
export default injectIntl(BackButton);
|
||||
83
src/discussions/in-context-topics/components/EmptyTopics.jsx
Normal file
83
src/discussions/in-context-topics/components/EmptyTopics.jsx
Normal file
@@ -0,0 +1,83 @@
|
||||
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);
|
||||
29
src/discussions/in-context-topics/components/NoResults.jsx
Normal file
29
src/discussions/in-context-topics/components/NoResults.jsx
Normal file
@@ -0,0 +1,29 @@
|
||||
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);
|
||||
4
src/discussions/in-context-topics/components/index.js
Normal file
4
src/discussions/in-context-topics/components/index.js
Normal file
@@ -0,0 +1,4 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export { default as BackButton } from './BackButton';
|
||||
export { default as EmptyTopic } from './EmptyTopics';
|
||||
export { default as NoResults } from './NoResults';
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Factory } from 'rosie';
|
||||
|
||||
import { getApiBaseUrl } from '../../../../data/constants';
|
||||
|
||||
Factory.define('topic')
|
||||
.sequence('id', ['topicPrefix'], (idx, topicPrefix) => `${topicPrefix}-${idx}`)
|
||||
.sequence('enabled-in-context', ['enabledInContext'], (idx, enabledInContext) => enabledInContext)
|
||||
.sequence('name', ['topicNamePrefix'], (idx, topicNamePrefix) => `${topicNamePrefix}-${idx}`)
|
||||
.sequence('usage-key', ['usageKey'], (idx, usageKey) => usageKey)
|
||||
.sequence('courseware', ['courseware'], (idx, courseware) => courseware)
|
||||
|
||||
.attr('thread_counts', ['discussionCount', 'questionCount'], (discCount, questCount) => {
|
||||
Factory.reset('thread-counts');
|
||||
return Factory.build('thread-counts', null, { discussionCount: discCount, questionCount: questCount });
|
||||
});
|
||||
|
||||
Factory.define('sub-section')
|
||||
.sequence('block_id', (idx) => `${idx}`)
|
||||
.option('topicPrefix', null, '')
|
||||
.sequence('id', ['topicPrefix'], (idx, topicPrefix) => `${topicPrefix}-topic-${idx}`)
|
||||
.sequence('display-name', ['sectionPrefix'], (idx, sectionPrefix) => `Introduction ${sectionPrefix + idx}`)
|
||||
.option('courseId', null, 'course-v1:edX+DemoX+Demo_Course')
|
||||
.sequence('legacy_web_url', ['id', 'courseId'],
|
||||
(idx, id, courseId) => `${getApiBaseUrl}/courses/${courseId}/jump_to/block-v1:${id}?experience=legacy`)
|
||||
.sequence('lms_web_url', ['id', 'courseId'],
|
||||
(idx, id, courseId) => `${getApiBaseUrl}/courses/${courseId}/jump_to/block-v1:${id}`)
|
||||
.sequence('student_view_url', ['id', 'courseId'],
|
||||
(idx, id) => `${getApiBaseUrl}/xblock/block-v1:${id}`)
|
||||
.attr('type', null, 'sequential')
|
||||
.attr('children', ['id', 'display-name', 'courseId'], (id, name, courseId) => {
|
||||
Factory.reset('topic');
|
||||
return Factory.buildList('topic', 2, null, {
|
||||
topicPrefix: `${id}`,
|
||||
enabledInContext: true,
|
||||
topicNamePrefix: `${name}`,
|
||||
usageKey: `${courseId.replace('course-v1:', 'block-v1:')} +type@vertical+block@vertical_`,
|
||||
discussionCount: 1,
|
||||
questionCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
Factory.define('section')
|
||||
.sequence('block_id', (idx) => `${idx}`)
|
||||
.option('topicPrefix', null, '')
|
||||
.sequence('id', ['topicPrefix'], (idx, topicPrefix) => `${topicPrefix}-topic-${idx}`)
|
||||
.attr('courseware', null, true)
|
||||
.sequence('display-name', (idx) => `Introduction ${idx}`)
|
||||
.option('courseId', null, 'course-v1:edX+DemoX+Demo_Course')
|
||||
.sequence('legacy_web_url', ['id', 'courseId'],
|
||||
(idx, id, courseId) => `${getApiBaseUrl}/courses/${courseId}/jump_to/${courseId.replace('course-v1:', 'block-v1:')}+type@chapter+block@${id}?experience=legacy`)
|
||||
.sequence('lms_web_url', ['id', 'courseId'],
|
||||
(idx, id, courseId) => `${getApiBaseUrl}/courses/${courseId}/jump_to/${courseId.replace('course-v1:', 'block-v1:')}+type@chapter+block@${id}`)
|
||||
.sequence('student_view_url', ['id', 'courseId'],
|
||||
(idx, id, courseId) => `${getApiBaseUrl}/xblock/${courseId.replace('course-v1:', 'block-v1:')}+type@chapter+block@${id}`)
|
||||
.attr('type', null, 'chapter')
|
||||
.attr('children', ['display-name'], (name) => {
|
||||
Factory.reset('sub-section');
|
||||
return Factory.buildList('sub-section', 2, null, { sectionPrefix: `${name}-`, topicPrefix: 'section' });
|
||||
});
|
||||
|
||||
Factory.define('thread-counts')
|
||||
.sequence('discussion', ['discussionCount'], (idx, discussionCount) => discussionCount)
|
||||
.sequence('question', ['questionCount'], (idx, questionCount) => questionCount);
|
||||
|
||||
Factory.define('archived-topics')
|
||||
.attr('id', null, 'archived')
|
||||
.option('courseId', null, 'course-v1:edX+DemoX+Demo_Course')
|
||||
.attr('children', ['id', 'courseId'], (id, courseId) => {
|
||||
Factory.reset('topic');
|
||||
return Factory.buildList('topic', 2, null, {
|
||||
topicPrefix: `${id}`,
|
||||
enabledInContext: false,
|
||||
topicNamePrefix: `${id}`,
|
||||
usageKey: `${courseId.replace('course-v1:', 'block-v1:')} +type@vertical+block@`,
|
||||
discussionCount: 1,
|
||||
questionCount: 1,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
import './inContextTopics.factory';
|
||||
13
src/discussions/in-context-topics/data/api.js
Normal file
13
src/discussions/in-context-topics/data/api.js
Normal file
@@ -0,0 +1,13 @@
|
||||
/* 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 const getCourseTopicsApiUrl = () => `${getApiBaseUrl()}/api/discussion/v3/course_topics/`;
|
||||
|
||||
export async function getCourseTopicsV3(courseId) {
|
||||
const url = `${getApiBaseUrl()}/api/discussion/v3/course_topics/${courseId}`;
|
||||
const { data } = await getAuthenticatedHttpClient().get(url);
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
72
src/discussions/in-context-topics/data/api.test.js
Normal file
72
src/discussions/in-context-topics/data/api.test.js
Normal file
@@ -0,0 +1,72 @@
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { Factory } from 'rosie';
|
||||
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { initializeMockApp } from '@edx/frontend-platform/testing';
|
||||
|
||||
import { initializeStore } from '../../../store';
|
||||
import { executeThunk } from '../../../test-utils';
|
||||
import { getCourseTopicsApiUrl, getCourseTopicsV3 } from './api';
|
||||
import { fetchCourseTopicsV3 } from './thunks';
|
||||
|
||||
import './__factories__';
|
||||
|
||||
const courseId = 'course-v1:edX+TestX+Test_Course';
|
||||
const courseId2 = 'course-v1:edX+TestX+Test_Course2';
|
||||
const courseTopicsApiUrl = getCourseTopicsApiUrl();
|
||||
|
||||
let axiosMock = null;
|
||||
let store;
|
||||
|
||||
describe('In context topic api tests', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
store = initializeStore();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
axiosMock.reset();
|
||||
});
|
||||
|
||||
test('successfully get topics', async () => {
|
||||
axiosMock.onGet(`${courseTopicsApiUrl}${courseId}`)
|
||||
.reply(200, (Factory.buildList('topic', 1, null, {
|
||||
topicPrefix: 'noncourseware-topic',
|
||||
enabledInContext: true,
|
||||
topicNamePrefix: 'general-topic',
|
||||
usageKey: '',
|
||||
courseware: false,
|
||||
discussionCount: 1,
|
||||
questionCount: 1,
|
||||
}).concat(Factory.buildList('section', 2, null, { topicPrefix: 'courseware' })))
|
||||
.concat(Factory.buildList('archived-topics', 2, null)));
|
||||
|
||||
const response = await getCourseTopicsV3(courseId);
|
||||
|
||||
expect(response).not.toBeUndefined();
|
||||
});
|
||||
|
||||
it('failed to fetch topics', async () => {
|
||||
axiosMock.onGet(`${courseTopicsApiUrl}${courseId2}`)
|
||||
.reply(404);
|
||||
await executeThunk(fetchCourseTopicsV3(courseId2), store.dispatch, store.getState);
|
||||
|
||||
expect(store.getState().inContextTopics.status).toEqual('failed');
|
||||
});
|
||||
|
||||
it('denied to fetch topics', async () => {
|
||||
axiosMock.onGet(`${courseTopicsApiUrl}${courseId}`)
|
||||
.reply(403, {});
|
||||
await executeThunk(fetchCourseTopicsV3(courseId), store.dispatch);
|
||||
|
||||
expect(store.getState().inContextTopics.status).toEqual('denied');
|
||||
});
|
||||
});
|
||||
186
src/discussions/in-context-topics/data/redux.test.js
Normal file
186
src/discussions/in-context-topics/data/redux.test.js
Normal file
@@ -0,0 +1,186 @@
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { Factory } from 'rosie';
|
||||
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { initializeMockApp } from '@edx/frontend-platform/testing';
|
||||
|
||||
import { initializeStore } from '../../../store';
|
||||
import { executeThunk } from '../../../test-utils';
|
||||
import { getCourseTopicsApiUrl } from './api';
|
||||
import { fetchCourseTopicsV3 } from './thunks';
|
||||
|
||||
import './__factories__';
|
||||
|
||||
const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||
const courseTopicsApiUrl = getCourseTopicsApiUrl();
|
||||
|
||||
let axiosMock;
|
||||
let store;
|
||||
|
||||
describe('Redux in context topics tests', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
Factory.resetAll();
|
||||
store = initializeStore();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
axiosMock.reset();
|
||||
});
|
||||
|
||||
async function setupMockData() {
|
||||
axiosMock.onGet(`${courseTopicsApiUrl}${courseId}`)
|
||||
.reply(200, (Factory.buildList('topic', 1, null, {
|
||||
topicPrefix: 'noncourseware-topic',
|
||||
enabledInContext: true,
|
||||
topicNamePrefix: 'general-topic',
|
||||
usageKey: '',
|
||||
courseware: false,
|
||||
discussionCount: 1,
|
||||
questionCount: 1,
|
||||
}).concat(Factory.buildList('section', 2, null, { topicPrefix: 'courseware' })))
|
||||
.concat(Factory.buildList('archived-topics', 2, null)));
|
||||
|
||||
await executeThunk(fetchCourseTopicsV3(courseId), store.dispatch, store.getState);
|
||||
const state = store.getState();
|
||||
return state;
|
||||
}
|
||||
|
||||
test('successfully load initial states in redux', async () => {
|
||||
executeThunk(fetchCourseTopicsV3(courseId), store.dispatch, store.getState);
|
||||
const state = store.getState();
|
||||
|
||||
expect(state.inContextTopics.status).toEqual('in-progress');
|
||||
expect(state.inContextTopics.topics).toHaveLength(0);
|
||||
expect(state.inContextTopics.coursewareTopics).toHaveLength(0);
|
||||
expect(state.inContextTopics.nonCoursewareTopics).toHaveLength(0);
|
||||
expect(state.inContextTopics.nonCoursewareIds).toHaveLength(0);
|
||||
expect(state.inContextTopics.units).toHaveLength(0);
|
||||
expect(state.inContextTopics.archivedTopics).toHaveLength(0);
|
||||
expect(state.inContextTopics.filter).toEqual('');
|
||||
});
|
||||
|
||||
test('successfully store all api data of courseware and noncourseware in redux', async () => {
|
||||
setupMockData().then((state) => {
|
||||
const { coursewareTopics, nonCoursewareTopics } = state.inContextTopics;
|
||||
|
||||
expect(coursewareTopics).toHaveLength(2);
|
||||
expect(nonCoursewareTopics).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
test('successfully store the combined list of courseware and noncourseware topics in topics', async () => {
|
||||
setupMockData().then((state) => {
|
||||
const {
|
||||
coursewareTopics, nonCoursewareTopics, archivedTopics, topics,
|
||||
} = state.inContextTopics;
|
||||
|
||||
expect(topics).toHaveLength(coursewareTopics.length + nonCoursewareTopics.length + archivedTopics.length);
|
||||
});
|
||||
});
|
||||
|
||||
test('successfully get the posts ', async () => {
|
||||
setupMockData().then((state) => {
|
||||
expect(state?.inContextTopics?.status).toEqual('successful');
|
||||
});
|
||||
});
|
||||
|
||||
test('successfully checked that the coursewaretopic has three levels', async () => {
|
||||
setupMockData().then((state) => {
|
||||
const { coursewareTopics } = state.inContextTopics;
|
||||
|
||||
// contain chapter at first level
|
||||
coursewareTopics.forEach((chapter, index) => {
|
||||
expect(chapter.courseware).toEqual(true);
|
||||
expect(chapter.id).toEqual(`courseware-topic-${index + 1}`);
|
||||
expect(chapter.type).toEqual('chapter');
|
||||
expect(chapter).toHaveProperty('blockId');
|
||||
expect(chapter).toHaveProperty('lmsWebUrl');
|
||||
expect(chapter).toHaveProperty('legacyWebUrl');
|
||||
expect(chapter).toHaveProperty('studentViewUrl');
|
||||
|
||||
// contain section at second level
|
||||
chapter.children.forEach((section, secIndex) => {
|
||||
expect(section.id).toEqual(`section-topic-${secIndex + 1}`);
|
||||
expect(section.type).toEqual('sequential');
|
||||
expect(section).toHaveProperty('blockId');
|
||||
expect(section).toHaveProperty('lmsWebUrl');
|
||||
expect(section).toHaveProperty('legacyWebUrl');
|
||||
expect(section).toHaveProperty('studentViewUrl');
|
||||
|
||||
// contain sub section at third level
|
||||
section.children.forEach((subSection, subSecIndex) => {
|
||||
expect(subSection.enabledInContext).toEqual(true);
|
||||
expect(subSection.id).toEqual(`${section.id}-${subSecIndex + 1}`);
|
||||
expect(subSection).toHaveProperty('usageKey');
|
||||
expect(subSection).not.toHaveProperty('blockId');
|
||||
expect(subSection?.threadCounts?.discussion).toEqual(1);
|
||||
expect(subSection?.threadCounts?.question).toEqual(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('successfully checked that the noncoursewaretopic have proper attributes', async () => {
|
||||
setupMockData().then((state) => {
|
||||
const { nonCoursewareTopics } = state.inContextTopics;
|
||||
|
||||
nonCoursewareTopics.forEach((topic, index) => {
|
||||
expect(topic.usageKey).toEqual('');
|
||||
expect(topic.id).toEqual(`noncourseware-topic-${index + 1}`);
|
||||
expect(topic.name).toEqual(`general-topic-${index + 1}`);
|
||||
expect(topic.enabledInContext).toEqual(true);
|
||||
expect(topic?.threadCounts?.discussion).toEqual(1);
|
||||
expect(topic?.threadCounts?.question).toEqual(1);
|
||||
expect(topic).not.toHaveProperty('blockId');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('nonCoursewareIds successfully contains ids of noncourseware topics', async () => {
|
||||
setupMockData().then((state) => {
|
||||
const { nonCoursewareIds, nonCoursewareTopics } = state.inContextTopics;
|
||||
|
||||
nonCoursewareIds.forEach((nonCoursewareId, index) => {
|
||||
expect(nonCoursewareTopics[index].id).toEqual(nonCoursewareId);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('selectUnits successfully contains all sub sections', async () => {
|
||||
setupMockData().then((state) => {
|
||||
const subSections = state.inContextTopics.coursewareTopics?.map(x => x.children)
|
||||
?.flat()?.map(x => x.children)?.flat();
|
||||
const { units } = state.inContextTopics;
|
||||
|
||||
units.forEach(unit => {
|
||||
const subSection = subSections.find(x => x.id === unit.id);
|
||||
expect(subSection?.id).toEqual(unit.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('successfully stored archived data in redux', async () => {
|
||||
setupMockData().then((state) => {
|
||||
const { archivedTopics } = state.inContextTopics;
|
||||
|
||||
archivedTopics.forEach((archivedTopic, index) => {
|
||||
expect(archivedTopic?.enabledInContext).toEqual(false);
|
||||
expect(archivedTopic?.id).toEqual(`archived-${index + 1}`);
|
||||
expect(archivedTopic?.usageKey).not.toBeNull();
|
||||
expect(archivedTopic?.threadCounts?.discussion).toEqual(1);
|
||||
expect(archivedTopic?.threadCounts?.question).toEqual(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
147
src/discussions/in-context-topics/data/selector.test.jsx
Normal file
147
src/discussions/in-context-topics/data/selector.test.jsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { Factory } from 'rosie';
|
||||
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { initializeMockApp } from '@edx/frontend-platform/testing';
|
||||
|
||||
import { initializeStore } from '../../../store';
|
||||
import { executeThunk } from '../../../test-utils';
|
||||
import { getCourseTopicsApiUrl } from './api';
|
||||
import {
|
||||
selectArchivedTopics,
|
||||
selectCoursewareTopics,
|
||||
selectLoadingStatus,
|
||||
selectNonCoursewareIds,
|
||||
selectNonCoursewareTopics,
|
||||
selectTopics,
|
||||
selectUnits,
|
||||
} from './selectors';
|
||||
import { fetchCourseTopicsV3 } from './thunks';
|
||||
|
||||
import './__factories__';
|
||||
|
||||
const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||
const courseTopicsApiUrl = getCourseTopicsApiUrl();
|
||||
|
||||
let axiosMock;
|
||||
let store;
|
||||
|
||||
describe('In Context Topics Selector test cases', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
Factory.resetAll();
|
||||
store = initializeStore();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
axiosMock.reset();
|
||||
});
|
||||
|
||||
async function setupMockData() {
|
||||
axiosMock.onGet(`${courseTopicsApiUrl}${courseId}`)
|
||||
.reply(200, (Factory.buildList('topic', 1, null, {
|
||||
topicPrefix: 'noncourseware-topic',
|
||||
enabledInContext: true,
|
||||
topicNamePrefix: 'general-topic',
|
||||
usageKey: '',
|
||||
courseware: false,
|
||||
discussionCount: 1,
|
||||
questionCount: 1,
|
||||
}).concat(Factory.buildList('section', 2, null, { topicPrefix: 'courseware' })))
|
||||
.concat(Factory.buildList('archived-topics', 2, null)));
|
||||
await executeThunk(fetchCourseTopicsV3(courseId), store.dispatch, store.getState);
|
||||
|
||||
const state = store.getState();
|
||||
return state;
|
||||
}
|
||||
|
||||
test('should return topics list', async () => {
|
||||
setupMockData().then((state) => {
|
||||
const topics = selectTopics(state);
|
||||
|
||||
expect(topics).not.toBeUndefined();
|
||||
topics.forEach(data => {
|
||||
const topicFunc = jest.fn((topic) => {
|
||||
if (topic.id.includes('noncourseware-topic')) { return true; }
|
||||
if (topic.id.includes('courseware-topic')) { return true; }
|
||||
if (topic.id.includes('archived')) { return true; }
|
||||
return false;
|
||||
});
|
||||
topicFunc(data);
|
||||
expect(topicFunc).toHaveReturnedWith(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('should return courseware topics list', async () => {
|
||||
setupMockData().then((state) => {
|
||||
const coursewareTopics = selectCoursewareTopics(state);
|
||||
|
||||
expect(coursewareTopics).not.toBeUndefined();
|
||||
coursewareTopics.forEach((topic, index) => {
|
||||
expect(topic?.id).toEqual(`courseware-topic-${index + 1}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('should return noncourseware topics list', async () => {
|
||||
setupMockData().then((state) => {
|
||||
const nonCoursewareTopics = selectNonCoursewareTopics(state);
|
||||
|
||||
expect(nonCoursewareTopics).not.toBeUndefined();
|
||||
nonCoursewareTopics.forEach((topic, index) => {
|
||||
expect(topic?.id).toEqual(`noncourseware-topic-${index + 1}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('should return noncourseware ids list', async () => {
|
||||
setupMockData().then((state) => {
|
||||
const nonCoursewareIds = selectNonCoursewareIds(state);
|
||||
|
||||
expect(nonCoursewareIds).not.toBeUndefined();
|
||||
nonCoursewareIds.forEach((id, index) => {
|
||||
expect(id).toEqual(`noncourseware-topic-${index + 1}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('should return units list', async () => {
|
||||
setupMockData().then((state) => {
|
||||
const units = selectUnits(state);
|
||||
|
||||
expect(units).not.toBeUndefined();
|
||||
units.forEach(unit => {
|
||||
expect(unit?.usageKey).not.toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('should return archived topics list', async () => {
|
||||
setupMockData().then((state) => {
|
||||
const archivedTopics = selectArchivedTopics(state);
|
||||
|
||||
expect(archivedTopics).not.toBeUndefined();
|
||||
archivedTopics.forEach((topic, index) => {
|
||||
expect(topic.id).toEqual(`archived-${index + 1}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('should return loading status successful', async () => {
|
||||
setupMockData().then((state) => {
|
||||
const status = selectLoadingStatus(state);
|
||||
|
||||
expect(status).toEqual('successful');
|
||||
});
|
||||
});
|
||||
});
|
||||
67
src/discussions/in-context-topics/data/selectors.js
Normal file
67
src/discussions/in-context-topics/data/selectors.js
Normal file
@@ -0,0 +1,67 @@
|
||||
/* 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)
|
||||
),
|
||||
);
|
||||
52
src/discussions/in-context-topics/data/slices.js
Normal file
52
src/discussions/in-context-topics/data/slices.js
Normal file
@@ -0,0 +1,52 @@
|
||||
/* 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.IDLE,
|
||||
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,
|
||||
fetchCourseTopicsDenied,
|
||||
setFilter,
|
||||
setSortBy,
|
||||
} = topicsSlice.actions;
|
||||
|
||||
export const inContextTopicsReducer = topicsSlice.reducer;
|
||||
71
src/discussions/in-context-topics/data/thunks.js
Normal file
71
src/discussions/in-context-topics/data/thunks.js
Normal file
@@ -0,0 +1,71 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import { reduce } from 'lodash';
|
||||
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
|
||||
import { getHttpErrorStatus } from '../../utils';
|
||||
import { getCourseTopicsV3 } from './api';
|
||||
import {
|
||||
fetchCourseTopicsDenied, 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) {
|
||||
if (getHttpErrorStatus(error) === 403) {
|
||||
dispatch(fetchCourseTopicsDenied());
|
||||
} else {
|
||||
dispatch(fetchCourseTopicsFailed());
|
||||
}
|
||||
logError(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
3
src/discussions/in-context-topics/index.js
Normal file
3
src/discussions/in-context-topics/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export { default as TopicPostsView } from './TopicPostsView';
|
||||
export { default as TopicsView } from './TopicsView';
|
||||
79
src/discussions/in-context-topics/messages.js
Normal file
79
src/discussions/in-context-topics/messages.js
Normal file
@@ -0,0 +1,79 @@
|
||||
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;
|
||||
@@ -0,0 +1,64 @@
|
||||
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);
|
||||
@@ -0,0 +1,30 @@
|
||||
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);
|
||||
3
src/discussions/in-context-topics/topic-search/index.js
Normal file
3
src/discussions/in-context-topics/topic-search/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export { default as TopicSearchBar } from './TopicSearchBar';
|
||||
export { default as TopicSearchResultBar } from './TopicSearchResultBar';
|
||||
@@ -0,0 +1,48 @@
|
||||
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);
|
||||
92
src/discussions/in-context-topics/topic/SectionBaseGroup.jsx
Normal file
92
src/discussions/in-context-topics/topic/SectionBaseGroup.jsx
Normal file
@@ -0,0 +1,92 @@
|
||||
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 TopicStats from '../../../components/TopicStats';
|
||||
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>
|
||||
<TopicStats threadCounts={subsection?.threadCounts} />
|
||||
</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);
|
||||
102
src/discussions/in-context-topics/topic/Topic.jsx
Normal file
102
src/discussions/in-context-topics/topic/Topic.jsx
Normal file
@@ -0,0 +1,102 @@
|
||||
/* 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 TopicStats from '../../../components/TopicStats';
|
||||
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>
|
||||
<TopicStats
|
||||
threadCounts={topic?.threadCounts}
|
||||
activeFlags={topic?.activeFlags}
|
||||
inactiveFlags={topic?.inactiveFlags}
|
||||
/>
|
||||
</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);
|
||||
4
src/discussions/in-context-topics/topic/index.js
Normal file
4
src/discussions/in-context-topics/topic/index.js
Normal file
@@ -0,0 +1,4 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export { default as ArchivedBaseGroup } from './ArchivedBaseGroup';
|
||||
export { default as SectionBaseGroup } from './SectionBaseGroup';
|
||||
export { default as Topic } from './Topic';
|
||||
@@ -1,4 +1,4 @@
|
||||
export * from './comments';
|
||||
export * from './discussions-home';
|
||||
export * from './post-comments';
|
||||
export * from './posts';
|
||||
export * from './topics';
|
||||
|
||||
@@ -88,7 +88,7 @@ function LearnerPostsView({ intl }) {
|
||||
onClick={() => history.push(discussionsPath(Routes.LEARNERS.PATH, { courseId })(location))}
|
||||
alt={intl.formatMessage(messages.back)}
|
||||
/>
|
||||
<div className="text-primary-500 font-style-normal font-family-inter font-weight-bold py-2.5">
|
||||
<div className="text-primary-500 font-style font-weight-bold py-2.5">
|
||||
{intl.formatMessage(messages.activityForLearner, { username: capitalize(username) })}
|
||||
</div>
|
||||
<div style={{ padding: '18px' }} />
|
||||
|
||||
@@ -4,6 +4,8 @@ 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';
|
||||
@@ -39,12 +41,20 @@ 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) {
|
||||
@@ -52,6 +62,7 @@ function LearnerPostFilterBar() {
|
||||
...postFilter,
|
||||
status: value,
|
||||
}));
|
||||
filterContentEventProperties.statusFilter = value;
|
||||
}
|
||||
} else if (name === 'orderBy') {
|
||||
if (postFilter.orderBy !== value) {
|
||||
@@ -59,6 +70,7 @@ function LearnerPostFilterBar() {
|
||||
...postFilter,
|
||||
orderBy: value,
|
||||
}));
|
||||
filterContentEventProperties.sortFilter = value;
|
||||
}
|
||||
} else if (name === 'cohort') {
|
||||
if (postFilter.cohort !== value) {
|
||||
@@ -66,8 +78,10 @@ function LearnerPostFilterBar() {
|
||||
...postFilter,
|
||||
cohort: value,
|
||||
}));
|
||||
filterContentEventProperties.cohortFilter = value;
|
||||
}
|
||||
}
|
||||
sendTrackEvent('edx.forum.filter.content', filterContentEventProperties);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -16,9 +16,9 @@ function LearnerCard({
|
||||
learner,
|
||||
courseId,
|
||||
}) {
|
||||
const { inContext, learnerUsername } = useContext(DiscussionContext);
|
||||
const { enableInContextSidebar, learnerUsername } = useContext(DiscussionContext);
|
||||
const linkUrl = discussionsPath(Routes.LEARNERS.POSTS, {
|
||||
0: inContext ? 'in-context' : undefined,
|
||||
0: enableInContextSidebar ? 'in-context' : undefined,
|
||||
learnerUsername: learner.username,
|
||||
courseId,
|
||||
});
|
||||
@@ -40,7 +40,7 @@ function LearnerCard({
|
||||
<div className="d-flex flex-column justify-content-start mw-100 flex-fill">
|
||||
<div className="d-flex align-items-center flex-fill">
|
||||
<div
|
||||
className="text-truncate font-weight-500 font-size-14 text-primary-500 font-style-normal font-family-inter"
|
||||
className="text-truncate font-weight-500 font-size-14 text-primary-500 font-style"
|
||||
>
|
||||
{learner.username}
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ 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';
|
||||
@@ -58,6 +59,12 @@ function LearnerFilterBar({
|
||||
|
||||
if (name === 'sort') {
|
||||
dispatch(setSortedBy(value));
|
||||
sendTrackEvent(
|
||||
'edx.forum.sort.user',
|
||||
{
|
||||
sort: value,
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -31,6 +31,11 @@ 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',
|
||||
@@ -71,16 +76,11 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Unmark as answered',
|
||||
description: 'Action to unmark a comment as answering a post',
|
||||
},
|
||||
deleteConfirmationCancel: {
|
||||
id: 'discussions.delete.confirmation.button.cancel',
|
||||
confirmationCancel: {
|
||||
id: 'discussions.modal.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: 'Blackout dates are currently active. Posting in discussions is unavailable at this time.',
|
||||
description: 'Informative text when discussions blackout is active',
|
||||
defaultMessage: 'Posting in discussions is temporarily disabled by the course team',
|
||||
description: 'Informative text when discussion posting is disabled',
|
||||
},
|
||||
imageWarningMessage: {
|
||||
id: 'discussions.editor.image.warning.message',
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
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;
|
||||
@@ -1,151 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,3 @@
|
||||
/* 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';
|
||||
|
||||
148
src/discussions/post-comments/PostCommentsView.jsx
Normal file
148
src/discussions/post-comments/PostCommentsView.jsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useParams } from 'react-router';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Button, Icon, IconButton, Spinner,
|
||||
} from '@edx/paragon';
|
||||
import { ArrowBack } from '@edx/paragon/icons';
|
||||
|
||||
import {
|
||||
EndorsementStatus, PostsPages, RequestStatus, ThreadType,
|
||||
} from '../../data/constants';
|
||||
import { useDispatchWithState } from '../../data/hooks';
|
||||
import { DiscussionContext } from '../common/context';
|
||||
import { useIsOnDesktop } from '../data/hooks';
|
||||
import { EmptyPage } from '../empty-posts';
|
||||
import { Post } from '../posts';
|
||||
import { fetchThread } from '../posts/data/thunks';
|
||||
import { discussionsPath } from '../utils';
|
||||
import { ResponseEditor } from './comments/comment';
|
||||
import CommentsSort from './comments/CommentsSort';
|
||||
import CommentsView from './comments/CommentsView';
|
||||
import { useCommentsCount, usePost } from './data/hooks';
|
||||
import { selectCommentsStatus } from './data/selectors';
|
||||
import messages from './messages';
|
||||
|
||||
function PostCommentsView({ intl }) {
|
||||
const [isLoading, submitDispatch] = useDispatchWithState();
|
||||
const { postId } = useParams();
|
||||
const thread = usePost(postId);
|
||||
const commentsStatus = useSelector(selectCommentsStatus);
|
||||
const commentsCount = useCommentsCount(postId);
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
const isOnDesktop = useIsOnDesktop();
|
||||
const [addingResponse, setAddingResponse] = useState(false);
|
||||
const {
|
||||
courseId, learnerUsername, category, topicId, page, enableInContextSidebar,
|
||||
} = useContext(DiscussionContext);
|
||||
|
||||
useEffect(() => {
|
||||
if (!thread) { submitDispatch(fetchThread(postId, courseId, true)); }
|
||||
setAddingResponse(false);
|
||||
}, [postId]);
|
||||
|
||||
if (!thread) {
|
||||
if (!isLoading) {
|
||||
return (
|
||||
<EmptyPage title={intl.formatMessage(messages.noThreadFound)} />
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
}}
|
||||
>
|
||||
<Spinner animation="border" variant="primary" data-testid="loading-indicator" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isOnDesktop && (
|
||||
enableInContextSidebar ? (
|
||||
<>
|
||||
<div className="px-4 py-1.5 bg-white">
|
||||
<Button
|
||||
variant="plain"
|
||||
className="px-0 line-height-24 py-0 my-1.5 border-0 font-weight-normal font-style text-primary-500"
|
||||
iconBefore={ArrowBack}
|
||||
onClick={() => history.push(discussionsPath(PostsPages[page], {
|
||||
courseId, learnerUsername, category, topicId,
|
||||
})(location))}
|
||||
size="sm"
|
||||
>
|
||||
{intl.formatMessage(messages.backAlt)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="border-bottom border-light-400" />
|
||||
</>
|
||||
) : (
|
||||
<IconButton
|
||||
src={ArrowBack}
|
||||
iconAs={Icon}
|
||||
style={{ padding: '18px' }}
|
||||
size="inline"
|
||||
className="ml-4 mt-4"
|
||||
onClick={() => history.push(discussionsPath(PostsPages[page], {
|
||||
courseId, learnerUsername, category, topicId,
|
||||
})(location))}
|
||||
alt={intl.formatMessage(messages.backAlt)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div
|
||||
className="discussion-comments d-flex flex-column card border-0 post-card-margin post-card-padding"
|
||||
>
|
||||
<Post post={thread} handleAddResponseButton={() => setAddingResponse(true)} />
|
||||
{!thread.closed && (
|
||||
<ResponseEditor
|
||||
postId={postId}
|
||||
handleCloseEditor={() => setAddingResponse(false)}
|
||||
addingResponse={addingResponse}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{!!commentsCount && commentsStatus === RequestStatus.SUCCESSFUL && <CommentsSort />}
|
||||
{thread.type === ThreadType.DISCUSSION && (
|
||||
<CommentsView
|
||||
postId={postId}
|
||||
intl={intl}
|
||||
postType={thread.type}
|
||||
endorsed={EndorsementStatus.DISCUSSION}
|
||||
isClosed={thread.closed}
|
||||
/>
|
||||
)}
|
||||
{thread.type === ThreadType.QUESTION && (
|
||||
<>
|
||||
<CommentsView
|
||||
postId={postId}
|
||||
intl={intl}
|
||||
postType={thread.type}
|
||||
endorsed={EndorsementStatus.ENDORSED}
|
||||
isClosed={thread.closed}
|
||||
/>
|
||||
<CommentsView
|
||||
postId={postId}
|
||||
intl={intl}
|
||||
postType={thread.type}
|
||||
endorsed={EndorsementStatus.UNENDORSED}
|
||||
isClosed={thread.closed}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
PostCommentsView.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(PostCommentsView);
|
||||
@@ -19,6 +19,7 @@ import DiscussionContent from '../discussions-home/DiscussionContent';
|
||||
import { getThreadsApiUrl } from '../posts/data/api';
|
||||
import { fetchThreads } from '../posts/data/thunks';
|
||||
import { getCommentsApiUrl } from './data/api';
|
||||
import { removeComment } from './data/thunks';
|
||||
|
||||
import '../posts/data/__factories__';
|
||||
import './data/__factories__';
|
||||
@@ -30,9 +31,11 @@ const discussionPostId = 'thread-1';
|
||||
const questionPostId = 'thread-2';
|
||||
const closedPostId = 'thread-2';
|
||||
const courseId = 'course-v1:edX+TestX+Test_Course';
|
||||
const reverseOrder = false;
|
||||
let store;
|
||||
let axiosMock;
|
||||
let testLocation;
|
||||
let container;
|
||||
|
||||
function mockAxiosReturnPagedComments() {
|
||||
[null, false, true].forEach(endorsed => {
|
||||
@@ -46,6 +49,7 @@ function mockAxiosReturnPagedComments() {
|
||||
page_size: undefined,
|
||||
requested_fields: 'profile_image',
|
||||
endorsed,
|
||||
reverse_order: reverseOrder,
|
||||
},
|
||||
})
|
||||
.reply(200, Factory.build('commentsResult', { can_delete: true }, {
|
||||
@@ -82,7 +86,7 @@ function mockAxiosReturnPagedCommentsResponses() {
|
||||
}
|
||||
|
||||
function renderComponent(postId) {
|
||||
render(
|
||||
const wrapper = render(
|
||||
<IntlProvider locale="en">
|
||||
<AppProvider store={store}>
|
||||
<DiscussionContext.Provider
|
||||
@@ -102,9 +106,10 @@ function renderComponent(postId) {
|
||||
</AppProvider>
|
||||
</IntlProvider>,
|
||||
);
|
||||
container = wrapper.container;
|
||||
}
|
||||
|
||||
describe('CommentsView', () => {
|
||||
describe('ThreadView', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
@@ -162,11 +167,12 @@ describe('CommentsView', () => {
|
||||
|
||||
it('should show and hide the editor', async () => {
|
||||
renderComponent(discussionPostId);
|
||||
await waitFor(() => screen.findByText('comment number 1', { exact: false }));
|
||||
const addResponseButtons = screen.getAllByRole('button', { name: /add a response/i });
|
||||
const post = screen.getByTestId('post-thread-1');
|
||||
const hoverCard = within(post).getByTestId('hover-card-thread-1');
|
||||
const addResponseButton = within(hoverCard).getByRole('button', { name: /Add response/i });
|
||||
await act(async () => {
|
||||
fireEvent.click(
|
||||
addResponseButtons[0],
|
||||
addResponseButton,
|
||||
);
|
||||
});
|
||||
expect(screen.queryByTestId('tinymce-editor')).toBeInTheDocument();
|
||||
@@ -178,11 +184,12 @@ describe('CommentsView', () => {
|
||||
|
||||
it('should allow posting a response', async () => {
|
||||
renderComponent(discussionPostId);
|
||||
await waitFor(() => screen.findByText('comment number 1', { exact: false }));
|
||||
const responseButtons = screen.getAllByRole('button', { name: /add a response/i });
|
||||
const post = await screen.findByTestId('post-thread-1');
|
||||
const hoverCard = within(post).getByTestId('hover-card-thread-1');
|
||||
const addResponseButton = within(hoverCard).getByRole('button', { name: /Add response/i });
|
||||
await act(async () => {
|
||||
fireEvent.click(
|
||||
responseButtons[0],
|
||||
addResponseButton,
|
||||
);
|
||||
});
|
||||
await act(() => {
|
||||
@@ -195,53 +202,51 @@ describe('CommentsView', () => {
|
||||
);
|
||||
});
|
||||
expect(screen.queryByTestId('tinymce-editor')).not.toBeInTheDocument();
|
||||
await waitFor(async () => expect(await screen.findByText('testing123', { exact: false })).toBeInTheDocument());
|
||||
await waitFor(async () => expect(await screen.findByTestId('comment-1')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('should not allow posting a response on a closed post', async () => {
|
||||
renderComponent(closedPostId);
|
||||
await waitFor(() => screen.findByText('Thread-2', { exact: false }));
|
||||
expect(screen.queryByRole('button', { name: /add a response/i })).not.toBeInTheDocument();
|
||||
const post = screen.getByTestId('post-thread-2');
|
||||
const hoverCard = within(post).getByTestId('hover-card-thread-2');
|
||||
expect(within(hoverCard).getByRole('button', { name: /Add response/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should allow posting a comment', async () => {
|
||||
renderComponent(discussionPostId);
|
||||
await waitFor(() => screen.findByText('comment number 1', { exact: false }));
|
||||
const comment = await waitFor(() => screen.findByTestId('comment-comment-1'));
|
||||
const hoverCard = within(comment).getByTestId('hover-card-comment-1');
|
||||
await act(async () => {
|
||||
fireEvent.click(
|
||||
screen.getAllByRole('button', { name: /add a comment/i })[0],
|
||||
within(hoverCard).getByRole('button', { name: /Add comment/i }),
|
||||
);
|
||||
});
|
||||
act(() => {
|
||||
fireEvent.change(screen.getByTestId('tinymce-editor'), { target: { value: 'testing123' } });
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(
|
||||
screen.getByText(/submit/i),
|
||||
);
|
||||
});
|
||||
expect(screen.queryByTestId('tinymce-editor')).not.toBeInTheDocument();
|
||||
await waitFor(async () => expect(await screen.findByText('testing123', { exact: false })).toBeInTheDocument());
|
||||
await waitFor(async () => expect(await screen.findByTestId('reply-comment-7')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('should not allow posting a comment on a closed post', async () => {
|
||||
renderComponent(closedPostId);
|
||||
await waitFor(() => screen.findByText('thread-2', { exact: false }));
|
||||
await act(async () => {
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /add a comment/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
const comment = await waitFor(() => screen.findByTestId('comment-comment-3'));
|
||||
const hoverCard = within(comment).getByTestId('hover-card-comment-3');
|
||||
expect(within(hoverCard).getByRole('button', { name: /Add comment/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should allow editing an existing comment', async () => {
|
||||
renderComponent(discussionPostId);
|
||||
await waitFor(() => screen.findByText('comment number 1', { exact: false }));
|
||||
const comment = await waitFor(() => screen.findByTestId('comment-comment-1'));
|
||||
const hoverCard = within(comment).getByTestId('hover-card-comment-1');
|
||||
await act(async () => {
|
||||
fireEvent.click(
|
||||
// The first edit menu is for the post, the second will be for the first comment.
|
||||
screen.getAllByRole('button', { name: /actions menu/i })[1],
|
||||
within(hoverCard).getByRole('button', { name: /actions menu/i }),
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
@@ -254,7 +259,7 @@ describe('CommentsView', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /submit/i }));
|
||||
});
|
||||
await waitFor(async () => {
|
||||
expect(await screen.findByText('testing123', { exact: false })).toBeInTheDocument();
|
||||
expect(await screen.findByTestId('comment-comment-1')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -278,11 +283,11 @@ describe('CommentsView', () => {
|
||||
it('should show reason codes when editing an existing comment', async () => {
|
||||
setupCourseConfig();
|
||||
renderComponent(discussionPostId);
|
||||
await waitFor(() => screen.findByText('comment number 1', { exact: false }));
|
||||
const comment = await waitFor(() => screen.findByTestId('comment-comment-1'));
|
||||
const hoverCard = within(comment).getByTestId('hover-card-comment-1');
|
||||
await act(async () => {
|
||||
fireEvent.click(
|
||||
// The first edit menu is for the post, the second will be for the first comment.
|
||||
screen.getAllByRole('button', { name: /actions menu/i })[1],
|
||||
within(hoverCard).getByRole('button', { name: /actions menu/i }),
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
@@ -291,10 +296,12 @@ 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' } });
|
||||
@@ -308,12 +315,11 @@ describe('CommentsView', () => {
|
||||
it('should show reason codes when closing a post', async () => {
|
||||
setupCourseConfig();
|
||||
renderComponent(discussionPostId);
|
||||
const post = await screen.findByTestId('post-thread-1');
|
||||
const hoverCard = within(post).getByTestId('hover-card-thread-1');
|
||||
await act(async () => {
|
||||
fireEvent.click(
|
||||
// The first edit menu is for the post
|
||||
screen.getAllByRole('button', {
|
||||
name: /actions menu/i,
|
||||
})[0],
|
||||
within(hoverCard).getByRole('button', { name: /actions menu/i }),
|
||||
);
|
||||
});
|
||||
expect(screen.queryByRole('dialog', { name: /close post/i })).not.toBeInTheDocument();
|
||||
@@ -336,10 +342,11 @@ describe('CommentsView', () => {
|
||||
it('should close the post directly if reason codes are not enabled', async () => {
|
||||
setupCourseConfig(false);
|
||||
renderComponent(discussionPostId);
|
||||
const post = await screen.findByTestId('post-thread-1');
|
||||
const hoverCard = within(post).getByTestId('hover-card-thread-1');
|
||||
await act(async () => {
|
||||
fireEvent.click(
|
||||
// The first edit menu is for the post
|
||||
screen.getAllByRole('button', { name: /actions menu/i })[0],
|
||||
within(hoverCard).getByRole('button', { name: /actions menu/i }),
|
||||
);
|
||||
});
|
||||
expect(screen.queryByRole('dialog', { name: /close post/i })).not.toBeInTheDocument();
|
||||
@@ -355,10 +362,11 @@ describe('CommentsView', () => {
|
||||
async (reasonCodesEnabled) => {
|
||||
setupCourseConfig(reasonCodesEnabled);
|
||||
renderComponent(closedPostId);
|
||||
const post = screen.getByTestId('post-thread-2');
|
||||
const hoverCard = within(post).getByTestId('hover-card-thread-2');
|
||||
await act(async () => {
|
||||
fireEvent.click(
|
||||
// The first edit menu is for the post
|
||||
screen.getAllByRole('button', { name: /actions menu/i })[0],
|
||||
within(hoverCard).getByRole('button', { name: /actions menu/i }),
|
||||
);
|
||||
});
|
||||
expect(screen.queryByRole('dialog', { name: /close post/i })).not.toBeInTheDocument();
|
||||
@@ -373,10 +381,11 @@ describe('CommentsView', () => {
|
||||
it('should show the editor if the post is edited', async () => {
|
||||
setupCourseConfig(false);
|
||||
renderComponent(discussionPostId);
|
||||
const post = await screen.findByTestId('post-thread-1');
|
||||
const hoverCard = within(post).getByTestId('hover-card-thread-1');
|
||||
await act(async () => {
|
||||
fireEvent.click(
|
||||
// The first edit menu is for the post
|
||||
screen.getAllByRole('button', { name: /actions menu/i })[0],
|
||||
within(hoverCard).getByRole('button', { name: /actions menu/i }),
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
@@ -384,12 +393,14 @@ describe('CommentsView', () => {
|
||||
});
|
||||
expect(testLocation.pathname).toBe(`/${courseId}/posts/${discussionPostId}/edit`);
|
||||
});
|
||||
|
||||
it('should allow pinning the post', async () => {
|
||||
renderComponent(discussionPostId);
|
||||
const post = await screen.findByTestId('post-thread-1');
|
||||
const hoverCard = within(post).getByTestId('hover-card-thread-1');
|
||||
await act(async () => {
|
||||
fireEvent.click(
|
||||
// The first edit menu is for the post
|
||||
screen.getAllByRole('button', { name: /actions menu/i })[0],
|
||||
within(hoverCard).getByRole('button', { name: /actions menu/i }),
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
@@ -397,55 +408,88 @@ describe('CommentsView', () => {
|
||||
});
|
||||
assertLastUpdateData({ pinned: false });
|
||||
});
|
||||
|
||||
it('should allow reporting the post', async () => {
|
||||
renderComponent(discussionPostId);
|
||||
const post = await screen.findByTestId('post-thread-1');
|
||||
const hoverCard = within(post).getByTestId('hover-card-thread-1');
|
||||
await act(async () => {
|
||||
fireEvent.click(
|
||||
// The first edit menu is for the post
|
||||
screen.getAllByRole('button', { name: /actions menu/i })[0],
|
||||
within(hoverCard).getByRole('button', { name: /actions menu/i }),
|
||||
);
|
||||
});
|
||||
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 });
|
||||
});
|
||||
|
||||
it('handles liking a comment', async () => {
|
||||
it('handles liking a post', async () => {
|
||||
renderComponent(discussionPostId);
|
||||
|
||||
// Wait for the content to load
|
||||
await screen.findByText('comment number 7', { exact: false });
|
||||
const view = screen.getByTestId('comment-comment-1');
|
||||
|
||||
const likeButton = within(view).getByRole('button', { name: /like/i });
|
||||
const post = await screen.findByTestId('post-thread-1');
|
||||
const hoverCard = within(post).getByTestId('hover-card-thread-1');
|
||||
await act(async () => {
|
||||
fireEvent.click(likeButton);
|
||||
fireEvent.click(
|
||||
within(hoverCard).getByRole('button', { name: /like/i }),
|
||||
);
|
||||
});
|
||||
expect(axiosMock.history.patch).toHaveLength(2);
|
||||
expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject({ voted: true });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['endorsing comments', 'Endorse', { endorsed: true }],
|
||||
['reporting comments', 'Report', { abuse_flagged: true }],
|
||||
])('handles %s', async (label, buttonLabel, patchData) => {
|
||||
it('handles liking a comment', async () => {
|
||||
renderComponent(discussionPostId);
|
||||
|
||||
// Wait for the content to load
|
||||
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 });
|
||||
const comment = await waitFor(() => screen.findByTestId('comment-comment-1'));
|
||||
const hoverCard = within(comment).getByTestId('hover-card-comment-1');
|
||||
await act(async () => {
|
||||
fireEvent.click(actionButtons[1]);
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: buttonLabel }));
|
||||
fireEvent.click(
|
||||
within(hoverCard).getByRole('button', { name: /like/i }),
|
||||
);
|
||||
});
|
||||
expect(axiosMock.history.patch).toHaveLength(2);
|
||||
expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject(patchData);
|
||||
expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject({ voted: true });
|
||||
});
|
||||
|
||||
it('handles endorsing comments', async () => {
|
||||
renderComponent(discussionPostId);
|
||||
// Wait for the content to load
|
||||
const comment = await waitFor(() => screen.findByTestId('comment-comment-1'));
|
||||
const hoverCard = within(comment).getByTestId('hover-card-comment-1');
|
||||
await act(async () => {
|
||||
fireEvent.click(within(hoverCard).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
|
||||
const comment = await waitFor(() => screen.findByTestId('comment-comment-1'));
|
||||
const hoverCard = within(comment).getByTestId('hover-card-comment-1');
|
||||
await act(async () => {
|
||||
fireEvent.click(
|
||||
within(hoverCard).getByRole('button', { name: /actions menu/i }),
|
||||
);
|
||||
});
|
||||
|
||||
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();
|
||||
expect(axiosMock.history.patch).toHaveLength(2);
|
||||
expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject({ abuse_flagged: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -460,9 +504,9 @@ describe('CommentsView', () => {
|
||||
|
||||
it('initially loads only the first page', async () => {
|
||||
renderComponent(discussionPostId);
|
||||
expect(await screen.findByText('comment number 1', { exact: false }))
|
||||
expect(await screen.findByTestId('comment-comment-1'))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.queryByText('comment number 2', { exact: false }))
|
||||
expect(screen.queryByTestId('comment-comment-2'))
|
||||
.not
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
@@ -473,8 +517,8 @@ describe('CommentsView', () => {
|
||||
const loadMoreButton = await findLoadMoreCommentsButton();
|
||||
fireEvent.click(loadMoreButton);
|
||||
|
||||
await screen.findByText('comment number 1', { exact: false });
|
||||
await screen.findByText('comment number 2', { exact: false });
|
||||
await screen.findByTestId('comment-comment-1');
|
||||
await screen.findByTestId('comment-comment-2');
|
||||
});
|
||||
|
||||
it('newly loaded comments are appended to the old ones', async () => {
|
||||
@@ -483,9 +527,9 @@ describe('CommentsView', () => {
|
||||
const loadMoreButton = await findLoadMoreCommentsButton();
|
||||
fireEvent.click(loadMoreButton);
|
||||
|
||||
await screen.findByText('comment number 1', { exact: false });
|
||||
await screen.findByTestId('comment-comment-1');
|
||||
// check that comments from the first page are also displayed
|
||||
expect(screen.queryByText('comment number 2', { exact: false }))
|
||||
expect(screen.queryByTestId('comment-comment-2'))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -498,7 +542,7 @@ describe('CommentsView', () => {
|
||||
fireEvent.click(loadMoreButton);
|
||||
}
|
||||
|
||||
await screen.findByText('comment number 2', { exact: false });
|
||||
await screen.findByTestId('comment-comment-2');
|
||||
await expect(findLoadMoreCommentsButton())
|
||||
.rejects
|
||||
.toThrow();
|
||||
@@ -510,11 +554,11 @@ describe('CommentsView', () => {
|
||||
|
||||
it('initially loads only the first page', async () => {
|
||||
act(() => renderComponent(questionPostId));
|
||||
expect(await screen.findByText('comment number 3', { exact: false }))
|
||||
expect(await screen.findByTestId('comment-comment-3'))
|
||||
.toBeInTheDocument();
|
||||
expect(await screen.findByText('endorsed comment number 5', { exact: false }))
|
||||
expect(await screen.findByTestId('comment-comment-5'))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.queryByText('comment number 4', { exact: false }))
|
||||
expect(screen.queryByTestId('comment-comment-4'))
|
||||
.not
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
@@ -527,15 +571,15 @@ describe('CommentsView', () => {
|
||||
const [loadMoreButtonEndorsed, loadMoreButtonUnendorsed] = await findLoadMoreCommentsButtons();
|
||||
// Both load more buttons should show
|
||||
expect(await findLoadMoreCommentsButtons()).toHaveLength(2);
|
||||
expect(await screen.findByText('unendorsed comment number 3', { exact: false }))
|
||||
expect(await screen.findByTestId('comment-comment-3'))
|
||||
.toBeInTheDocument();
|
||||
expect(await screen.findByText('endorsed comment number 5', { exact: false }))
|
||||
expect(await screen.findByTestId('comment-comment-5'))
|
||||
.toBeInTheDocument();
|
||||
// Comments from next page should not be loaded yet.
|
||||
expect(await screen.queryByText('endorsed comment number 6', { exact: false }))
|
||||
expect(await screen.queryByTestId('comment-comment-6'))
|
||||
.not
|
||||
.toBeInTheDocument();
|
||||
expect(await screen.queryByText('unendorsed comment number 4', { exact: false }))
|
||||
expect(await screen.queryByTestId('comment-comment-4'))
|
||||
.not
|
||||
.toBeInTheDocument();
|
||||
|
||||
@@ -543,10 +587,10 @@ describe('CommentsView', () => {
|
||||
fireEvent.click(loadMoreButtonEndorsed);
|
||||
});
|
||||
// Endorsed comment from next page should be loaded now.
|
||||
await waitFor(() => expect(screen.queryByText('endorsed comment number 6', { exact: false }))
|
||||
await waitFor(() => expect(screen.queryByTestId('comment-comment-6'))
|
||||
.toBeInTheDocument());
|
||||
// Unendorsed comment from next page should not be loaded yet.
|
||||
expect(await screen.queryByText('unendorsed comment number 4', { exact: false }))
|
||||
expect(await screen.queryByTestId('comment-comment-4'))
|
||||
.not
|
||||
.toBeInTheDocument();
|
||||
// Now only one load more buttons should show, for unendorsed comments
|
||||
@@ -555,20 +599,20 @@ describe('CommentsView', () => {
|
||||
fireEvent.click(loadMoreButtonUnendorsed);
|
||||
});
|
||||
// Unendorsed comment from next page should be loaded now.
|
||||
await waitFor(() => expect(screen.queryByText('unendorsed comment number 4', { exact: false }))
|
||||
await waitFor(() => expect(screen.queryByTestId('comment-comment-4'))
|
||||
.toBeInTheDocument());
|
||||
await expect(findLoadMoreCommentsButtons()).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('comments responses', () => {
|
||||
describe('for comments replies', () => {
|
||||
const findLoadMoreCommentsResponsesButton = () => screen.findByTestId('load-more-comments-responses');
|
||||
|
||||
it('initially loads only the first page', async () => {
|
||||
renderComponent(discussionPostId);
|
||||
|
||||
await waitFor(() => screen.findByText('comment number 7', { exact: false }));
|
||||
expect(screen.queryByText('comment number 8', { exact: false })).not.toBeInTheDocument();
|
||||
await waitFor(() => screen.findByTestId('reply-comment-7'));
|
||||
expect(screen.queryByTestId('reply-comment-8')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('pressing load more button will load next page of responses', async () => {
|
||||
@@ -579,7 +623,7 @@ describe('CommentsView', () => {
|
||||
fireEvent.click(loadMoreButton);
|
||||
});
|
||||
|
||||
await screen.findByText('comment number 8', { exact: false });
|
||||
await screen.findByTestId('reply-comment-8');
|
||||
});
|
||||
|
||||
it('newly loaded responses are appended to the old ones', async () => {
|
||||
@@ -590,9 +634,9 @@ describe('CommentsView', () => {
|
||||
fireEvent.click(loadMoreButton);
|
||||
});
|
||||
|
||||
await screen.findByText('comment number 8', { exact: false });
|
||||
await screen.findByTestId('reply-comment-8');
|
||||
// check that comments from the first page are also displayed
|
||||
expect(screen.queryByText('comment number 7', { exact: false })).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('reply-comment-7')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('load more button is hidden when no more responses pages to load', async () => {
|
||||
@@ -606,77 +650,116 @@ describe('CommentsView', () => {
|
||||
});
|
||||
}
|
||||
|
||||
await screen.findByText('comment number 8', { exact: false });
|
||||
await screen.findByTestId('reply-comment-8');
|
||||
await expect(findLoadMoreCommentsResponsesButton())
|
||||
.rejects
|
||||
.toThrow();
|
||||
});
|
||||
|
||||
it('handles liking a comment', async () => {
|
||||
renderComponent(discussionPostId);
|
||||
|
||||
// Wait for the content to load
|
||||
await screen.findByText('comment number 7', { exact: false });
|
||||
const view = screen.getByTestId('comment-comment-1');
|
||||
|
||||
const likeButton = within(view).getByRole('button', { name: /like/i });
|
||||
await act(async () => {
|
||||
fireEvent.click(likeButton);
|
||||
});
|
||||
expect(axiosMock.history.patch).toHaveLength(2);
|
||||
expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject({ voted: true });
|
||||
});
|
||||
|
||||
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 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[1]);
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: buttonLabel }));
|
||||
});
|
||||
expect(axiosMock.history.patch).toHaveLength(2);
|
||||
expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject(patchData);
|
||||
});
|
||||
});
|
||||
|
||||
describe.each([
|
||||
{ component: 'post', testId: 'post-thread-1' },
|
||||
{ component: 'comment', testId: 'comment-comment-1' },
|
||||
{ component: 'reply', testId: 'reply-comment-7' },
|
||||
{ component: 'post', testId: 'post-thread-1', cardId: 'hover-card-thread-1' },
|
||||
{ component: 'comment', testId: 'comment-comment-1', cardId: 'hover-card-comment-1' },
|
||||
])('delete confirmation modal', ({
|
||||
component,
|
||||
testId,
|
||||
cardId,
|
||||
}) => {
|
||||
test(`for ${component}`, async () => {
|
||||
renderComponent(discussionPostId);
|
||||
// Wait for the content to load
|
||||
await waitFor(() => expect(screen.queryByText('comment number 7', { exact: false })).toBeInTheDocument());
|
||||
const content = screen.getByTestId(testId);
|
||||
const actionsButton = within(content).getAllByRole('button', { name: /actions menu/i })[0];
|
||||
const post = await screen.findByTestId(testId);
|
||||
const hoverCard = within(post).getByTestId(cardId);
|
||||
expect(screen.queryByRole('dialog', { name: /Delete response/i, exact: false })).not.toBeInTheDocument();
|
||||
await act(async () => {
|
||||
fireEvent.click(actionsButton);
|
||||
fireEvent.click(
|
||||
within(hoverCard).getByRole('button', { name: /actions menu/i }),
|
||||
);
|
||||
});
|
||||
expect(screen.queryByRole('dialog', { name: /delete \w+/i, exact: false })).not.toBeInTheDocument();
|
||||
const deleteButton = within(content).queryByRole('button', { name: /delete/i });
|
||||
await act(async () => {
|
||||
fireEvent.click(deleteButton);
|
||||
fireEvent.click(screen.queryByRole('button', { name: /Delete/i }));
|
||||
});
|
||||
expect(screen.queryByRole('dialog', { name: /delete \w+/i, exact: false })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('dialog', { name: /Delete/i, exact: false })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('for comments replies', () => {
|
||||
it('shows delete confirmation modal', async () => {
|
||||
renderComponent(discussionPostId);
|
||||
const reply = await waitFor(() => screen.findByTestId('reply-comment-7'));
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.queryByRole('button', { name: /delete/i }));
|
||||
fireEvent.click(
|
||||
within(reply).getByRole('button', { name: /actions menu/i }),
|
||||
);
|
||||
});
|
||||
expect(screen.queryByRole('dialog', { name: /delete \w+/i, exact: false })).not.toBeInTheDocument();
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.queryByRole('button', { name: /Delete/i }));
|
||||
});
|
||||
expect(screen.queryByRole('dialog', { name: /Delete/i, exact: false })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('for comments sort', () => {
|
||||
it('should show sort dropdown if there are endorse or unendorsed comments', async () => {
|
||||
renderComponent(discussionPostId);
|
||||
|
||||
const comment = await waitFor(() => screen.findByTestId('comment-comment-1'));
|
||||
const sortWrapper = container.querySelector('.comments-sort');
|
||||
const sortDropDown = within(sortWrapper).getByRole('button', { name: /Oldest first/i });
|
||||
|
||||
expect(comment).toBeInTheDocument();
|
||||
expect(sortDropDown).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show sort dropdown if there is no response', async () => {
|
||||
const commentId = 'comment-1';
|
||||
renderComponent(discussionPostId);
|
||||
|
||||
await waitFor(() => screen.findByTestId('comment-comment-1'));
|
||||
axiosMock.onDelete(`${commentsApiUrl}${commentId}/`).reply(201);
|
||||
await executeThunk(removeComment(commentId, discussionPostId), store.dispatch, store.getState);
|
||||
|
||||
expect(await waitFor(() => screen.findByText('No responses', { exact: true }))).toBeInTheDocument();
|
||||
expect(container.querySelector('.comments-sort')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have only two options', async () => {
|
||||
renderComponent(discussionPostId);
|
||||
|
||||
await waitFor(() => screen.findByTestId('comment-comment-1'));
|
||||
await act(async () => { fireEvent.click(screen.getByRole('button', { name: /Oldest first/i })); });
|
||||
const dropdown = await waitFor(() => screen.findByTestId('comment-sort-dropdown-modal-popup'));
|
||||
|
||||
expect(dropdown).toBeInTheDocument();
|
||||
expect(await within(dropdown).getAllByRole('button')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should be selected Oldest first and auto focus', async () => {
|
||||
renderComponent(discussionPostId);
|
||||
|
||||
await waitFor(() => screen.findByTestId('comment-comment-1'));
|
||||
await act(async () => { fireEvent.click(screen.getByRole('button', { name: /Oldest first/i })); });
|
||||
const dropdown = await waitFor(() => screen.findByTestId('comment-sort-dropdown-modal-popup'));
|
||||
|
||||
expect(dropdown).toBeInTheDocument();
|
||||
expect(within(dropdown).getByRole('button', { name: /Oldest first/i })).toBeInTheDocument();
|
||||
expect(within(dropdown).getByRole('button', { name: /Oldest first/i })).toHaveFocus();
|
||||
expect(within(dropdown).getByRole('button', { name: /Newest first/i })).not.toHaveFocus();
|
||||
});
|
||||
|
||||
test('successfully handles sort state update', async () => {
|
||||
renderComponent(discussionPostId);
|
||||
|
||||
expect(store.getState().comments.sortOrder).toBeFalsy();
|
||||
|
||||
await waitFor(() => screen.findByTestId('comment-comment-1'));
|
||||
await act(async () => { fireEvent.click(screen.getByRole('button', { name: /Oldest first/i })); });
|
||||
const dropdown = await waitFor(() => screen.findByTestId('comment-sort-dropdown-modal-popup'));
|
||||
await act(async () => {
|
||||
fireEvent.click(within(dropdown).getByRole('button', { name: /Newest first/i }));
|
||||
});
|
||||
|
||||
expect(store.getState().comments.sortOrder).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
92
src/discussions/post-comments/comments/CommentsSort.jsx
Normal file
92
src/discussions/post-comments/comments/CommentsSort.jsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Button, Dropdown, ModalPopup, useToggle,
|
||||
} from '@edx/paragon';
|
||||
import {
|
||||
ExpandLess, ExpandMore,
|
||||
} from '@edx/paragon/icons';
|
||||
|
||||
import { selectCommentSortOrder } from '../data/selectors';
|
||||
import { setCommentSortOrder } from '../data/slices';
|
||||
import messages from '../messages';
|
||||
|
||||
function CommentSortDropdown({
|
||||
intl,
|
||||
}) {
|
||||
const dispatch = useDispatch();
|
||||
const sortedOrder = useSelector(selectCommentSortOrder);
|
||||
const [isOpen, open, close] = useToggle(false);
|
||||
const [target, setTarget] = useState(null);
|
||||
|
||||
const handleActions = (reverseOrder) => {
|
||||
close();
|
||||
dispatch(setCommentSortOrder(reverseOrder));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="comments-sort d-flex justify-content-end mx-4 mt-2">
|
||||
<Button
|
||||
alt={intl.formatMessage(messages.actionsAlt)}
|
||||
ref={setTarget}
|
||||
variant="tertiary"
|
||||
onClick={open}
|
||||
size="sm"
|
||||
iconAfter={isOpen ? ExpandLess : ExpandMore}
|
||||
>
|
||||
{intl.formatMessage(messages.commentSort, {
|
||||
sort: sortedOrder,
|
||||
})}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="actions-dropdown">
|
||||
<ModalPopup
|
||||
onClose={close}
|
||||
positionRef={target}
|
||||
isOpen={isOpen}
|
||||
>
|
||||
<div
|
||||
className="bg-white p-1 shadow d-flex flex-column"
|
||||
data-testid="comment-sort-dropdown-modal-popup"
|
||||
>
|
||||
<Dropdown.Item
|
||||
className="d-flex justify-content-start py-1.5 mb-1"
|
||||
as={Button}
|
||||
variant="tertiary"
|
||||
size="inline"
|
||||
onClick={() => handleActions(false)}
|
||||
autoFocus={sortedOrder === false}
|
||||
>
|
||||
{intl.formatMessage(messages.commentSort, {
|
||||
sort: false,
|
||||
})}
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item
|
||||
className="d-flex justify-content-start py-1.5"
|
||||
as={Button}
|
||||
variant="tertiary"
|
||||
size="inline"
|
||||
onClick={() => handleActions(true)}
|
||||
autoFocus={sortedOrder === true}
|
||||
>
|
||||
{intl.formatMessage(messages.commentSort, {
|
||||
sort: true,
|
||||
})}
|
||||
</Dropdown.Item>
|
||||
</div>
|
||||
</ModalPopup>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
CommentSortDropdown.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
|
||||
};
|
||||
|
||||
export default injectIntl(CommentSortDropdown);
|
||||
130
src/discussions/post-comments/comments/CommentsView.jsx
Normal file
130
src/discussions/post-comments/comments/CommentsView.jsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Button, Spinner } from '@edx/paragon';
|
||||
|
||||
import { EndorsementStatus } from '../../../data/constants';
|
||||
import { useUserCanAddThreadInBlackoutDate } from '../../data/hooks';
|
||||
import { filterPosts, isLastElementOfList } from '../../utils';
|
||||
import { usePostComments } from '../data/hooks';
|
||||
import messages from '../messages';
|
||||
import { Comment, ResponseEditor } from './comment';
|
||||
|
||||
function CommentsView({
|
||||
postType,
|
||||
postId,
|
||||
intl,
|
||||
endorsed,
|
||||
isClosed,
|
||||
}) {
|
||||
const {
|
||||
comments,
|
||||
hasMorePages,
|
||||
isLoading,
|
||||
handleLoadMoreResponses,
|
||||
} = usePostComments(postId, endorsed);
|
||||
|
||||
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"
|
||||
role="heading"
|
||||
aria-level="2"
|
||||
>
|
||||
{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 border-0 line-height-24 py-0 mb-2 font-style font-weight-500 font-size-14"
|
||||
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 border-0 py-10px mt-2 font-style font-weight-500
|
||||
line-height-24 font-size-14 text-primary-500"
|
||||
onClick={() => setAddingResponse(true)}
|
||||
data-testid="add-response"
|
||||
>
|
||||
{intl.formatMessage(messages.addResponse)}
|
||||
</Button>
|
||||
)}
|
||||
<ResponseEditor
|
||||
postId={postId}
|
||||
handleCloseEditor={() => setAddingResponse(false)}
|
||||
addWrappingDiv
|
||||
addingResponse={addingResponse}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
CommentsView.propTypes = {
|
||||
postId: PropTypes.string.isRequired,
|
||||
postType: PropTypes.string.isRequired,
|
||||
isClosed: PropTypes.bool.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
endorsed: PropTypes.oneOf([
|
||||
EndorsementStatus.ENDORSED, EndorsementStatus.UNENDORSED, EndorsementStatus.DISCUSSION,
|
||||
]).isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(CommentsView);
|
||||
225
src/discussions/post-comments/comments/comment/Comment.jsx
Normal file
225
src/discussions/post-comments/comments/comment/Comment.jsx
Normal file
@@ -0,0 +1,225 @@
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
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 { DiscussionContext } from '../../../common/context';
|
||||
import HoverCard from '../../../common/HoverCard';
|
||||
import { useUserCanAddThreadInBlackoutDate } from '../../../data/hooks';
|
||||
import { fetchThread } from '../../../posts/data/thunks';
|
||||
import LikeButton from '../../../posts/post/LikeButton';
|
||||
import { useActions } from '../../../utils';
|
||||
import { selectCommentCurrentPage, selectCommentHasMorePages, selectCommentResponses } from '../../data/selectors';
|
||||
import { editComment, fetchCommentResponses, removeComment } from '../../data/thunks';
|
||||
import messages from '../../messages';
|
||||
import CommentEditor from './CommentEditor';
|
||||
import CommentHeader from './CommentHeader';
|
||||
import { commentShape } from './proptypes';
|
||||
import Reply from './Reply';
|
||||
|
||||
function Comment({
|
||||
postType,
|
||||
comment,
|
||||
showFullThread = true,
|
||||
isClosedPost,
|
||||
intl,
|
||||
marginBottom,
|
||||
}) {
|
||||
const dispatch = useDispatch();
|
||||
const hasChildren = comment.childCount > 0;
|
||||
const isNested = Boolean(comment.parentId);
|
||||
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 {
|
||||
courseId,
|
||||
} = useContext(DiscussionContext);
|
||||
useEffect(() => {
|
||||
// If the comment has a parent comment, it won't have any children, so don't fetch them.
|
||||
if (hasChildren && !currentPage && showFullThread) {
|
||||
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),
|
||||
[ContentActions.ENDORSE]: async () => {
|
||||
await dispatch(editComment(comment.id, { endorsed: !comment.endorsed }, ContentActions.ENDORSE));
|
||||
await dispatch(fetchThread(comment.threadId, courseId));
|
||||
},
|
||||
[ContentActions.DELETE]: showDeleteConfirmation,
|
||||
[ContentActions.REPORT]: () => handleAbusedFlag(),
|
||||
};
|
||||
|
||||
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
|
||||
isOpen={isDeleting}
|
||||
title={intl.formatMessage(messages.deleteResponseTitle)}
|
||||
description={intl.formatMessage(messages.deleteResponseDescription)}
|
||||
onClose={hideDeleteConfirmation}
|
||||
comfirmAction={handleDeleteConfirmation}
|
||||
closeButtonVaraint="tertiary"
|
||||
confirmButtonText={intl.formatMessage(messages.deleteConfirmationDelete)}
|
||||
/>
|
||||
{!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}>
|
||||
<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}
|
||||
/>
|
||||
<AlertBanner content={comment} />
|
||||
<CommentHeader comment={comment} />
|
||||
{isEditing
|
||||
? (
|
||||
<CommentEditor comment={comment} onCloseEditor={() => setEditing(false)} formClasses="pt-3" />
|
||||
)
|
||||
: (
|
||||
<HTMLLoader
|
||||
cssClassName="comment-body html-loader text-break mt-14px font-style text-primary-500"
|
||||
componentId="comment"
|
||||
htmlNode={comment.renderedBody}
|
||||
testId={comment.id}
|
||||
/>
|
||||
)}
|
||||
{comment.voted && (
|
||||
<div className="ml-n1.5 mt-10px">
|
||||
<LikeButton
|
||||
count={comment.voteCount}
|
||||
onClick={() => dispatch(editComment(comment.id, { voted: !comment.voted }))}
|
||||
voted={comment.voted}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
{hasMorePages && (
|
||||
<Button
|
||||
onClick={handleLoadMoreComments}
|
||||
variant="link"
|
||||
block="true"
|
||||
className="font-size-14 line-height-24 font-style pt-10px border-0 font-weight-500 pb-0"
|
||||
data-testid="load-more-comments-responses"
|
||||
>
|
||||
{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>
|
||||
) : (
|
||||
<>
|
||||
{!isClosedPost && userCanAddThreadInBlackoutDate && (inlineReplies.length >= 5)
|
||||
&& (
|
||||
<Button
|
||||
className="d-flex flex-grow mt-2 font-size-14 font-style font-weight-500 text-primary-500"
|
||||
variant="plain"
|
||||
style={{ height: '36px' }}
|
||||
onClick={() => setReplying(true)}
|
||||
>
|
||||
{intl.formatMessage(messages.addComment)}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Comment.propTypes = {
|
||||
postType: PropTypes.oneOf(['discussion', 'question']).isRequired,
|
||||
comment: commentShape.isRequired,
|
||||
showFullThread: PropTypes.bool,
|
||||
isClosedPost: PropTypes.bool,
|
||||
intl: intlShape.isRequired,
|
||||
marginBottom: PropTypes.bool,
|
||||
};
|
||||
|
||||
Comment.defaultProps = {
|
||||
showFullThread: true,
|
||||
isClosedPost: false,
|
||||
marginBottom: true,
|
||||
};
|
||||
|
||||
export default injectIntl(Comment);
|
||||
@@ -9,19 +9,19 @@ import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { AppContext } from '@edx/frontend-platform/react';
|
||||
import { Button, Form, StatefulButton } from '@edx/paragon';
|
||||
|
||||
import { TinyMCEEditor } from '../../../components';
|
||||
import FormikErrorFeedback from '../../../components/FormikErrorFeedback';
|
||||
import PostPreviewPane from '../../../components/PostPreviewPane';
|
||||
import { useDispatchWithState } from '../../../data/hooks';
|
||||
import { TinyMCEEditor } from '../../../../components';
|
||||
import FormikErrorFeedback from '../../../../components/FormikErrorFeedback';
|
||||
import PostPreviewPane from '../../../../components/PostPreviewPane';
|
||||
import { useDispatchWithState } from '../../../../data/hooks';
|
||||
import {
|
||||
selectModerationSettings,
|
||||
selectUserHasModerationPrivileges,
|
||||
selectUserIsGroupTa,
|
||||
selectUserIsStaff,
|
||||
} from '../../data/selectors';
|
||||
import { formikCompatibleHandler, isFormikFieldInvalid } from '../../utils';
|
||||
import { addComment, editComment } from '../data/thunks';
|
||||
import messages from '../messages';
|
||||
} from '../../../data/selectors';
|
||||
import { formikCompatibleHandler, isFormikFieldInvalid } from '../../../utils';
|
||||
import { addComment, editComment } from '../../data/thunks';
|
||||
import messages from '../../messages';
|
||||
|
||||
function CommentEditor({
|
||||
intl,
|
||||
@@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { injectIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Avatar } from '@edx/paragon';
|
||||
|
||||
import { AvatarOutlineAndLabelColors } from '../../../../data/constants';
|
||||
import { AuthorLabel } from '../../../common';
|
||||
import { useAlertBannerVisible } from '../../../data/hooks';
|
||||
import { selectAuthorAvatars } from '../../../posts/data/selectors';
|
||||
import { commentShape } from './proptypes';
|
||||
|
||||
function CommentHeader({
|
||||
comment,
|
||||
}) {
|
||||
const authorAvatars = useSelector(selectAuthorAvatars(comment.author));
|
||||
const colorClass = AvatarOutlineAndLabelColors[comment.authorLabel];
|
||||
const hasAnyAlert = useAlertBannerVisible(comment);
|
||||
|
||||
return (
|
||||
<div className={classNames('d-flex flex-row justify-content-between', {
|
||||
'mt-2': hasAnyAlert,
|
||||
})}
|
||||
>
|
||||
<div className="align-items-center d-flex flex-row">
|
||||
<Avatar
|
||||
className={`border-0 ml-0.5 mr-2.5 ${colorClass ? `outline-${colorClass}` : 'outline-anonymous'}`}
|
||||
alt={comment.author}
|
||||
src={authorAvatars?.imageUrlSmall}
|
||||
style={{
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
}}
|
||||
/>
|
||||
<AuthorLabel
|
||||
author={comment.author}
|
||||
authorLabel={comment.authorLabel}
|
||||
labelColor={colorClass && `text-${colorClass}`}
|
||||
linkToProfile
|
||||
postCreatedAt={comment.createdAt}
|
||||
postOrComment
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
CommentHeader.propTypes = {
|
||||
comment: commentShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(CommentHeader);
|
||||
154
src/discussions/post-comments/comments/comment/Reply.jsx
Normal file
154
src/discussions/post-comments/comments/comment/Reply.jsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import React, { useState } 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 { Avatar, useToggle } from '@edx/paragon';
|
||||
|
||||
import HTMLLoader from '../../../../components/HTMLLoader';
|
||||
import { AvatarOutlineAndLabelColors, ContentActions } from '../../../../data/constants';
|
||||
import {
|
||||
ActionsDropdown, AlertBanner, AuthorLabel, Confirmation,
|
||||
} from '../../../common';
|
||||
import timeLocale from '../../../common/time-locale';
|
||||
import { useAlertBannerVisible } from '../../../data/hooks';
|
||||
import { selectAuthorAvatars } from '../../../posts/data/selectors';
|
||||
import { editComment, removeComment } from '../../data/thunks';
|
||||
import messages from '../../messages';
|
||||
import CommentEditor from './CommentEditor';
|
||||
import { commentShape } from './proptypes';
|
||||
|
||||
function Reply({
|
||||
reply,
|
||||
postType,
|
||||
intl,
|
||||
}) {
|
||||
timeago.register('time-locale', timeLocale);
|
||||
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(
|
||||
reply.id,
|
||||
{ endorsed: !reply.endorsed },
|
||||
ContentActions.ENDORSE,
|
||||
)),
|
||||
[ContentActions.DELETE]: showDeleteConfirmation,
|
||||
[ContentActions.REPORT]: () => handleAbusedFlag(),
|
||||
};
|
||||
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
|
||||
isOpen={isDeleting}
|
||||
title={intl.formatMessage(messages.deleteCommentTitle)}
|
||||
description={intl.formatMessage(messages.deleteCommentDescription)}
|
||||
onClose={hideDeleteConfirmation}
|
||||
comfirmAction={handleDeleteConfirmation}
|
||||
closeButtonVaraint="tertiary"
|
||||
confirmButtonText={intl.formatMessage(messages.deleteConfirmationDelete)}
|
||||
/>
|
||||
{!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">
|
||||
<Avatar />
|
||||
</div>
|
||||
<div className="w-100">
|
||||
<AlertBanner content={reply} intl={intl} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="d-flex">
|
||||
<div className="d-flex mr-3 mt-2.5">
|
||||
<Avatar
|
||||
className={`ml-0.5 mt-0.5 border-0 ${colorClass ? `outline-${colorClass}` : 'outline-anonymous'}`}
|
||||
alt={reply.author}
|
||||
src={authorAvatars?.imageUrlSmall}
|
||||
style={{
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="bg-light-300 pl-4 pt-2.5 pr-2.5 pb-10px 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="ml-auto d-flex">
|
||||
<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 text-primary-500"
|
||||
testId={reply.id}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reply.propTypes = {
|
||||
postType: PropTypes.oneOf(['discussion', 'question']).isRequired,
|
||||
reply: commentShape.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
export default injectIntl(Reply);
|
||||
@@ -0,0 +1,43 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { injectIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import CommentEditor from './CommentEditor';
|
||||
|
||||
function ResponseEditor({
|
||||
postId,
|
||||
addWrappingDiv,
|
||||
handleCloseEditor,
|
||||
addingResponse,
|
||||
}) {
|
||||
useEffect(() => {
|
||||
handleCloseEditor();
|
||||
}, [postId]);
|
||||
|
||||
return addingResponse
|
||||
&& (
|
||||
<div className={classNames({ 'bg-white p-4 mb-4 rounded mt-2': addWrappingDiv })}>
|
||||
<CommentEditor
|
||||
comment={{ threadId: postId }}
|
||||
edit={false}
|
||||
onCloseEditor={handleCloseEditor}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ResponseEditor.propTypes = {
|
||||
postId: PropTypes.string.isRequired,
|
||||
addWrappingDiv: PropTypes.bool,
|
||||
handleCloseEditor: PropTypes.func.isRequired,
|
||||
addingResponse: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
ResponseEditor.defaultProps = {
|
||||
addWrappingDiv: false,
|
||||
};
|
||||
|
||||
export default injectIntl(ResponseEditor);
|
||||
@@ -23,6 +23,7 @@ export async function getThreadComments(
|
||||
endorsed,
|
||||
page,
|
||||
pageSize,
|
||||
reverseOrder,
|
||||
} = {},
|
||||
) {
|
||||
const params = snakeCaseObject({
|
||||
@@ -30,6 +31,7 @@ export async function getThreadComments(
|
||||
endorsed: EndorsementValue[endorsed],
|
||||
page,
|
||||
pageSize,
|
||||
reverseOrder,
|
||||
requestedFields: 'profile_image',
|
||||
});
|
||||
|
||||
62
src/discussions/post-comments/data/hooks.js
Normal file
62
src/discussions/post-comments/data/hooks.js
Normal file
@@ -0,0 +1,62 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { EndorsementStatus } from '../../../data/constants';
|
||||
import { useDispatchWithState } from '../../../data/hooks';
|
||||
import { selectThread } from '../../posts/data/selectors';
|
||||
import { markThreadAsRead } from '../../posts/data/thunks';
|
||||
import {
|
||||
selectCommentSortOrder, selectThreadComments, selectThreadCurrentPage, selectThreadHasMorePages,
|
||||
} from './selectors';
|
||||
import { fetchThreadComments } from './thunks';
|
||||
|
||||
export function usePost(postId) {
|
||||
const dispatch = useDispatch();
|
||||
const thread = useSelector(selectThread(postId));
|
||||
|
||||
useEffect(() => {
|
||||
if (thread && !thread.read) {
|
||||
dispatch(markThreadAsRead(postId));
|
||||
}
|
||||
}, [postId]);
|
||||
|
||||
return thread;
|
||||
}
|
||||
|
||||
export function usePostComments(postId, endorsed = null) {
|
||||
const [isLoading, dispatch] = useDispatchWithState();
|
||||
const comments = useSelector(selectThreadComments(postId, endorsed));
|
||||
const reverseOrder = useSelector(selectCommentSortOrder);
|
||||
const hasMorePages = useSelector(selectThreadHasMorePages(postId, endorsed));
|
||||
const currentPage = useSelector(selectThreadCurrentPage(postId, endorsed));
|
||||
|
||||
const handleLoadMoreResponses = async () => dispatch(fetchThreadComments(postId, {
|
||||
endorsed,
|
||||
page: currentPage + 1,
|
||||
reverseOrder,
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchThreadComments(postId, {
|
||||
endorsed,
|
||||
page: 1,
|
||||
reverseOrder,
|
||||
}));
|
||||
}, [postId, reverseOrder]);
|
||||
|
||||
return {
|
||||
comments,
|
||||
hasMorePages,
|
||||
isLoading,
|
||||
handleLoadMoreResponses,
|
||||
};
|
||||
}
|
||||
|
||||
export function useCommentsCount(postId) {
|
||||
const discussions = useSelector(selectThreadComments(postId, EndorsementStatus.DISCUSSION));
|
||||
const endorsedQuestions = useSelector(selectThreadComments(postId, EndorsementStatus.ENDORSED));
|
||||
const unendorsedQuestions = useSelector(selectThreadComments(postId, EndorsementStatus.UNENDORSED));
|
||||
|
||||
return [...discussions, ...endorsedQuestions, ...unendorsedQuestions].length;
|
||||
}
|
||||
1
src/discussions/post-comments/data/index.js
Normal file
1
src/discussions/post-comments/data/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export * from './slices';
|
||||
@@ -276,8 +276,7 @@ describe('Comments/Responses data layer tests', () => {
|
||||
const commentId = 'comment-1';
|
||||
|
||||
// This will generate 3 comments, so the responses will start at id = 'comment-4'
|
||||
axiosMock.onGet(commentsApiUrl)
|
||||
.reply(200, Factory.build('commentsResult'));
|
||||
axiosMock.onGet(commentsApiUrl).reply(200, Factory.build('commentsResult'));
|
||||
await executeThunk(fetchThreadComments(threadId), store.dispatch, store.getState);
|
||||
|
||||
// Build all comments first, so we can paginate over them and they
|
||||
@@ -301,8 +300,7 @@ describe('Comments/Responses data layer tests', () => {
|
||||
parent_id: commentId,
|
||||
});
|
||||
allResponses.push(comment);
|
||||
axiosMock.onPost(commentsApiUrl)
|
||||
.reply(200, comment);
|
||||
axiosMock.onPost(commentsApiUrl).reply(200, comment);
|
||||
await executeThunk(addComment('Test Comment', threadId, null), store.dispatch, store.getState);
|
||||
|
||||
// Someone else posted a new response now
|
||||
@@ -316,15 +314,14 @@ describe('Comments/Responses data layer tests', () => {
|
||||
});
|
||||
await executeThunk(fetchCommentResponses(commentId, { page: 2 }), store.dispatch, store.getState);
|
||||
|
||||
// sorting is implemented on backend
|
||||
expect(store.getState().comments.commentsInComments[commentId])
|
||||
.toEqual([
|
||||
'comment-4',
|
||||
'comment-5',
|
||||
'comment-6',
|
||||
'comment-7',
|
||||
// our comment was pushed down
|
||||
'comment-8',
|
||||
// the newer comment is placed correctly
|
||||
'comment-7',
|
||||
'comment-9',
|
||||
]);
|
||||
});
|
||||
@@ -356,8 +353,7 @@ describe('Comments/Responses data layer tests', () => {
|
||||
// Post new comment
|
||||
const comment = Factory.build('comment', { thread_id: threadId });
|
||||
allComments.push(comment);
|
||||
axiosMock.onPost(commentsApiUrl)
|
||||
.reply(200, comment);
|
||||
axiosMock.onPost(commentsApiUrl).reply(200, comment);
|
||||
await executeThunk(addComment('Test Comment', threadId, null), store.dispatch, store.getState);
|
||||
|
||||
// Somebody else posted a new response now
|
||||
@@ -371,15 +367,14 @@ describe('Comments/Responses data layer tests', () => {
|
||||
});
|
||||
await executeThunk(fetchThreadComments(threadId, { page: 2, endorsed }), store.dispatch, store.getState);
|
||||
|
||||
// sorting is implemented on backend
|
||||
expect(store.getState().comments.commentsInThreads[threadId][endorsed])
|
||||
.toEqual([
|
||||
'comment-1',
|
||||
'comment-2',
|
||||
'comment-3',
|
||||
'comment-4',
|
||||
// our comment was pushed down
|
||||
'comment-5',
|
||||
// the newer comment is placed correctly
|
||||
'comment-4',
|
||||
'comment-6',
|
||||
]);
|
||||
});
|
||||
@@ -36,4 +36,6 @@ export const selectCommentCurrentPage = commentId => (
|
||||
state => state.comments.responsesPagination[commentId]?.currentPage || null
|
||||
);
|
||||
|
||||
export const commentsStatus = state => state.comments.status;
|
||||
export const selectCommentsStatus = state => state.comments.status;
|
||||
|
||||
export const selectCommentSortOrder = state => state.comments.sortOrder;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user