Compare commits
238 Commits
jenkins/ve
...
open-relea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a6d067481 | ||
|
|
947dd1f1a6 | ||
|
|
e158964c41 | ||
|
|
7dfbd44552 | ||
|
|
d9e60ddd92 | ||
|
|
8d35a729d2 | ||
|
|
e89792b8d8 | ||
|
|
5c3d561152 | ||
|
|
e16dc59955 | ||
|
|
fc95d16536 | ||
|
|
9ce791d1d5 | ||
|
|
5e12d872b8 | ||
|
|
1a9899c696 | ||
|
|
ab18806fa6 | ||
|
|
459511281d | ||
|
|
d58b104027 | ||
|
|
990072e80f | ||
|
|
b92e10e8ae | ||
|
|
1aadbd9c4f | ||
|
|
e2619ef68c | ||
|
|
9350922200 | ||
|
|
1c5b0ac581 | ||
|
|
b4da5d35af | ||
|
|
1886b22cb3 | ||
|
|
db928965e9 | ||
|
|
cff01eb9d1 | ||
|
|
72df5ecb23 | ||
|
|
32593f6736 | ||
|
|
f686fb40a1 | ||
|
|
257e249532 | ||
|
|
87209ab169 | ||
|
|
41f9e5b30a | ||
|
|
32dc8671b2 | ||
|
|
dfd880d4b3 | ||
|
|
6d87bf879d | ||
|
|
c68ed35c59 | ||
|
|
4df4aec33a | ||
|
|
c2910affcf | ||
|
|
7ced4b292c | ||
|
|
12dd08d97f | ||
|
|
deea2aab8e | ||
|
|
2203f43052 | ||
|
|
4fa20388dd | ||
|
|
0bdd1001a5 | ||
|
|
b01272a5ca | ||
|
|
3ec0b82dce | ||
|
|
375af11a7f | ||
|
|
c5ad7cfca5 | ||
|
|
def74fd9f2 | ||
|
|
69daea0a41 | ||
|
|
7965c3e7fc | ||
|
|
08d1833b39 | ||
|
|
0a8d4d17d4 | ||
|
|
ecae19b4ec | ||
|
|
fd9edd7d77 | ||
|
|
4f74016637 | ||
|
|
0a4f2a41c5 | ||
|
|
d7d159dac2 | ||
|
|
8ef83c5ae8 | ||
|
|
401d38ce05 | ||
|
|
cd204c8e05 | ||
|
|
9c1d68a5ad | ||
|
|
4fecfabf2e | ||
|
|
50b2e15731 | ||
|
|
947204ff38 | ||
|
|
c5bda33bcf | ||
|
|
969df198d5 | ||
|
|
b5ed63c2ed | ||
|
|
150f80412e | ||
|
|
8eb3143c7e | ||
|
|
52550149ea | ||
|
|
35ad0ae0c3 | ||
|
|
11e9ebcfd0 | ||
|
|
fc86417444 | ||
|
|
315da6ba5d | ||
|
|
f43832187c | ||
|
|
8f8c5f279b | ||
|
|
4089b90524 | ||
|
|
211323b2b5 | ||
|
|
52bd342dbf | ||
|
|
de81833f9a | ||
|
|
59740948c5 | ||
|
|
4e1f3ae2f0 | ||
|
|
ab2c031bdc | ||
|
|
b256238941 | ||
|
|
72eed3d83a | ||
|
|
c6da71f5f4 | ||
|
|
457ae2823c | ||
|
|
421bc3df5f | ||
|
|
62f5566729 | ||
|
|
61c6f29313 | ||
|
|
576a02bce7 | ||
|
|
2812dac38c | ||
|
|
5e144deff7 | ||
|
|
34e448d65b | ||
|
|
d47c783a70 | ||
|
|
66f23d07a2 | ||
|
|
ac6765bbbd | ||
|
|
0f56b492a0 | ||
|
|
7017f43b35 | ||
|
|
1bcdceff02 | ||
|
|
b5dce94200 | ||
|
|
5ba964fa70 | ||
|
|
3506a231e5 | ||
|
|
adcd272700 | ||
|
|
b774b6bab1 | ||
|
|
4ed2f3b510 | ||
|
|
beda7d0cd7 | ||
|
|
8baec0bd4b | ||
|
|
b49c5d64b9 | ||
|
|
93387f1d5b | ||
|
|
77c28bcd15 | ||
|
|
c45c468a19 | ||
|
|
02202e2c07 | ||
|
|
b0737da689 | ||
|
|
8a73f23cb0 | ||
|
|
3d10b6dbed | ||
|
|
5704b402cd | ||
|
|
41ee555e88 | ||
|
|
eb2ece323c | ||
|
|
2fd1545d21 | ||
|
|
799895512c | ||
|
|
f878a04057 | ||
|
|
fab9d91a0b | ||
|
|
19666b88d2 | ||
|
|
418f78cfc8 | ||
|
|
45c596b770 | ||
|
|
d1dcf20312 | ||
|
|
5d72cc563e | ||
|
|
a45aeee43b | ||
|
|
9be71ff92e | ||
|
|
7ee5a8e157 | ||
|
|
332e5f0cd5 | ||
|
|
7426ee8838 | ||
|
|
77748bec22 | ||
|
|
928fa20f68 | ||
|
|
027e9c04f9 | ||
|
|
b29f5d7c34 | ||
|
|
592f63cae9 | ||
|
|
67618ab732 | ||
|
|
ef9cfd7287 | ||
|
|
22967357df | ||
|
|
3e0e040cb1 | ||
|
|
afe27d2da4 | ||
|
|
007a7c8085 | ||
|
|
339e37302d | ||
|
|
e34ebdbeed | ||
|
|
b7d436fe2f | ||
|
|
314b31a3b2 | ||
|
|
3657767b25 | ||
|
|
fd60e96e1d | ||
|
|
7ea3e62d33 | ||
|
|
b15fd96108 | ||
|
|
f71e04c868 | ||
|
|
edd5b96981 | ||
|
|
4991eba2b2 | ||
|
|
5f29bffea7 | ||
|
|
c90e77d291 | ||
|
|
25cf4ce4c8 | ||
|
|
2405040f08 | ||
|
|
74e2169768 | ||
|
|
97b92a1762 | ||
|
|
76595e5508 | ||
|
|
d3adb8b3e7 | ||
|
|
d71a53d9ee | ||
|
|
f447be151d | ||
|
|
c30637fcff | ||
|
|
98b97d4125 | ||
|
|
12da372fa0 | ||
|
|
c1b0aa0f8c | ||
|
|
d61045ea32 | ||
|
|
67b634d391 | ||
|
|
bf953354a1 | ||
|
|
ca1783a2b6 | ||
|
|
70027d0f49 | ||
|
|
b2f5bd8305 | ||
|
|
dbf6679c9d | ||
|
|
a49f71f717 | ||
|
|
a089235253 | ||
|
|
ae6397ea32 | ||
|
|
5719b5ce39 | ||
|
|
8aeceb7c09 | ||
|
|
59187d2217 | ||
|
|
84148954d0 | ||
|
|
945e948d42 | ||
|
|
b0249539d8 | ||
|
|
275dd99b18 | ||
|
|
b9cb2f3e2e | ||
|
|
c352030fad | ||
|
|
34071b1c69 | ||
|
|
5d342f3898 | ||
|
|
40572e1363 | ||
|
|
9cd06ce426 | ||
|
|
478799e728 | ||
|
|
6d591c935c | ||
|
|
5e5c286392 | ||
|
|
a2cfbc2b3a | ||
|
|
d7392af0f8 | ||
|
|
ad51e15409 | ||
|
|
c16206111a | ||
|
|
ef966a5700 | ||
|
|
c2106423c6 | ||
|
|
b8b01e98e9 | ||
|
|
57e1811921 | ||
|
|
245f8a0f3d | ||
|
|
5226cb3552 | ||
|
|
7502006f01 | ||
|
|
2ea48f6825 | ||
|
|
524366a3e9 | ||
|
|
2f88aae6ce | ||
|
|
8d02ad33af | ||
|
|
c80c3b8143 | ||
|
|
8fd3ac7bd9 | ||
|
|
8dd19de8a2 | ||
|
|
20b9bba5d8 | ||
|
|
5de4b440be | ||
|
|
759974c503 | ||
|
|
fef9790930 | ||
|
|
2355c2fc37 | ||
|
|
f6a65f91f5 | ||
|
|
e6bb13d4c3 | ||
|
|
8f16f7f2de | ||
|
|
3d3d5651fd | ||
|
|
a6577bffcf | ||
|
|
2d1c41b698 | ||
|
|
e01d413e47 | ||
|
|
0269c0d81c | ||
|
|
1ed1013f6b | ||
|
|
97f60d94f6 | ||
|
|
ee3d1c4061 | ||
|
|
a52c370b92 | ||
|
|
dec3f3e6e4 | ||
|
|
c843d19ff2 | ||
|
|
fd984c6ed6 | ||
|
|
243274ab10 | ||
|
|
681824432a | ||
|
|
3cb5e12ad8 | ||
|
|
2ed6d14b98 |
5
.env
5
.env
@@ -15,8 +15,11 @@ LOGO_WHITE_URL=''
|
||||
FAVICON_URL=''
|
||||
MARKETING_SITE_BASE_URL=''
|
||||
ORDER_HISTORY_URL=''
|
||||
POST_MARK_AS_READ_DELAY=2000
|
||||
REFRESH_ACCESS_TOKEN_ENDPOINT=''
|
||||
SEGMENT_KEY=''
|
||||
SITE_NAME=''
|
||||
USER_INFO_COOKIE_NAME=''
|
||||
SUPPORT_URL=''
|
||||
TA_FEEDBACK_FORM= ''
|
||||
STAFF_FEEDBACK_FORM= ''
|
||||
DISPLAY_FEEDBACK_BANNER='false'
|
||||
|
||||
@@ -16,8 +16,11 @@ LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
|
||||
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
|
||||
MARKETING_SITE_BASE_URL='http://localhost:18000'
|
||||
ORDER_HISTORY_URL='http://localhost:1996/orders'
|
||||
POST_MARK_AS_READ_DELAY=2000
|
||||
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
|
||||
SEGMENT_KEY=''
|
||||
SITE_NAME=localhost
|
||||
USER_INFO_COOKIE_NAME='edx-user-info'
|
||||
SUPPORT_URL='https://support.edx.org'
|
||||
TA_FEEDBACK_FORM='https://learner-form.test'
|
||||
STAFF_FEEDBACK_FORM='https://staff-form.test'
|
||||
DISPLAY_FEEDBACK_BANNER='false'
|
||||
|
||||
15
.env.test
15
.env.test
@@ -8,14 +8,17 @@ LMS_BASE_URL='http://localhost:18000'
|
||||
LEARNING_BASE_URL='http://localhost:2000'
|
||||
LOGIN_URL='http://localhost:18000/login'
|
||||
LOGOUT_URL='http://localhost:18000/logout'
|
||||
LOGO_URL=https://edx-cdn.org/v3/default/logo.svg
|
||||
LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/default/logo-trademark.svg
|
||||
LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
|
||||
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
|
||||
LOGO_URL='https://edx-cdn.org/v3/default/logo.svg'
|
||||
LOGO_TRADEMARK_URL='https://edx-cdn.org/v3/default/logo-trademark.svg'
|
||||
LOGO_WHITE_URL='https://edx-cdn.org/v3/default/logo-white.svg'
|
||||
FAVICON_URL='https://edx-cdn.org/v3/default/favicon.ico'
|
||||
MARKETING_SITE_BASE_URL='http://localhost:18000'
|
||||
ORDER_HISTORY_URL='http://localhost:1996/orders'
|
||||
POST_MARK_AS_READ_DELAY=2000
|
||||
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
|
||||
SEGMENT_KEY=''
|
||||
SITE_NAME=localhost
|
||||
SITE_NAME='localhost'
|
||||
USER_INFO_COOKIE_NAME='edx-user-info'
|
||||
SUPPORT_URL='https://support.edx.org'
|
||||
TA_FEEDBACK_FORM='https://learner-form.test'
|
||||
STAFF_FEEDBACK_FORM='https://staff-form.test'
|
||||
DISPLAY_FEEDBACK_BANNER='false'
|
||||
|
||||
@@ -5,6 +5,9 @@ module.exports = createConfig('eslint',
|
||||
"plugins": ["simple-import-sort"],
|
||||
"rules": {
|
||||
'import/no-extraneous-dependencies': 'off',
|
||||
'react-hooks/exhaustive-deps': 'off',
|
||||
'jsx-a11y/no-noninteractive-element-interactions': 'off',
|
||||
'jsx-a11y/no-access-key': 'off',
|
||||
'simple-import-sort/imports': [
|
||||
'error', {
|
||||
groups: [
|
||||
|
||||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -11,7 +11,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node: [12, 14, 16]
|
||||
node: [16]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
|
||||
2
.github/workflows/commitlint.yml
vendored
2
.github/workflows/commitlint.yml
vendored
@@ -7,4 +7,4 @@ on:
|
||||
|
||||
jobs:
|
||||
commitlint:
|
||||
uses: edx/.github/.github/workflows/commitlint.yml@master
|
||||
uses: openedx/.github/.github/workflows/commitlint.yml@master
|
||||
|
||||
2
.github/workflows/lockfileversion-check.yml
vendored
2
.github/workflows/lockfileversion-check.yml
vendored
@@ -10,4 +10,4 @@ on:
|
||||
|
||||
jobs:
|
||||
version-check:
|
||||
uses: edx/.github/.github/workflows/lockfileversion-check.yml@master
|
||||
uses: openedx/.github/.github/workflows/lockfileversion-check.yml@master
|
||||
|
||||
2
.jest/setEnvVars.js
Normal file
2
.jest/setEnvVars.js
Normal file
@@ -0,0 +1,2 @@
|
||||
process.env.TA_FEEDBACK_FORM= 'https://learner-form.test';
|
||||
process.env.STAFF_FEEDBACK_FORM= 'https://staff-form.test';
|
||||
8
.tx/config
Normal file
8
.tx/config
Normal file
@@ -0,0 +1,8 @@
|
||||
[main]
|
||||
host = https://www.transifex.com
|
||||
|
||||
[o:open-edx:p:edx-platform:r:frontend-app-discussions]
|
||||
file_filter = src/i18n/messages/<lang>.json
|
||||
source_file = src/i18n/transifex_input.json
|
||||
source_lang = en
|
||||
type = KEYVALUEJSON
|
||||
29
Makefile
29
Makefile
@@ -1,5 +1,6 @@
|
||||
export TRANSIFEX_RESOURCE = frontend-app-discussions
|
||||
transifex_resource = frontend-app-discussions
|
||||
transifex_langs = "ar,fr,es_419,zh_CN"
|
||||
transifex_langs = "ar,fr,es_419,zh_CN,tr_TR,pl,fr_CA,fr_FR,de_DE,it_IT"
|
||||
|
||||
transifex_utils = ./node_modules/.bin/transifex-utils.js
|
||||
i18n = ./src/i18n
|
||||
@@ -10,12 +11,24 @@ tx_url2 = https://www.transifex.com/api/2/project/edx-platform/resource/$(transi
|
||||
# This directory must match .babelrc .
|
||||
transifex_temp = ./temp/babel-plugin-react-intl
|
||||
|
||||
NPM_TESTS=build i18n_extract lint test
|
||||
|
||||
.PHONY: test
|
||||
test: $(addprefix test.npm.,$(NPM_TESTS)) ## validate ci suite
|
||||
|
||||
.PHONY: test.npm.*
|
||||
test.npm.%: validate-no-uncommitted-package-lock-changes
|
||||
test -d node_modules || $(MAKE) requirements
|
||||
npm run $(*)
|
||||
|
||||
.PHONY: requirements
|
||||
|
||||
precommit:
|
||||
npm run lint
|
||||
npm audit
|
||||
|
||||
requirements:
|
||||
npm install
|
||||
requirements: ## install ci requirements
|
||||
npm ci
|
||||
|
||||
i18n.extract:
|
||||
# Pulling display strings from .jsx files into .json files...
|
||||
@@ -38,17 +51,17 @@ push_translations:
|
||||
# Pushing strings to Transifex...
|
||||
tx push -s
|
||||
# Fetching hashes from Transifex...
|
||||
./node_modules/reactifex/bash_scripts/get_hashed_strings.sh $(tx_url1)
|
||||
./node_modules/@edx/reactifex/bash_scripts/get_hashed_strings_v3.sh
|
||||
# Writing out comments to file...
|
||||
$(transifex_utils) $(transifex_temp) --comments
|
||||
$(transifex_utils) $(transifex_temp) --comments --v3-scripts-path
|
||||
# Pushing comments to Transifex...
|
||||
./node_modules/reactifex/bash_scripts/put_comments.sh $(tx_url2)
|
||||
./node_modules/@edx/reactifex/bash_scripts/put_comments_v3.sh
|
||||
|
||||
# Pulls translations from Transifex.
|
||||
pull_translations:
|
||||
tx pull -f --mode reviewed --language=$(transifex_langs)
|
||||
tx pull -t -f --mode reviewed --languages=$(transifex_langs)
|
||||
|
||||
# This target is used by Travis.
|
||||
validate-no-uncommitted-package-lock-changes:
|
||||
# Checking for package-lock.json changes...
|
||||
git diff --exit-code package-lock.json
|
||||
git diff --exit-code package-lock.json
|
||||
@@ -14,7 +14,7 @@ This repository is a React-based micro frontend for the Open edX discussion foru
|
||||
|
||||
1. Clone your new repo:
|
||||
|
||||
``git clone https://github.com/edx/frontend-app-discussions.git``
|
||||
``git clone https://github.com/openedx/frontend-app-discussions.git``
|
||||
|
||||
2. Install npm dependencies:
|
||||
|
||||
@@ -29,7 +29,7 @@ The dev server is running at `http://localhost:2002 <http://localhost:2002>`_.
|
||||
Project Structure
|
||||
-----------------
|
||||
|
||||
The source for this project is organized into nested submodules according to the ADR `Feature-based Application Organization <https://github.com/edx/frontend-app-discussions/blob/master/docs/decisions/0002-feature-based-application-organization.rst>`_.
|
||||
The source for this project is organized into nested submodules according to the ADR `Feature-based Application Organization <https://github.com/openedx/frontend-app-discussions/blob/master/docs/decisions/0002-feature-based-application-organization.rst>`_.
|
||||
|
||||
Build Process Notes
|
||||
-------------------
|
||||
@@ -41,7 +41,7 @@ The production build is created with ``npm run build``.
|
||||
Internationalization
|
||||
--------------------
|
||||
|
||||
Please see `edx/frontend-platform's i18n module <https://edx.github.io/frontend-platform/module-Internationalization.html>`_ for documentation on internationalization. The documentation explains how to use it, and the `How To <https://github.com/edx/frontend-i18n/blob/master/docs/how_tos/i18n.rst>`_ has more detail.
|
||||
Please see `edx/frontend-platform's i18n module <https://edx.github.io/frontend-platform/module-Internationalization.html>`_ for documentation on internationalization. The documentation explains how to use it, and the `How To <https://github.com/openedx/frontend-i18n/blob/master/docs/how_tos/i18n.rst>`_ has more detail.
|
||||
|
||||
.. |Build Status| image:: https://api.travis-ci.org/edx/frontend-app-discussions.svg?branch=master
|
||||
:target: https://travis-ci.org/edx/frontend-app-discussions
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
React App i18n HOWTO
|
||||
####################
|
||||
|
||||
This document has moved to the frontend-platform repo: https://github.com/edx/frontend-platform/blob/master/docs/how_tos/i18n.rst
|
||||
This document has moved to the frontend-platform repo: https://github.com/openedx/frontend-platform/blob/master/docs/how_tos/i18n.rst
|
||||
|
||||
@@ -2,7 +2,8 @@ const { createConfig } = require('@edx/frontend-build');
|
||||
|
||||
module.exports = createConfig('jest', {
|
||||
// setupFilesAfterEnv is used after the jest environment has been loaded. In general this is what you want.
|
||||
// If you want to add config BEFORE jest loads, use setupFiles instead.
|
||||
// If you want to add config BEFORE jest loads, use setupFiles instead.
|
||||
setupFiles: ['<rootDir>/.jest/setEnvVars.js'],
|
||||
setupFilesAfterEnv: [
|
||||
'<rootDir>/src/setupTest.js',
|
||||
],
|
||||
|
||||
@@ -8,5 +8,4 @@ openedx-release:
|
||||
# The openedx-release key is described in OEP-10:
|
||||
# https://open-edx-proposals.readthedocs.io/en/latest/oep-0010-proc-openedx-releases.html
|
||||
# The FAQ might also be helpful: https://openedx.atlassian.net/wiki/spaces/COMM/pages/1331268879/Open+edX+Release+FAQ
|
||||
maybe: true # Delete this "maybe" line when you have decided about Open edX inclusion.
|
||||
ref: master
|
||||
|
||||
21826
package-lock.json
generated
21826
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
23
package.json
23
package.json
@@ -4,16 +4,14 @@
|
||||
"description": "Discussions Frontend",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/edx/frontend-app-discussions.git"
|
||||
"url": "git+https://github.com/openedx/frontend-app-discussions.git"
|
||||
},
|
||||
"browserslist": [
|
||||
"last 2 versions",
|
||||
"ie 11"
|
||||
"extends @edx/browserslist-config"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "fedx-scripts webpack",
|
||||
"i18n_extract": "BABEL_ENV=i18n fedx-scripts babel src --quiet > /dev/null",
|
||||
"is-es5": "es-check es5 ./dist/*.js",
|
||||
"lint": "fedx-scripts eslint --ext .js --ext .jsx .",
|
||||
"lint:fix": "fedx-scripts eslint --ext .js --ext .jsx . --fix",
|
||||
"snapshot": "fedx-scripts jest --updateSnapshot",
|
||||
@@ -27,17 +25,19 @@
|
||||
},
|
||||
"author": "edX",
|
||||
"license": "AGPL-3.0",
|
||||
"homepage": "https://github.com/edx/frontend-app-discussions#readme",
|
||||
"homepage": "https://github.com/openedx/frontend-app-discussions#readme",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/edx/frontend-app-discussions/issues"
|
||||
"url": "https://github.com/openedx/frontend-app-discussions/issues"
|
||||
},
|
||||
"dependencies": {
|
||||
"@edx/brand": "npm:@edx/brand-openedx@1.1.0",
|
||||
"@edx/frontend-platform": "1.15.4",
|
||||
"@edx/paragon": "19.10.1",
|
||||
"@edx/frontend-component-footer": "11.2.0",
|
||||
"@edx/frontend-component-header": "3.2.0",
|
||||
"@edx/frontend-platform": "2.6.1",
|
||||
"@edx/paragon": "20.15.0",
|
||||
"@reduxjs/toolkit": "1.8.0",
|
||||
"@tinymce/tinymce-react": "3.13.1",
|
||||
"babel-polyfill": "6.26.0",
|
||||
@@ -49,6 +49,7 @@
|
||||
"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",
|
||||
@@ -59,18 +60,20 @@
|
||||
"yup": "0.31.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@edx/frontend-build": "9.1.4",
|
||||
"@edx/browserslist-config": "1.1.0",
|
||||
"@edx/frontend-build": "11.0.1",
|
||||
"@edx/reactifex": "1.0.3",
|
||||
"@testing-library/jest-dom": "5.16.2",
|
||||
"@testing-library/react": "12.1.4",
|
||||
"@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",
|
||||
"jest": "27.5.1",
|
||||
"reactifex": "1.1.1",
|
||||
"rosie": "2.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
<!doctype html>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en-us">
|
||||
<head>
|
||||
<title>Discussions | <%= process.env.SITE_NAME %></title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="shortcut icon" href="<%=htmlWebpackPlugin.options.FAVICON_URL%>" type="image/x-icon" />
|
||||
<title>Discussions | <%= process.env.SITE_NAME %></title>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link
|
||||
rel="shortcut icon"
|
||||
href="<%=htmlWebpackPlugin.options.FAVICON_URL%>"
|
||||
type="image/x-icon"
|
||||
/>
|
||||
</head>
|
||||
<body class="vh-100 vw-100 overflow-hidden">
|
||||
<div id="root" class="vh-100 vw-100 small"></div>
|
||||
<body>
|
||||
<div id="root" class="small"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
201
src/components/FilterBar.jsx
Normal file
201
src/components/FilterBar.jsx
Normal file
@@ -0,0 +1,201 @@
|
||||
/* eslint-disable react/forbid-prop-types */
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { capitalize, toString } from 'lodash';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Collapsible, Form, Icon, Spinner,
|
||||
} from '@edx/paragon';
|
||||
import { Tune } from '@edx/paragon/icons';
|
||||
|
||||
import {
|
||||
PostsStatusFilter, RequestStatus,
|
||||
ThreadOrdering, ThreadType,
|
||||
} from '../data/constants';
|
||||
import { selectCourseCohorts } from '../discussions/cohorts/data/selectors';
|
||||
import messages from '../discussions/posts/post-filter-bar/messages';
|
||||
import { ActionItem } from '../discussions/posts/post-filter-bar/PostFilterBar';
|
||||
|
||||
function FilterBar({
|
||||
intl,
|
||||
filters,
|
||||
selectedFilters,
|
||||
onFilterChange,
|
||||
showCohortsFilter,
|
||||
}) {
|
||||
const [isOpen, setOpen] = useState(false);
|
||||
const cohorts = useSelector(selectCourseCohorts);
|
||||
const { status } = useSelector(state => state.cohorts);
|
||||
const selectedCohort = useMemo(() => cohorts.find(cohort => (
|
||||
toString(cohort.id) === selectedFilters.cohort)),
|
||||
[selectedFilters.cohort]);
|
||||
|
||||
const allFilters = [
|
||||
{
|
||||
id: 'type-all',
|
||||
label: intl.formatMessage(messages.allPosts),
|
||||
value: ThreadType.ALL,
|
||||
},
|
||||
{
|
||||
id: 'type-discussions',
|
||||
label: intl.formatMessage(messages.filterDiscussions),
|
||||
value: ThreadType.DISCUSSION,
|
||||
},
|
||||
{
|
||||
id: 'type-questions',
|
||||
label: intl.formatMessage(messages.filterQuestions),
|
||||
value: ThreadType.QUESTION,
|
||||
},
|
||||
{
|
||||
id: 'status-any',
|
||||
label: intl.formatMessage(messages.filterAnyStatus),
|
||||
value: PostsStatusFilter.ALL,
|
||||
},
|
||||
{
|
||||
id: 'status-unread',
|
||||
label: intl.formatMessage(messages.filterUnread),
|
||||
value: PostsStatusFilter.UNREAD,
|
||||
},
|
||||
{
|
||||
id: 'status-reported',
|
||||
label: intl.formatMessage(messages.filterReported),
|
||||
value: PostsStatusFilter.REPORTED,
|
||||
},
|
||||
{
|
||||
id: 'status-unanswered',
|
||||
label: intl.formatMessage(messages.filterUnanswered),
|
||||
value: PostsStatusFilter.UNANSWERED,
|
||||
},
|
||||
{
|
||||
id: 'status-unresponded',
|
||||
label: intl.formatMessage(messages.filterUnresponded),
|
||||
value: PostsStatusFilter.UNRESPONDED,
|
||||
},
|
||||
{
|
||||
id: 'sort-activity',
|
||||
label: intl.formatMessage(messages.lastActivityAt),
|
||||
value: ThreadOrdering.BY_LAST_ACTIVITY,
|
||||
},
|
||||
{
|
||||
id: 'sort-comments',
|
||||
label: intl.formatMessage(messages.commentCount),
|
||||
value: ThreadOrdering.BY_COMMENT_COUNT,
|
||||
},
|
||||
{
|
||||
id: 'sort-votes',
|
||||
label: intl.formatMessage(messages.voteCount),
|
||||
value: ThreadOrdering.BY_VOTE_COUNT,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Collapsible.Advanced
|
||||
open={isOpen}
|
||||
onToggle={() => setOpen(!isOpen)}
|
||||
className="filter-bar collapsible-card-lg border-0"
|
||||
>
|
||||
<Collapsible.Trigger className="collapsible-trigger border-0">
|
||||
<span className="text-primary-700 pr-4">
|
||||
{intl.formatMessage(messages.sortFilterStatus, {
|
||||
own: false,
|
||||
type: selectedFilters.postType,
|
||||
sort: selectedFilters.orderBy,
|
||||
status: selectedFilters.status,
|
||||
cohortType: selectedCohort?.name ? 'group' : 'all',
|
||||
cohort: capitalize(selectedCohort?.name),
|
||||
})}
|
||||
</span>
|
||||
<Collapsible.Visible whenClosed>
|
||||
<Icon src={Tune} />
|
||||
</Collapsible.Visible>
|
||||
<Collapsible.Visible whenOpen>
|
||||
<Icon src={Tune} />
|
||||
</Collapsible.Visible>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Body className="collapsible-body px-4 pb-3 pt-0">
|
||||
<Form>
|
||||
<div className="d-flex flex-row py-2 justify-content-between">
|
||||
{filters.map((value) => (
|
||||
<Form.RadioSet
|
||||
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;
|
||||
})
|
||||
}
|
||||
|
||||
</Form.RadioSet>
|
||||
))}
|
||||
</div>
|
||||
{showCohortsFilter && (
|
||||
<>
|
||||
<div className="border-bottom my-2" />
|
||||
{status === RequestStatus.IN_PROGRESS ? (
|
||||
<div className="d-flex justify-content-center p-4">
|
||||
<Spinner animation="border" variant="primary" size="lg" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="d-flex flex-row pt-2">
|
||||
<Form.RadioSet
|
||||
name="cohort"
|
||||
className="d-flex flex-column list-group list-group-flush w-100"
|
||||
value={selectedFilters.cohort}
|
||||
onChange={onFilterChange}
|
||||
>
|
||||
<ActionItem
|
||||
id="all-groups"
|
||||
label="All groups"
|
||||
value=""
|
||||
selected={selectedFilters.cohort}
|
||||
/>
|
||||
{cohorts.map(cohort => (
|
||||
<ActionItem
|
||||
key={toString(cohort.id)}
|
||||
id={toString(cohort.id)}
|
||||
label={capitalize(cohort.name)}
|
||||
value={toString(cohort.id)}
|
||||
selected={selectedFilters.cohort}
|
||||
/>
|
||||
))}
|
||||
</Form.RadioSet>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
</Collapsible.Body>
|
||||
</Collapsible.Advanced>
|
||||
);
|
||||
}
|
||||
|
||||
FilterBar.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
filters: PropTypes.array.isRequired,
|
||||
selectedFilters: PropTypes.object.isRequired,
|
||||
onFilterChange: PropTypes.func.isRequired,
|
||||
showCohortsFilter: PropTypes.bool,
|
||||
};
|
||||
|
||||
FilterBar.defaultProps = {
|
||||
showCohortsFilter: false,
|
||||
};
|
||||
|
||||
export default injectIntl(FilterBar);
|
||||
66
src/components/HTMLLoader.jsx
Normal file
66
src/components/HTMLLoader.jsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import MathJax from 'react-mathjax-preview';
|
||||
|
||||
const baseConfig = {
|
||||
showMathMenu: true,
|
||||
tex2jax: {
|
||||
inlineMath: [
|
||||
['$', '$'],
|
||||
['\\\\(', '\\\\)'],
|
||||
['\\(', '\\)'],
|
||||
['[mathjaxinline]', '[/mathjaxinline]'],
|
||||
['\\begin{math}', '\\end{math}'],
|
||||
],
|
||||
displayMath: [
|
||||
['[mathjax]', '[/mathjax]'],
|
||||
['$$', '$$'],
|
||||
['\\\\[', '\\\\]'],
|
||||
['\\[', '\\]'],
|
||||
['\\begin{displaymath}', '\\end{displaymath}'],
|
||||
['\\begin{equation}', '\\end{equation}'],
|
||||
],
|
||||
},
|
||||
|
||||
skipStartupTypeset: true,
|
||||
};
|
||||
|
||||
function HTMLLoader({ htmlNode, componentId, cssClassName }) {
|
||||
const isLatex = htmlNode.match(/(\${1,2})((?:\\.|.)*)\1/)
|
||||
|| htmlNode.match(/(\[mathjax](.+?)\[\/mathjax])+/)
|
||||
|| htmlNode.match(/(\[mathjaxinline](.+?)\[\/mathjaxinline])+/)
|
||||
|| htmlNode.match(/(\\begin\{math}(.+?)\\end\{math})+/)
|
||||
|| htmlNode.match(/(\\begin\{displaymath}(.+?)\\end\{displaymath})+/)
|
||||
|| htmlNode.match(/(\\begin\{equation}(.+?)\\end\{equation})+/)
|
||||
|| htmlNode.match(/(\\\[(.+?)\\\])+/)
|
||||
|| htmlNode.match(/(\\\((.+?)\\\))+/);
|
||||
|
||||
return (
|
||||
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 }} />
|
||||
);
|
||||
}
|
||||
|
||||
HTMLLoader.propTypes = {
|
||||
htmlNode: PropTypes.node,
|
||||
componentId: PropTypes.string,
|
||||
cssClassName: PropTypes.string,
|
||||
};
|
||||
|
||||
HTMLLoader.defaultProps = {
|
||||
htmlNode: '',
|
||||
componentId: null,
|
||||
cssClassName: '',
|
||||
};
|
||||
|
||||
export default HTMLLoader;
|
||||
64
src/components/NavigationBar/CourseTabsNavigation.jsx
Normal file
64
src/components/NavigationBar/CourseTabsNavigation.jsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import React, { useEffect } 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 { fetchTab } from './data/thunks';
|
||||
import Tabs from './tabs/Tabs';
|
||||
import messages from './messages';
|
||||
|
||||
import './navBar.scss';
|
||||
|
||||
function CourseTabsNavigation({
|
||||
activeTab, className, intl, courseId, rootSlug,
|
||||
}) {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const tabs = useSelector(state => state.courseTabs.tabs);
|
||||
useEffect(() => {
|
||||
dispatch(fetchTab(courseId, rootSlug));
|
||||
}, [courseId]);
|
||||
|
||||
return (
|
||||
<div id="courseTabsNavigation" className={classNames('course-tabs-navigation', className)}>
|
||||
<div className="container-xl">
|
||||
{!!tabs.length
|
||||
&& (
|
||||
<Tabs
|
||||
className="nav-underline-tabs"
|
||||
aria-label={intl.formatMessage(messages.courseMaterial)}
|
||||
>
|
||||
{tabs.map(({ url, title, slug }) => (
|
||||
<a
|
||||
key={slug}
|
||||
className={classNames('nav-item flex-shrink-0 nav-link', { active: slug === activeTab })}
|
||||
href={url}
|
||||
>
|
||||
{title}
|
||||
</a>
|
||||
))}
|
||||
</Tabs>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
CourseTabsNavigation.propTypes = {
|
||||
activeTab: PropTypes.string,
|
||||
className: PropTypes.string,
|
||||
rootSlug: PropTypes.string,
|
||||
courseId: PropTypes.string.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
CourseTabsNavigation.defaultProps = {
|
||||
activeTab: undefined,
|
||||
className: null,
|
||||
rootSlug: 'outline',
|
||||
};
|
||||
|
||||
export default injectIntl(CourseTabsNavigation);
|
||||
29
src/components/NavigationBar/data/api.js
Normal file
29
src/components/NavigationBar/data/api.js
Normal file
@@ -0,0 +1,29 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import { camelCaseObject } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
import { getApiBaseUrl } from '../../../data/constants';
|
||||
|
||||
function normalizeCourseHomeCourseMetadata(metadata, rootSlug) {
|
||||
const data = camelCaseObject(metadata);
|
||||
return {
|
||||
...data,
|
||||
tabs: data.tabs.map(tab => ({
|
||||
// The API uses "courseware" as a slug for both courseware and the outline tab.
|
||||
// If needed, we switch it to "outline" here for
|
||||
// use within the MFE to differentiate between course home and courseware.
|
||||
slug: tab.tabId === 'courseware' ? rootSlug : tab.tabId,
|
||||
title: tab.title,
|
||||
url: tab.url,
|
||||
})),
|
||||
isMasquerading: data.originalUserIsStaff && !data.isStaff,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getCourseHomeCourseMetadata(courseId, rootSlug) {
|
||||
const url = `${getApiBaseUrl()}/api/course_home/course_metadata/${courseId}`;
|
||||
// don't know the context of adding timezone in url. hence omitting it
|
||||
// url = appendBrowserTimezoneToUrl(url);
|
||||
const { data } = await getAuthenticatedHttpClient().get(url);
|
||||
return normalizeCourseHomeCourseMetadata(data, rootSlug);
|
||||
}
|
||||
1
src/components/NavigationBar/data/index.js
Normal file
1
src/components/NavigationBar/data/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export * from './slice';
|
||||
51
src/components/NavigationBar/data/slice.js
Normal file
51
src/components/NavigationBar/data/slice.js
Normal file
@@ -0,0 +1,51 @@
|
||||
/* eslint-disable no-param-reassign */
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
export const LOADING = 'loading';
|
||||
export const LOADED = 'loaded';
|
||||
export const FAILED = 'failed';
|
||||
export const DENIED = 'denied';
|
||||
|
||||
const slice = createSlice({
|
||||
name: 'courseTabs',
|
||||
initialState: {
|
||||
courseStatus: 'loading',
|
||||
courseId: null,
|
||||
tabs: [],
|
||||
courseTitle: null,
|
||||
courseNumber: null,
|
||||
org: null,
|
||||
},
|
||||
reducers: {
|
||||
fetchTabDenied: (state, { payload }) => {
|
||||
state.courseId = payload.courseId;
|
||||
state.courseStatus = DENIED;
|
||||
},
|
||||
fetchTabFailure: (state, { payload }) => {
|
||||
state.courseId = payload.courseId;
|
||||
state.courseStatus = FAILED;
|
||||
},
|
||||
fetchTabRequest: (state, { payload }) => {
|
||||
state.courseId = payload.courseId;
|
||||
state.courseStatus = LOADING;
|
||||
},
|
||||
fetchTabSuccess: (state, { payload }) => {
|
||||
state.courseId = payload.courseId;
|
||||
state.targetUserId = payload.targetUserId;
|
||||
state.tabs = payload.tabs;
|
||||
state.courseStatus = LOADED;
|
||||
state.courseTitle = payload.courseTitle;
|
||||
state.courseNumber = payload.courseNumber;
|
||||
state.org = payload.org;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
fetchTabDenied,
|
||||
fetchTabFailure,
|
||||
fetchTabRequest,
|
||||
fetchTabSuccess,
|
||||
} = slice.actions;
|
||||
|
||||
export const courseTabsReducer = slice.reducer;
|
||||
33
src/components/NavigationBar/data/thunks.js
Normal file
33
src/components/NavigationBar/data/thunks.js
Normal file
@@ -0,0 +1,33 @@
|
||||
/* eslint-disable import/prefer-default-export, no-unused-expressions */
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
|
||||
import { getCourseHomeCourseMetadata } from './api';
|
||||
import {
|
||||
fetchTabDenied,
|
||||
fetchTabFailure,
|
||||
fetchTabRequest,
|
||||
fetchTabSuccess,
|
||||
} from './slice';
|
||||
|
||||
export function fetchTab(courseId, rootSlug) {
|
||||
return async (dispatch) => {
|
||||
dispatch(fetchTabRequest({ courseId }));
|
||||
try {
|
||||
const courseHomeCourseMetadata = await getCourseHomeCourseMetadata(courseId, rootSlug);
|
||||
if (!courseHomeCourseMetadata.courseAccess.hasAccess) {
|
||||
dispatch(fetchTabDenied({ courseId }));
|
||||
} else {
|
||||
dispatch(fetchTabSuccess({
|
||||
courseId,
|
||||
tabs: courseHomeCourseMetadata.tabs,
|
||||
org: courseHomeCourseMetadata.org,
|
||||
courseNumber: courseHomeCourseMetadata.number,
|
||||
courseTitle: courseHomeCourseMetadata.title,
|
||||
}));
|
||||
}
|
||||
} catch (e) {
|
||||
dispatch(fetchTabFailure({ courseId }));
|
||||
logError(e);
|
||||
}
|
||||
};
|
||||
}
|
||||
2
src/components/NavigationBar/index.js
Normal file
2
src/components/NavigationBar/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export { default as CourseTabsNavigation } from './CourseTabsNavigation';
|
||||
11
src/components/NavigationBar/messages.js
Normal file
11
src/components/NavigationBar/messages.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
courseMaterial: {
|
||||
id: 'navigation.course.tabs.label',
|
||||
defaultMessage: 'Course Material',
|
||||
description: 'The accessible label for course tabs navigation',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
47
src/components/NavigationBar/navBar.scss
Executable file
47
src/components/NavigationBar/navBar.scss
Executable file
@@ -0,0 +1,47 @@
|
||||
@import "@edx/brand/paragon/fonts.scss";
|
||||
@import "@edx/brand/paragon/variables.scss";
|
||||
@import "@edx/paragon/scss/core/core.scss";
|
||||
@import "@edx/brand/paragon/overrides.scss";
|
||||
|
||||
$fa-font-path: "~font-awesome/fonts";
|
||||
@import "~font-awesome/scss/font-awesome";
|
||||
.course-tabs-navigation {
|
||||
border-bottom: solid 1px #eaeaea;
|
||||
|
||||
.nav a,
|
||||
.nav button {
|
||||
&:hover {
|
||||
background-color: $light-400;
|
||||
}
|
||||
}
|
||||
|
||||
.nav a {
|
||||
&:not(.active):hover {
|
||||
background-color: $light-400;
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nav-underline-tabs {
|
||||
margin: 0 0 -1px;
|
||||
|
||||
.nav-link {
|
||||
border-bottom: 4px solid transparent;
|
||||
border-top: 4px solid transparent;
|
||||
color: $gray-700;
|
||||
|
||||
// temporary until we can remove .btn class from dropdowns
|
||||
border-left: 0;
|
||||
border-right: 0;
|
||||
border-radius: 0;
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&.active {
|
||||
font-weight: $font-weight-normal;
|
||||
color: $primary-500;
|
||||
border-bottom-color: $primary-500;
|
||||
}
|
||||
}
|
||||
}
|
||||
75
src/components/NavigationBar/tabs/Tabs.jsx
Normal file
75
src/components/NavigationBar/tabs/Tabs.jsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import { Dropdown } from '@edx/paragon';
|
||||
|
||||
import useIndexOfLastVisibleChild from './useIndexOfLastVisibleChild';
|
||||
|
||||
export default function Tabs({ children, className, ...attrs }) {
|
||||
const [
|
||||
indexOfLastVisibleChild,
|
||||
containerElementRef,
|
||||
invisibleStyle,
|
||||
overflowElementRef,
|
||||
] = useIndexOfLastVisibleChild();
|
||||
|
||||
const tabChildren = useMemo(() => {
|
||||
const childrenArray = React.Children.toArray(children);
|
||||
const indexOfOverflowStart = indexOfLastVisibleChild + 1;
|
||||
|
||||
// All tabs will be rendered. Those that would overflow are set to invisible.
|
||||
const wrappedChildren = childrenArray.map((child, index) => React.cloneElement(child, {
|
||||
style: index > indexOfLastVisibleChild ? invisibleStyle : null,
|
||||
}));
|
||||
|
||||
// Build the list of items to put in the overflow menu
|
||||
const overflowChildren = childrenArray.slice(indexOfOverflowStart)
|
||||
.map(overflowChild => React.cloneElement(overflowChild, { className: 'dropdown-item' }));
|
||||
|
||||
// Insert the overflow menu at the cut off index (even if it will be hidden
|
||||
// it so it can be part of measurements)
|
||||
wrappedChildren.splice(indexOfOverflowStart, 0, (
|
||||
<div
|
||||
className="nav-item flex-shrink-0"
|
||||
style={indexOfOverflowStart >= React.Children.count(children) ? invisibleStyle : null}
|
||||
ref={overflowElementRef}
|
||||
key="overflow"
|
||||
>
|
||||
<Dropdown className="h-100">
|
||||
<Dropdown.Toggle variant="link" className="nav-link h-100" id="learn.course.tabs.navigation.overflow.menu">
|
||||
<FormattedMessage
|
||||
id="learn.course.tabs.navigation.overflow.menu"
|
||||
description="The title of the overflow menu for course tabs"
|
||||
defaultMessage="More..."
|
||||
/>
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu className="dropdown-menu-right">{overflowChildren}</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
</div>
|
||||
));
|
||||
return wrappedChildren;
|
||||
}, [children, indexOfLastVisibleChild]);
|
||||
|
||||
return (
|
||||
<nav
|
||||
{...attrs}
|
||||
className={classNames('nav flex-nowrap', className)}
|
||||
ref={containerElementRef}
|
||||
>
|
||||
{tabChildren}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
Tabs.propTypes = {
|
||||
children: PropTypes.node,
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
Tabs.defaultProps = {
|
||||
children: null,
|
||||
className: undefined,
|
||||
};
|
||||
60
src/components/NavigationBar/tabs/Tabs.test.jsx
Normal file
60
src/components/NavigationBar/tabs/Tabs.test.jsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import React from 'react';
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { IntlProvider } from 'react-intl';
|
||||
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
|
||||
import Tabs from './Tabs';
|
||||
import useIndexOfLastVisibleChild from './useIndexOfLastVisibleChild';
|
||||
|
||||
jest.mock('./useIndexOfLastVisibleChild');
|
||||
|
||||
describe('Tabs', () => {
|
||||
const mockChildren = [...Array(4).keys()].map(i => (<button key={i} type="button">{`Item ${i}`}</button>));
|
||||
// Only half of the children will be visible. The rest of them will be in the dropdown.
|
||||
const indexOfLastVisibleChild = mockChildren.length / 2 - 1;
|
||||
|
||||
const invisibleStyle = { visibility: 'hidden' };
|
||||
useIndexOfLastVisibleChild.mockReturnValue([indexOfLastVisibleChild, null, invisibleStyle, null]);
|
||||
|
||||
function renderComponent(children = null) {
|
||||
render(
|
||||
<IntlProvider locale="en">
|
||||
<Tabs>
|
||||
{children}
|
||||
</Tabs>
|
||||
</IntlProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('renders without children', async () => {
|
||||
renderComponent();
|
||||
expect(screen.getByRole('button', { text: 'More...', hidden: true })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides invisible children', async () => {
|
||||
renderComponent(mockChildren);
|
||||
// adding hidden property is necessary because everything enclosed in a div with property hidden
|
||||
const allButtons = screen.getAllByRole('button', { hidden: true });
|
||||
expect(screen.getAllByRole('button', { hidden: false })).toHaveLength(3);
|
||||
[...Array(mockChildren.length).keys()].forEach(i => {
|
||||
if (i <= indexOfLastVisibleChild + 1) {
|
||||
expect(allButtons[i]).not.toHaveAttribute('style');
|
||||
} else {
|
||||
expect(allButtons[i]).toHaveStyle('visibility: hidden;');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useLayoutEffect, useRef, useState } from 'react';
|
||||
|
||||
import { useWindowSize } from '@edx/paragon';
|
||||
|
||||
const invisibleStyle = {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
pointerEvents: 'none',
|
||||
visibility: 'hidden',
|
||||
};
|
||||
|
||||
/**
|
||||
* This hook will find the index of the last child of a containing element
|
||||
* that fits within its bounding rectangle. This is done by summing the widths
|
||||
* of the children until they exceed the width of the container.
|
||||
*
|
||||
* The hook returns an array containing:
|
||||
* [indexOfLastVisibleChild, containerElementRef, invisibleStyle, overflowElementRef]
|
||||
*
|
||||
* indexOfLastVisibleChild - the index of the last visible child
|
||||
* containerElementRef - a ref to be added to the containing html node
|
||||
* invisibleStyle - a set of styles to be applied to child of the containing node
|
||||
* if it needs to be hidden. These styles remove the element visually, from
|
||||
* screen readers, and from normal layout flow. But, importantly, these styles
|
||||
* preserve the width of the element, so that future width calculations will
|
||||
* still be accurate.
|
||||
* overflowElementRef - a ref to be added to an html node inside the container
|
||||
* that is likely to be used to contain a "More" type dropdown or other
|
||||
* mechanism to reveal hidden children. The width of this element is always
|
||||
* included when determining which children will fit or not. Usage of this ref
|
||||
* is optional.
|
||||
*/
|
||||
export default function useIndexOfLastVisibleChild() {
|
||||
const containerElementRef = useRef(null);
|
||||
const overflowElementRef = useRef(null);
|
||||
const containingRectRef = useRef({});
|
||||
const [indexOfLastVisibleChild, setIndexOfLastVisibleChild] = useState(-1);
|
||||
const windowSize = useWindowSize();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const containingRect = containerElementRef.current.getBoundingClientRect();
|
||||
|
||||
// No-op if the width is unchanged.
|
||||
// (Assumes tabs themselves don't change count or width).
|
||||
if (!containingRect.width === containingRectRef.current.width) {
|
||||
return;
|
||||
}
|
||||
// Update for future comparison
|
||||
containingRectRef.current = containingRect;
|
||||
|
||||
// Get array of child nodes from NodeList form
|
||||
const childNodesArr = Array.prototype.slice.call(containerElementRef.current.children);
|
||||
const { nextIndexOfLastVisibleChild } = childNodesArr
|
||||
// filter out the overflow element
|
||||
.filter(childNode => childNode !== overflowElementRef.current)
|
||||
// sum the widths to find the last visible element's index
|
||||
.reduce((acc, childNode, index) => {
|
||||
// use floor to prevent rounding errors
|
||||
acc.sumWidth += Math.floor(childNode.getBoundingClientRect().width);
|
||||
if (acc.sumWidth <= containingRect.width) {
|
||||
acc.nextIndexOfLastVisibleChild = index;
|
||||
}
|
||||
return acc;
|
||||
}, {
|
||||
// Include the overflow element's width to begin with. Doing this means
|
||||
// sometimes we'll show a dropdown with one item in it when it would fit,
|
||||
// but allowing this case dramatically simplifies the calculations we need
|
||||
// to do above.
|
||||
sumWidth: overflowElementRef.current ? overflowElementRef.current.getBoundingClientRect().width : 0,
|
||||
nextIndexOfLastVisibleChild: -1,
|
||||
});
|
||||
|
||||
setIndexOfLastVisibleChild(nextIndexOfLastVisibleChild);
|
||||
}, [windowSize, containerElementRef.current]);
|
||||
|
||||
return [indexOfLastVisibleChild, containerElementRef, invisibleStyle, overflowElementRef];
|
||||
}
|
||||
64
src/components/PostPreviewPane.jsx
Normal file
64
src/components/PostPreviewPane.jsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Button, Icon, IconButton } from '@edx/paragon';
|
||||
import { Close } from '@edx/paragon/icons';
|
||||
|
||||
import messages from '../discussions/posts/post-editor/messages';
|
||||
import HTMLLoader from './HTMLLoader';
|
||||
|
||||
function PostPreviewPane({
|
||||
htmlNode, intl, isPost, editExisting,
|
||||
}) {
|
||||
const [showPreviewPane, setShowPreviewPane] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
{showPreviewPane && (
|
||||
<div
|
||||
className={`w-100 p-2 bg-light-200 rounded box-shadow-down-1 post-preview ${isPost ? 'mt-2 mb-5' : 'my-3'}`}
|
||||
style={{ minHeight: '200px', wordBreak: 'break-word' }}
|
||||
>
|
||||
<IconButton
|
||||
onClick={() => setShowPreviewPane(false)}
|
||||
alt={intl.formatMessage(messages.actionsAlt)}
|
||||
src={Close}
|
||||
iconAs={Icon}
|
||||
size="inline"
|
||||
className="float-right p-3"
|
||||
iconClassNames="icon-size"
|
||||
/>
|
||||
<HTMLLoader htmlNode={htmlNode} cssClassName="text-primary" />
|
||||
</div>
|
||||
)}
|
||||
<div className="d-flex justify-content-end">
|
||||
{!showPreviewPane
|
||||
&& (
|
||||
<Button
|
||||
variant="link"
|
||||
size="md"
|
||||
onClick={() => setShowPreviewPane(true)}
|
||||
className={`text-primary-500 px-0 ${editExisting && 'mb-4.5'}`}
|
||||
>
|
||||
{intl.formatMessage(messages.showPreviewButton)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
PostPreviewPane.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
htmlNode: PropTypes.node.isRequired,
|
||||
isPost: PropTypes.bool,
|
||||
editExisting: PropTypes.bool,
|
||||
};
|
||||
|
||||
PostPreviewPane.defaultProps = {
|
||||
isPost: false,
|
||||
editExisting: false,
|
||||
};
|
||||
|
||||
export default injectIntl(PostPreviewPane);
|
||||
@@ -1,41 +0,0 @@
|
||||
import React, {
|
||||
useEffect,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
function ScrollThreshold({ onScroll }) {
|
||||
const elementRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!elementRef.current) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// create the observer
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
onScroll();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
observer.observe(elementRef.current);
|
||||
|
||||
// cleanup callback
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [elementRef]);
|
||||
|
||||
return (
|
||||
<div ref={elementRef} />
|
||||
);
|
||||
}
|
||||
|
||||
ScrollThreshold.propTypes = {
|
||||
onScroll: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default ScrollThreshold;
|
||||
|
||||
86
src/components/Search.jsx
Normal file
86
src/components/Search.jsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import React, { useContext, useEffect } from 'react';
|
||||
|
||||
import camelCase from 'lodash/camelCase';
|
||||
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 '../discussions/common/context';
|
||||
import { setUsernameSearch } from '../discussions/learners/data';
|
||||
import { setSearchQuery } from '../discussions/posts/data';
|
||||
import postsMessages from '../discussions/posts/post-actions-bar/messages';
|
||||
import { setFilter as setTopicFilter } from '../discussions/topics/data/slices';
|
||||
|
||||
function Search({ intl }) {
|
||||
const dispatch = useDispatch();
|
||||
const { page } = useContext(DiscussionContext);
|
||||
const postSearch = useSelector(({ threads }) => threads.filters.search);
|
||||
const topicSearch = useSelector(({ topics }) => topics.filter);
|
||||
const learnerSearch = useSelector(({ learners }) => learners.usernameSearch);
|
||||
const isPostSearch = ['posts', 'my-posts'].includes(page);
|
||||
const isTopicSearch = 'topics'.includes(page);
|
||||
let searchValue = '';
|
||||
let currentValue = '';
|
||||
if (isPostSearch) {
|
||||
currentValue = postSearch;
|
||||
} else if (isTopicSearch) {
|
||||
currentValue = topicSearch;
|
||||
} else {
|
||||
currentValue = learnerSearch;
|
||||
}
|
||||
|
||||
const onClear = () => {
|
||||
dispatch(setSearchQuery(''));
|
||||
dispatch(setTopicFilter(''));
|
||||
dispatch(setUsernameSearch(''));
|
||||
};
|
||||
|
||||
const onChange = (query) => {
|
||||
searchValue = query;
|
||||
};
|
||||
|
||||
const onSubmit = (query) => {
|
||||
if (query === '') {
|
||||
return;
|
||||
}
|
||||
if (isPostSearch) {
|
||||
dispatch(setSearchQuery(query));
|
||||
} else if (page === 'topics') {
|
||||
dispatch(setTopicFilter(query));
|
||||
} else if (page === 'learners') {
|
||||
dispatch(setUsernameSearch(query));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => onClear(), [page]);
|
||||
return (
|
||||
<>
|
||||
<SearchField.Advanced
|
||||
onClear={onClear}
|
||||
onChange={onChange}
|
||||
onSubmit={onSubmit}
|
||||
value={currentValue}
|
||||
>
|
||||
<SearchField.Label />
|
||||
<SearchField.Input
|
||||
style={{ paddingRight: '1rem' }}
|
||||
placeholder={intl.formatMessage(postsMessages.search, { page: camelCase(page) })}
|
||||
/>
|
||||
<span className="mt-auto mb-auto mr-2.5 pointer-cursor-hover">
|
||||
<Icon
|
||||
src={SearchIcon}
|
||||
onClick={() => onSubmit(searchValue)}
|
||||
/>
|
||||
</span>
|
||||
</SearchField.Advanced>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Search.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(Search);
|
||||
54
src/components/SearchInfo.jsx
Normal file
54
src/components/SearchInfo.jsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Button, Icon } from '@edx/paragon';
|
||||
import { Search } from '@edx/paragon/icons';
|
||||
|
||||
import { RequestStatus } from '../data/constants';
|
||||
import messages from '../discussions/posts/post-actions-bar/messages';
|
||||
|
||||
function SearchInfo({
|
||||
intl,
|
||||
count,
|
||||
text,
|
||||
loadingStatus,
|
||||
onClear,
|
||||
textSearchRewrite,
|
||||
}) {
|
||||
return (
|
||||
<div className="d-flex flex-row border-bottom border-light-400">
|
||||
<Icon src={Search} className="justify-content-start ml-3.5 mr-2 mb-2 mt-2.5" />
|
||||
<Button variant="" size="inline" className="text-justify p-2">
|
||||
{loadingStatus === RequestStatus.SUCCESSFUL && (
|
||||
textSearchRewrite ? intl.formatMessage(messages.searchRewriteInfo, {
|
||||
searchString: text,
|
||||
count,
|
||||
textSearchRewrite,
|
||||
})
|
||||
: intl.formatMessage(messages.searchInfo, { count, text })
|
||||
)}
|
||||
{loadingStatus !== RequestStatus.SUCCESSFUL && intl.formatMessage(messages.searchInfoSearching)}
|
||||
</Button>
|
||||
<Button variant="link" size="inline" className="ml-auto mr-3" onClick={onClear} style={{ minWidth: '26%' }}>
|
||||
{intl.formatMessage(messages.clearSearch)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
SearchInfo.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
count: PropTypes.number.isRequired,
|
||||
text: PropTypes.string.isRequired,
|
||||
loadingStatus: PropTypes.string.isRequired,
|
||||
textSearchRewrite: PropTypes.string,
|
||||
onClear: PropTypes.func,
|
||||
};
|
||||
|
||||
SearchInfo.defaultProps = {
|
||||
onClear: () => {},
|
||||
textSearchRewrite: null,
|
||||
};
|
||||
|
||||
export default injectIntl(SearchInfo);
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { Editor } from '@tinymce/tinymce-react';
|
||||
import { useParams } from 'react-router';
|
||||
@@ -6,6 +6,11 @@ import { useParams } from 'react-router';
|
||||
// eslint-disable-next-line no-unused-vars,import/no-extraneous-dependencies
|
||||
import tinymce from 'tinymce/tinymce';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { ActionRow, AlertModal, Button } from '@edx/paragon';
|
||||
|
||||
import { MAX_UPLOAD_FILE_SIZE } from '../data/constants';
|
||||
import messages from '../discussions/messages';
|
||||
import { uploadFile } from '../discussions/posts/data/api';
|
||||
|
||||
import 'tinymce/plugins/code';
|
||||
@@ -17,12 +22,16 @@ import 'tinymce/icons/default';
|
||||
import 'tinymce/skins/ui/oxide/skin.css';
|
||||
// importing the plugin js.
|
||||
import 'tinymce/plugins/autolink';
|
||||
import 'tinymce/plugins/autoresize';
|
||||
import 'tinymce/plugins/autosave';
|
||||
import 'tinymce/plugins/codesample';
|
||||
import 'tinymce/plugins/image';
|
||||
import 'tinymce/plugins/imagetools';
|
||||
import 'tinymce/plugins/link';
|
||||
import 'tinymce/plugins/lists';
|
||||
import 'tinymce/plugins/emoticons';
|
||||
import 'tinymce/plugins/emoticons/js/emojis';
|
||||
import 'tinymce/plugins/charmap';
|
||||
/* eslint import/no-webpack-loader-syntax: off */
|
||||
// eslint-disable-next-line import/no-unresolved
|
||||
import edxBrandCss from '!!raw-loader!sass-loader!../index.scss';
|
||||
@@ -31,6 +40,7 @@ import contentCss from '!!raw-loader!tinymce/skins/content/default/content.min.c
|
||||
// eslint-disable-next-line import/no-unresolved
|
||||
import contentUiCss from '!!raw-loader!tinymce/skins/ui/oxide/content.min.css';
|
||||
|
||||
/* istanbul ignore next */
|
||||
const setup = (editor) => {
|
||||
editor.ui.registry.addButton('openedx_code', {
|
||||
icon: 'sourcecode',
|
||||
@@ -46,17 +56,29 @@ const setup = (editor) => {
|
||||
});
|
||||
};
|
||||
|
||||
/* istanbul ignore next */
|
||||
export default function TinyMCEEditor(props) {
|
||||
// note that skin and content_css is disabled to avoid the normal
|
||||
// loading process and is instead loaded as a string via content_style
|
||||
|
||||
const { courseId, postId } = useParams();
|
||||
|
||||
const [showImageWarning, setShowImageWarning] = useState(false);
|
||||
const intl = useIntl();
|
||||
const uploadHandler = async (blobInfo, success, failure) => {
|
||||
try {
|
||||
const blob = blobInfo.blob();
|
||||
const imageSize = blobInfo.blob().size / 1024;
|
||||
if (imageSize > MAX_UPLOAD_FILE_SIZE) {
|
||||
failure(`Images size should not exceed ${MAX_UPLOAD_FILE_SIZE} KB`);
|
||||
return;
|
||||
}
|
||||
const filename = blobInfo.filename();
|
||||
const { location } = await uploadFile(blob, filename, courseId, postId || 'root');
|
||||
const img = new Image();
|
||||
img.onload = function () {
|
||||
if (img.height > 999 || img.width > 999) { setShowImageWarning(true); }
|
||||
};
|
||||
img.src = location;
|
||||
success(location);
|
||||
} catch (e) {
|
||||
failure(e.toString(), { remove: true });
|
||||
@@ -72,32 +94,54 @@ export default function TinyMCEEditor(props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Editor
|
||||
init={{
|
||||
skin: false,
|
||||
menubar: false,
|
||||
branding: false,
|
||||
contextmenu: false,
|
||||
browser_spellcheck: true,
|
||||
a11y_advanced_options: true,
|
||||
autosave_interval: '1s',
|
||||
autosave_restore_when_empty: true,
|
||||
plugins: 'autosave codesample link lists image imagetools code',
|
||||
toolbar: 'formatselect | bold italic underline'
|
||||
+ ' | link blockquote openedx_code image'
|
||||
+ ' | bullist numlist outdent indent'
|
||||
+ ' | removeformat'
|
||||
+ ' | openedx_html'
|
||||
+ ' | undo redo',
|
||||
content_css: false,
|
||||
content_style: contentStyle,
|
||||
body_class: 'm-2',
|
||||
default_link_target: '_blank',
|
||||
target_list: false,
|
||||
images_upload_handler: uploadHandler,
|
||||
setup,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
<>
|
||||
<Editor
|
||||
init={{
|
||||
skin: false,
|
||||
menubar: false,
|
||||
branding: 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',
|
||||
toolbar: 'undo redo'
|
||||
+ ' | formatselect | bold italic underline'
|
||||
+ ' | link blockquote openedx_code image'
|
||||
+ ' | bullist numlist outdent indent'
|
||||
+ ' | removeformat'
|
||||
+ ' | openedx_html'
|
||||
+ ' | emoticons'
|
||||
+ ' | charmap',
|
||||
content_css: false,
|
||||
content_style: contentStyle,
|
||||
body_class: 'm-2 text-editor',
|
||||
default_link_target: '_blank',
|
||||
target_list: false,
|
||||
images_upload_handler: uploadHandler,
|
||||
setup,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
<AlertModal
|
||||
title={intl.formatMessage(messages.imageWarningModalTitle)}
|
||||
isOpen={showImageWarning}
|
||||
onClose={() => setShowImageWarning(false)}
|
||||
isBlocking
|
||||
footerNode={(
|
||||
<ActionRow>
|
||||
<Button variant="danger" onClick={() => setShowImageWarning(false)}>
|
||||
{intl.formatMessage(messages.imageWarningDismissButton)}
|
||||
</Button>
|
||||
</ActionRow>
|
||||
)}
|
||||
>
|
||||
<p>
|
||||
{intl.formatMessage(messages.imageWarningMessage)}
|
||||
</p>
|
||||
</AlertModal>
|
||||
</>
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
20
src/components/icons/InsertLink.jsx
Normal file
20
src/components/icons/InsertLink.jsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function InsertLink() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
26
src/components/icons/Issue.jsx
Normal file
26
src/components/icons/Issue.jsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function Issue() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="28"
|
||||
height="28"
|
||||
fill="none"
|
||||
viewBox="0 0 28 28"
|
||||
>
|
||||
<path
|
||||
fill="#F2F0EF"
|
||||
d="M0 14C0 6.268 6.268 0 14 0s14 6.268 14 14-6.268 14-14 14S0 21.732 0 14z"
|
||||
/>
|
||||
<path
|
||||
fill="#2D494E"
|
||||
d="M14 2.333C7.56 2.333 2.333 7.56 2.333 14c0 6.44 5.227 11.667 11.667 11.667 6.44 0 11.667-5.227 11.667-11.667C25.667 7.56 20.44 2.334 14 2.334z"
|
||||
/>
|
||||
<path
|
||||
fill="#fff"
|
||||
d="M12.833 22.167h2.334v-2.334h-2.334v2.334zM16.532 14.198l1.05-1.073a3.713 3.713 0 001.085-2.625A4.665 4.665 0 0014 5.833 4.665 4.665 0 009.333 10.5h2.334A2.34 2.34 0 0114 8.167a2.34 2.34 0 012.333 2.333c0 .642-.256 1.225-.688 1.645l-1.447 1.47a4.696 4.696 0 00-1.365 3.302v.583h2.334c0-1.75.525-2.45 1.365-3.302z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
18
src/components/icons/People.jsx
Normal file
18
src/components/icons/People.jsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function People() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="none"
|
||||
viewBox="0 0 16 16"
|
||||
>
|
||||
<path
|
||||
fill="#707070"
|
||||
d="M11.072 7.332a1.992 1.992 0 001.993-2 1.997 1.997 0 10-3.993 0c0 1.107.893 2 2 2zm-5.334 0a1.992 1.992 0 001.994-2 1.997 1.997 0 10-3.993 0c0 1.107.893 2 2 2zm0 1.333c-1.553 0-4.666.78-4.666 2.334v1.666h9.333V11c0-1.554-3.113-2.334-4.667-2.334zm5.334 0c-.194 0-.414.014-.647.034.773.56 1.313 1.313 1.313 2.3v1.666h4V11c0-1.554-3.113-2.334-4.666-2.334z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
20
src/components/icons/PushPin.jsx
Normal file
20
src/components/icons/PushPin.jsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function PushPin() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
d="M16 9V4H18V2H6V4H8V9C8 10.66 6.66 12 5 12V14H10.97V21L11.97 22L12.97 21V14H19V12C17.34 12 16 10.66 16 9Z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
26
src/components/icons/Question.jsx
Normal file
26
src/components/icons/Question.jsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function Question() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="28"
|
||||
height="28"
|
||||
fill="none"
|
||||
viewBox="0 0 28 28"
|
||||
>
|
||||
<path
|
||||
fill="#fff"
|
||||
d="M0 14.001c0-7.732 6.268-14 14-14s14 6.268 14 14-6.268 14-14 14-14-6.268-14-14z"
|
||||
/>
|
||||
<path
|
||||
fill="#2D494E"
|
||||
d="M14 2.334c-6.44 0-11.667 5.227-11.667 11.667 0 6.44 5.227 11.667 11.667 11.667 6.44 0 11.666-5.227 11.666-11.667 0-6.44-5.226-11.667-11.666-11.667z"
|
||||
/>
|
||||
<path
|
||||
fill="#fff"
|
||||
d="M12.833 22.168h2.333v-2.334h-2.333v2.334zM16.531 14.2l1.05-1.074a3.712 3.712 0 001.085-2.625A4.665 4.665 0 0014 5.834a4.665 4.665 0 00-4.667 4.667h2.333A2.34 2.34 0 0114 8.168a2.34 2.34 0 012.333 2.333c0 .642-.257 1.225-.688 1.645l-1.447 1.47a4.696 4.696 0 00-1.365 3.302v.583h2.333c0-1.75.525-2.45 1.365-3.302z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { _extends } from './common';
|
||||
|
||||
export default function QuestionAnswer(props) {
|
||||
return /* #__PURE__ */React.createElement('svg', _extends({
|
||||
width: 20,
|
||||
height: 20,
|
||||
viewBox: '0 0 20 20',
|
||||
fill: 'none',
|
||||
xmlns: 'http://www.w3.org/2000/svg',
|
||||
}, props), /* #__PURE__ */React.createElement('path', {
|
||||
d: 'M16.7371 4.00002H14.2371V11.5H3.4038V14H13.4038L16.7371 17.3334V4.00002ZM12.5705 9.83335V0.666687H0.0704651V13.1667L3.4038 9.83335H12.5705Z',
|
||||
fill: 'currentColor',
|
||||
}));
|
||||
}
|
||||
18
src/components/icons/QuestionAnswer.jsx
Normal file
18
src/components/icons/QuestionAnswer.jsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function QuestionAnswer() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="21"
|
||||
height="20"
|
||||
fill="none"
|
||||
viewBox="0 0 21 20"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M18.737 5h-2.5v7.5H5.404V15h10l3.333 3.333V5zm-4.166 5.833V1.667H2.07v12.5l3.333-3.334h9.166z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { _extends } from './common';
|
||||
|
||||
export default function QuestionAnswerOutline(props) {
|
||||
return /* #__PURE__ */React.createElement('svg', _extends({
|
||||
width: 20,
|
||||
height: 20,
|
||||
viewBox: '0 0 20 20',
|
||||
fill: 'none',
|
||||
xmlns: 'http://www.w3.org/2000/svg',
|
||||
}, props), /* #__PURE__ */React.createElement('path', {
|
||||
d: 'M 16.7371 4 H 14.2371 V 11.5 H 3.4038 V 14 H 13.4038 L 16.7371 17.3334 V 4 Z M 12.5705 9.8333 V 0.6667 H 0.0705 V 13.1667 L 3.4038 9.8333 H 12.5705 Z M 11.465 8.618 H 1.038 V 1.683 H 11.465Z',
|
||||
fill: 'currentColor',
|
||||
}));
|
||||
}
|
||||
18
src/components/icons/QuestionAnswerOutline.jsx
Normal file
18
src/components/icons/QuestionAnswerOutline.jsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function QuestionAnswerOutline() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
fill="none"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12.6 3.267v6.067H4.08l-.512.512-.502.502v-7.08H12.6zm.867-1.733H2.198a.87.87 0 00-.867.867v12.134L4.8 11.068h8.668a.87.87 0 00.866-.867v-7.8a.87.87 0 00-.867-.867zM17.8 5h-1.733v7.8H4.799v1.734c0 .476.39.867.867.867H15.2l3.467 3.466v-13A.87.87 0 0017.8 5z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { _extends } from './common';
|
||||
|
||||
export default function StarFilled(props) {
|
||||
return /* #__PURE__ */React.createElement('svg', _extends({
|
||||
width: 20,
|
||||
height: 20,
|
||||
viewBox: '0 0 20 20',
|
||||
fill: 'none',
|
||||
xmlns: 'http://www.w3.org/2000/svg',
|
||||
}, props), /* #__PURE__ */React.createElement('path', {
|
||||
d: 'M8.4038 13.3917L13.5538 16.5L12.1871 10.6417L16.7371 6.70002L10.7455 6.19169L8.4038 0.666687L6.06213 6.19169L0.0704651 6.70002L4.62047 10.6417L3.2538 16.5L8.4038 13.3917Z',
|
||||
fill: 'currentColor',
|
||||
}));
|
||||
}
|
||||
18
src/components/icons/StarFilled.jsx
Normal file
18
src/components/icons/StarFilled.jsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function StarFilled() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="21"
|
||||
height="20"
|
||||
fill="none"
|
||||
viewBox="0 0 21 20"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M10.404 14.392l5.15 3.108-1.367-5.858 4.55-3.942-5.991-.508-2.342-5.525-2.342 5.525L2.07 7.7l4.55 3.942L5.254 17.5l5.15-3.108z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { _extends } from './common';
|
||||
|
||||
export default function StarOutline(props) {
|
||||
return /* #__PURE__ */React.createElement('svg', _extends({
|
||||
width: 20,
|
||||
height: 20,
|
||||
viewBox: '0 0 20 20',
|
||||
fill: 'none',
|
||||
xmlns: 'http://www.w3.org/2000/svg',
|
||||
}, props), /* #__PURE__ */React.createElement('path', {
|
||||
d: 'M16.7371 6.69999L10.7455 6.18332L8.4038 0.666656L6.06213 6.19166L0.0704651 6.69999L4.62047 10.6417L3.2538 16.5L8.4038 13.3917L13.5538 16.5L12.1955 10.6417L16.7371 6.69999ZM8.4038 11.8333L5.27047 13.725L6.1038 10.1583L3.33713 7.75832L6.98713 7.44166L8.4038 4.08332L9.8288 7.44999L13.4788 7.76666L10.7121 10.1667L11.5455 13.7333L8.4038 11.8333Z',
|
||||
fill: 'currentColor',
|
||||
}));
|
||||
}
|
||||
18
src/components/icons/StarOutline.jsx
Normal file
18
src/components/icons/StarOutline.jsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function StarOutline() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
fill="none"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M18.737 7.7l-5.991-.517-2.342-5.516-2.342 5.525L2.07 7.7l4.55 3.942L5.254 17.5l5.15-3.108 5.15 3.108-1.359-5.858L18.737 7.7zm-8.333 5.133L7.27 14.725l.834-3.567-2.767-2.4 3.65-.316 1.417-3.359 1.425 3.367 3.65.317-2.767 2.4.834 3.566-3.142-1.9z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { _extends } from './common';
|
||||
|
||||
export default function ThumbUpFilled(props) {
|
||||
return /* #__PURE__ */React.createElement('svg', _extends({
|
||||
width: 20,
|
||||
height: 20,
|
||||
viewBox: '0 0 20 20',
|
||||
fill: 'none',
|
||||
xmlns: 'http://www.w3.org/2000/svg',
|
||||
}, props), /* #__PURE__ */React.createElement('path', {
|
||||
d: 'M11.2122 0.833344L5.23715 6.81668V17.5H15.4955L18.5705 10.3333V6.66668H11.6455L12.5788 2.18334L11.2122 0.833344ZM0.237152 7.50001H3.57049V17.5H0.237152V7.50001Z',
|
||||
fill: 'currentColor',
|
||||
}));
|
||||
}
|
||||
18
src/components/icons/ThumbUpFilled.jsx
Normal file
18
src/components/icons/ThumbUpFilled.jsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function ThumbUpFilled() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="21"
|
||||
height="20"
|
||||
fill="none"
|
||||
viewBox="0 0 21 20"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12.212.833L6.237 6.817V17.5h10.258l3.075-7.167V6.667h-6.925l.934-4.484-1.367-1.35zM1.237 7.5H4.57v10H1.237v-10z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { _extends } from './common';
|
||||
|
||||
export default function ThumbUpOutline(props) {
|
||||
return /* #__PURE__ */React.createElement('svg', _extends({
|
||||
width: 20,
|
||||
height: 20,
|
||||
viewBox: '0 0 20 20',
|
||||
fill: 'none',
|
||||
xmlns: 'http://www.w3.org/2000/svg',
|
||||
}, props), /* #__PURE__ */React.createElement('path', {
|
||||
d: 'M18.5705 6.66668V10.3333L15.4955 17.5L5.23715 17.5L5.23715 6.81668L11.2122 0.833344L12.5788 2.18334L11.6455 6.66668L18.5705 6.66668ZM6.90382 7.50834L6.90382 15.8333L14.3955 15.8333L16.9038 9.99168V8.33334L9.59548 8.33334L10.5205 3.88334L6.90382 7.50834Z M3.57049 17.5H0.237152L0.237152 7.50001H3.57049L3.57049 17.5Z',
|
||||
fill: 'currentColor',
|
||||
}));
|
||||
}
|
||||
21
src/components/icons/ThumbUpOutline.jsx
Normal file
21
src/components/icons/ThumbUpOutline.jsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function ThumbUpOutline() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
fill="none"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
d="M19.57 6.667v3.666L16.495 17.5H6.238V6.817L12.212.833l1.367 1.35-.934 4.484h6.925zm-11.666.841v8.325h7.492l2.508-5.841V8.333h-7.309l.925-4.45-3.616 3.625z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
<path fill="currentColor" d="M4.57 17.5H1.237v-10H4.57v10z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
/* eslint-disable no-underscore-dangle */
|
||||
/* eslint-disable no-func-assign */
|
||||
/* eslint-disable prefer-object-spread */
|
||||
/* eslint-disable prefer-rest-params */
|
||||
/* eslint-disable no-param-reassign */
|
||||
/* eslint-disable no-restricted-syntax */
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export function _extends() {
|
||||
_extends = Object.assign || function (target) {
|
||||
for (let i = 1; i < arguments.length; i++) {
|
||||
const source = arguments[i];
|
||||
for (const key in source) {
|
||||
if (Object.prototype.hasOwnProperty.call(source, key)) {
|
||||
target[key] = source[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
return target;
|
||||
};
|
||||
return _extends.apply(this, arguments);
|
||||
}
|
||||
@@ -1,3 +1,8 @@
|
||||
export { default as InsertLink } from './InsertLink';
|
||||
export { default as Issue } from './Issue';
|
||||
export { default as People } from './People';
|
||||
export { default as PushPin } from './PushPin';
|
||||
export { default as Question } from './Question';
|
||||
export { default as QuestionAnswer } from './QuestionAnswer';
|
||||
export { default as QuestionAnswerOutline } from './QuestionAnswerOutline';
|
||||
export { default as StarFilled } from './StarFilled';
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export { default as PostActionsBar } from '../discussions/posts/post-actions-bar/PostActionsBar';
|
||||
export { default as Search } from './Search';
|
||||
export { default as TinyMCEEditor } from './TinyMCEEditor';
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
import { API_BASE_URL } from './constants';
|
||||
import { getApiBaseUrl } from './constants';
|
||||
|
||||
export const blocksAPIURL = `${API_BASE_URL}/api/courses/v1/blocks/`;
|
||||
export const getBlocksAPIURL = () => `${getApiBaseUrl()}/api/courses/v1/blocks/`;
|
||||
export async function getCourseBlocks(courseId, username) {
|
||||
const params = {
|
||||
course_id: courseId,
|
||||
@@ -14,6 +14,6 @@ export async function getCourseBlocks(courseId, username) {
|
||||
student_view_data: 'discussion',
|
||||
};
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(blocksAPIURL, { params });
|
||||
.get(getBlocksAPIURL(), { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
export const API_BASE_URL = getConfig().LMS_BASE_URL;
|
||||
export const getApiBaseUrl = () => getConfig().LMS_BASE_URL;
|
||||
|
||||
/**
|
||||
* Enum for thread types.
|
||||
@@ -45,6 +45,7 @@ export const ContentActions = {
|
||||
PIN: 'pinned',
|
||||
ENDORSE: 'endorsed',
|
||||
CLOSE: 'closed',
|
||||
COPY_LINK: 'copy_link',
|
||||
REPORT: 'abuse_flagged',
|
||||
DELETE: 'delete',
|
||||
FOLLOWING: 'following',
|
||||
@@ -73,9 +74,9 @@ export const RequestStatus = {
|
||||
* @readonly
|
||||
* @enum {string}
|
||||
*/
|
||||
export const AvatarBorderAndLabelColors = {
|
||||
Staff: 'warning-700',
|
||||
'Community TA': 'success-700',
|
||||
export const AvatarOutlineAndLabelColors = {
|
||||
Staff: 'staff-color',
|
||||
'Community TA': 'TA-color',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -89,16 +90,6 @@ export const ThreadOrdering = {
|
||||
BY_VOTE_COUNT: 'voteCount',
|
||||
};
|
||||
|
||||
/**
|
||||
* Enum for thread view status filtering.
|
||||
* @readonly
|
||||
* @enum {string}
|
||||
*/
|
||||
export const ThreadViewStatus = {
|
||||
UNREAD: 'unread',
|
||||
UNANSWERED: 'unanswered',
|
||||
};
|
||||
|
||||
/**
|
||||
* Enum for filtering posts by status.
|
||||
* @readonly
|
||||
@@ -110,6 +101,7 @@ export const PostsStatusFilter = {
|
||||
FOLLOWING: 'statusFollowing',
|
||||
REPORTED: 'statusReported',
|
||||
UNANSWERED: 'statusUnanswered',
|
||||
UNRESPONDED: 'statusUnresponded',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -132,17 +124,7 @@ export const TopicOrdering = {
|
||||
export const LearnersOrdering = {
|
||||
BY_FLAG: 'flagged',
|
||||
BY_LAST_ACTIVITY: 'activity',
|
||||
};
|
||||
|
||||
/**
|
||||
* Enum for Learner content tabs
|
||||
* @readonly
|
||||
* @enum {string}
|
||||
*/
|
||||
export const LearnerTabs = {
|
||||
POSTS: 'posts',
|
||||
COMMENTS: 'comments',
|
||||
RESPONSES: 'responses',
|
||||
BY_RECENCY: 'recency',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -162,12 +144,7 @@ export const Routes = {
|
||||
},
|
||||
LEARNERS: {
|
||||
PATH: `${BASE_PATH}/learners`,
|
||||
LEARNER: `${BASE_PATH}/learners/:learnerUsername`,
|
||||
TABS: {
|
||||
posts: `${BASE_PATH}/learners/:learnerUsername/${LearnerTabs.POSTS}`,
|
||||
responses: `${BASE_PATH}/learners/:learnerUsername/${LearnerTabs.RESPONSES}`,
|
||||
comments: `${BASE_PATH}/learners/:learnerUsername/${LearnerTabs.COMMENTS}`,
|
||||
},
|
||||
POSTS: `${BASE_PATH}/learners/:learnerUsername/posts(/:postId)?`,
|
||||
},
|
||||
POSTS: {
|
||||
PATH: `${BASE_PATH}/topics/:topicId`,
|
||||
@@ -183,6 +160,7 @@ export const Routes = {
|
||||
`${BASE_PATH}/topics/:topicId/posts/:postId/edit`,
|
||||
`${BASE_PATH}/posts/:postId/edit`,
|
||||
`${BASE_PATH}/my-posts/:postId/edit`,
|
||||
`${BASE_PATH}/learners/:learnerUsername/posts/:postId/edit`,
|
||||
],
|
||||
},
|
||||
COMMENTS: {
|
||||
@@ -191,6 +169,7 @@ export const Routes = {
|
||||
`${BASE_PATH}/topics/:topicId/posts/:postId`,
|
||||
`${BASE_PATH}/posts/:postId`,
|
||||
`${BASE_PATH}/my-posts/:postId`,
|
||||
`${BASE_PATH}/learners/:learnerUsername/posts/:postId`,
|
||||
],
|
||||
PAGE: `${BASE_PATH}/:page`,
|
||||
PAGES: {
|
||||
@@ -198,6 +177,7 @@ export const Routes = {
|
||||
topics: `${BASE_PATH}/topics/:topicId/posts/:postId`,
|
||||
posts: `${BASE_PATH}/posts/:postId`,
|
||||
'my-posts': `${BASE_PATH}/my-posts/:postId`,
|
||||
learners: `${BASE_PATH}/learners/:learnerUsername/posts/:postId`,
|
||||
},
|
||||
},
|
||||
TOPICS: {
|
||||
@@ -213,10 +193,20 @@ export const Routes = {
|
||||
},
|
||||
};
|
||||
|
||||
export const PostsPages = {
|
||||
category: `${BASE_PATH}/category/:category/posts`,
|
||||
topics: `${BASE_PATH}/topics/:topicId/posts`,
|
||||
posts: `${BASE_PATH}/posts`,
|
||||
'my-posts': `${BASE_PATH}/my-posts`,
|
||||
learners: `${BASE_PATH}/learners/:learnerUsername/posts`,
|
||||
};
|
||||
|
||||
export const ALL_ROUTES = []
|
||||
.concat([Routes.TOPICS.CATEGORY_POST, Routes.TOPICS.CATEGORY])
|
||||
.concat(Routes.COMMENTS.PATH)
|
||||
.concat(Routes.TOPICS.PATH)
|
||||
.concat([Routes.POSTS.ALL_POSTS, Routes.POSTS.MY_POSTS])
|
||||
.concat([Routes.LEARNERS.LEARNER, Routes.LEARNERS.PATH])
|
||||
.concat([Routes.LEARNERS.POSTS, Routes.LEARNERS.PATH])
|
||||
.concat([Routes.DISCUSSIONS.PATH]);
|
||||
|
||||
export const MAX_UPLOAD_FILE_SIZE = 1024;
|
||||
|
||||
@@ -7,9 +7,11 @@ import { initializeMockApp } from '@edx/frontend-platform/testing';
|
||||
import { initializeStore } from '../store';
|
||||
import { executeThunk } from '../test-utils';
|
||||
import { getBlocksAPIResponse } from './__factories__';
|
||||
import { blocksAPIURL } from './api';
|
||||
import { getBlocksAPIURL } from './api';
|
||||
import { RequestStatus } from './constants';
|
||||
import { fetchCourseBlocks } from './thunks';
|
||||
|
||||
const blocksAPIURL = getBlocksAPIURL();
|
||||
const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||
|
||||
let axiosMock;
|
||||
@@ -35,7 +37,7 @@ describe('Course blocks data layer tests', () => {
|
||||
axiosMock.reset();
|
||||
});
|
||||
|
||||
test('successfully processes block data', async () => {
|
||||
it('successfully processes block data', async () => {
|
||||
axiosMock.onGet(blocksAPIURL)
|
||||
.reply(200, getBlocksAPIResponse());
|
||||
|
||||
@@ -77,4 +79,29 @@ describe('Course blocks data layer tests', () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('handles network error', async () => {
|
||||
axiosMock.onGet(blocksAPIURL).networkError();
|
||||
|
||||
await executeThunk(fetchCourseBlocks(courseId, 'test-user'), store.dispatch, store.getState);
|
||||
|
||||
expect(store.getState().blocks.status)
|
||||
.toBe(RequestStatus.FAILED);
|
||||
});
|
||||
it('handles network timeout', async () => {
|
||||
axiosMock.onGet(blocksAPIURL).timeout();
|
||||
|
||||
await executeThunk(fetchCourseBlocks(courseId, 'test-user'), store.dispatch, store.getState);
|
||||
|
||||
expect(store.getState().blocks.status)
|
||||
.toBe(RequestStatus.FAILED);
|
||||
});
|
||||
it('handles access denied', async () => {
|
||||
axiosMock.onGet(blocksAPIURL).reply(403, {});
|
||||
|
||||
await executeThunk(fetchCourseBlocks(courseId, 'test-user'), store.dispatch, store.getState);
|
||||
|
||||
expect(store.getState().blocks.status)
|
||||
.toBe(RequestStatus.DENIED);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
|
||||
import { selectDiscussionProvider } from '../discussions/data/selectors';
|
||||
import { selectDiscussionProvider, selectGroupAtSubsection } from '../discussions/data/selectors';
|
||||
import { DiscussionProvider } from './constants';
|
||||
|
||||
export const selectTopicContext = (topicId) => (state) => state.blocks.topics[topicId];
|
||||
@@ -14,6 +14,18 @@ export const selectorForUnitSubsection = createSelector(
|
||||
blocks => key => blocks[blocks[key]?.parent],
|
||||
);
|
||||
|
||||
// If subsection grouping is enabled, and the current selection is a unit, then get the current subsection.
|
||||
export const selectCurrentCategoryGrouping = createSelector(
|
||||
selectDiscussionProvider,
|
||||
selectGroupAtSubsection,
|
||||
selectBlocks,
|
||||
(provider, groupAtSubsection, blocks) => blockId => (
|
||||
(provider !== 'openedx' || !groupAtSubsection || blocks[blockId]?.type !== 'vertical')
|
||||
? blockId
|
||||
: blocks[blockId].parent
|
||||
),
|
||||
);
|
||||
|
||||
export const selectChapters = (state) => state.blocks.chapters;
|
||||
export const selectTopicsUnderCategory = createSelector(
|
||||
selectDiscussionProvider,
|
||||
@@ -32,4 +44,10 @@ export const selectSequences = createSelector(
|
||||
(chapterIds, blocks) => chapterIds?.flatMap(cId => blocks[cId].children.map(seqId => blocks[seqId])) || [],
|
||||
);
|
||||
|
||||
export const selectArchivedTopics = createSelector(
|
||||
state => state.topics.topics,
|
||||
state => state.topics.archivedIds || [],
|
||||
(topics, ids) => ids.map(id => topics[id]),
|
||||
);
|
||||
|
||||
export const selectTopicIds = () => (state) => state.blocks.chapters;
|
||||
|
||||
@@ -6,9 +6,7 @@ ensureConfig([
|
||||
'LMS_BASE_URL',
|
||||
], 'Comments API service');
|
||||
|
||||
const apiBaseUrl = getConfig().LMS_BASE_URL;
|
||||
|
||||
export const getCohortsApiUrl = courseId => `${apiBaseUrl}/api/cohorts/v1/courses/${courseId}/cohorts/`;
|
||||
export const getCohortsApiUrl = courseId => `${getConfig().LMS_BASE_URL}/api/cohorts/v1/courses/${courseId}/cohorts/`;
|
||||
|
||||
export async function getCourseCohorts(courseId) {
|
||||
const params = snakeCaseObject({ courseId });
|
||||
|
||||
@@ -1,38 +1,43 @@
|
||||
import React, { useEffect } from 'react';
|
||||
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 { ensureConfig, getConfig } from '@edx/frontend-platform';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Button, Spinner } from '@edx/paragon';
|
||||
import {
|
||||
Button, Icon, IconButton, Spinner,
|
||||
} from '@edx/paragon';
|
||||
import { ArrowBack } from '@edx/paragon/icons';
|
||||
|
||||
import { EndorsementStatus, ThreadType } from '../../data/constants';
|
||||
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 { markThreadAsRead } from '../posts/data/thunks';
|
||||
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';
|
||||
|
||||
ensureConfig(['POST_MARK_AS_READ_DELAY'], 'Comment thread view');
|
||||
|
||||
function usePost(postId) {
|
||||
const dispatch = useDispatch();
|
||||
const thread = useSelector(selectThread(postId));
|
||||
|
||||
useEffect(() => {
|
||||
const markReadTimer = setTimeout(() => {
|
||||
if (thread && !thread.read) {
|
||||
dispatch(markThreadAsRead(postId));
|
||||
}
|
||||
}, getConfig().POST_MARK_AS_READ_DELAY);
|
||||
return () => {
|
||||
clearTimeout(markReadTimer);
|
||||
};
|
||||
if (thread && !thread.read) {
|
||||
dispatch(markThreadAsRead(postId));
|
||||
}
|
||||
}, [postId]);
|
||||
|
||||
return thread;
|
||||
}
|
||||
|
||||
@@ -64,6 +69,7 @@ function DiscussionCommentsView({
|
||||
postId,
|
||||
intl,
|
||||
endorsed,
|
||||
isClosed,
|
||||
}) {
|
||||
const {
|
||||
comments,
|
||||
@@ -71,41 +77,56 @@ function DiscussionCommentsView({
|
||||
isLoading,
|
||||
handleLoadMoreResponses,
|
||||
} = usePostComments(postId, endorsed);
|
||||
return (
|
||||
<div className="m-3">
|
||||
<div className="my-3">
|
||||
{endorsed === EndorsementStatus.ENDORSED
|
||||
? intl.formatMessage(messages.endorsedResponseCount, { num: comments.length })
|
||||
: intl.formatMessage(messages.responseCount, { num: comments.length })}
|
||||
</div>
|
||||
{comments.map(comment => (
|
||||
<Comment comment={comment} key={comment.id} postType={postType} />
|
||||
))}
|
||||
const sortedComments = useMemo(() => [...filterPosts(comments, 'endorsed'),
|
||||
...filterPosts(comments, 'unendorsed')], [comments]);
|
||||
|
||||
{hasMorePages && !isLoading && (
|
||||
<Button
|
||||
onClick={handleLoadMoreResponses}
|
||||
variant="link"
|
||||
block="true"
|
||||
className="card p-4"
|
||||
data-testid="load-more-comments"
|
||||
>
|
||||
{intl.formatMessage(messages.loadMoreResponses)}
|
||||
</Button>
|
||||
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>
|
||||
)}
|
||||
{isLoading
|
||||
|
||||
<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>
|
||||
)}
|
||||
</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,
|
||||
@@ -113,28 +134,82 @@ DiscussionCommentsView.propTypes = {
|
||||
};
|
||||
|
||||
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 (
|
||||
<>
|
||||
<div className="discussion-comments d-flex flex-column mt-3 mb-0 mx-3 p-4 card">
|
||||
{!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} />
|
||||
<ResponseEditor postId={postId} />
|
||||
{!thread.closed && <ResponseEditor postId={postId} /> }
|
||||
</div>
|
||||
{thread.type === ThreadType.DISCUSSION
|
||||
&& (
|
||||
{thread.type === ThreadType.DISCUSSION && (
|
||||
<DiscussionCommentsView
|
||||
postId={postId}
|
||||
intl={intl}
|
||||
postType={thread.type}
|
||||
endorsed={EndorsementStatus.DISCUSSION}
|
||||
isClosed={thread.closed}
|
||||
/>
|
||||
)}
|
||||
)}
|
||||
{thread.type === ThreadType.QUESTION && (
|
||||
<>
|
||||
<DiscussionCommentsView
|
||||
@@ -142,12 +217,14 @@ function CommentsView({ intl }) {
|
||||
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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -12,16 +12,20 @@ import { AppProvider } from '@edx/frontend-platform/react';
|
||||
|
||||
import { initializeStore } from '../../store';
|
||||
import { executeThunk } from '../../test-utils';
|
||||
import { courseConfigApiUrl } from '../data/api';
|
||||
import { DiscussionContext } from '../common/context';
|
||||
import { getCourseConfigApiUrl } from '../data/api';
|
||||
import { fetchCourseConfig } from '../data/thunks';
|
||||
import DiscussionContent from '../discussions-home/DiscussionContent';
|
||||
import { threadsApiUrl } from '../posts/data/api';
|
||||
import { getThreadsApiUrl } from '../posts/data/api';
|
||||
import { fetchThreads } from '../posts/data/thunks';
|
||||
import { commentsApiUrl } from './data/api';
|
||||
import { getCommentsApiUrl } from './data/api';
|
||||
|
||||
import '../posts/data/__factories__';
|
||||
import './data/__factories__';
|
||||
|
||||
const courseConfigApiUrl = getCourseConfigApiUrl();
|
||||
const commentsApiUrl = getCommentsApiUrl();
|
||||
const threadsApiUrl = getThreadsApiUrl();
|
||||
const discussionPostId = 'thread-1';
|
||||
const questionPostId = 'thread-2';
|
||||
const closedPostId = 'thread-2';
|
||||
@@ -81,23 +85,27 @@ function renderComponent(postId) {
|
||||
render(
|
||||
<IntlProvider locale="en">
|
||||
<AppProvider store={store}>
|
||||
<MemoryRouter initialEntries={[`/${courseId}/posts/${postId}`]}>
|
||||
<DiscussionContent />
|
||||
<Route
|
||||
path="*"
|
||||
render={({ location }) => {
|
||||
testLocation = location;
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
<DiscussionContext.Provider
|
||||
value={{ courseId }}
|
||||
>
|
||||
<MemoryRouter initialEntries={[`/${courseId}/posts/${postId}`]}>
|
||||
<DiscussionContent />
|
||||
<Route
|
||||
path="*"
|
||||
render={({ location }) => {
|
||||
testLocation = location;
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
</DiscussionContext.Provider>
|
||||
</AppProvider>
|
||||
</IntlProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('CommentsView', () => {
|
||||
beforeEach(async () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
@@ -142,7 +150,7 @@ describe('CommentsView', () => {
|
||||
)];
|
||||
});
|
||||
|
||||
await executeThunk(fetchThreads(courseId), store.dispatch, store.getState);
|
||||
executeThunk(fetchThreads(courseId), store.dispatch, store.getState);
|
||||
mockAxiosReturnPagedComments();
|
||||
mockAxiosReturnPagedCommentsResponses();
|
||||
});
|
||||
@@ -155,9 +163,10 @@ 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 });
|
||||
await act(async () => {
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: /add a response/i }),
|
||||
addResponseButtons[0],
|
||||
);
|
||||
});
|
||||
expect(screen.queryByTestId('tinymce-editor')).toBeInTheDocument();
|
||||
@@ -166,15 +175,17 @@ describe('CommentsView', () => {
|
||||
});
|
||||
expect(screen.queryByTestId('tinymce-editor')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
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 });
|
||||
await act(async () => {
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: /add a response/i }),
|
||||
responseButtons[0],
|
||||
);
|
||||
});
|
||||
act(() => {
|
||||
await act(() => {
|
||||
fireEvent.change(screen.getByTestId('tinymce-editor'), { target: { value: 'testing123' } });
|
||||
});
|
||||
|
||||
@@ -186,6 +197,13 @@ describe('CommentsView', () => {
|
||||
expect(screen.queryByTestId('tinymce-editor')).not.toBeInTheDocument();
|
||||
await waitFor(async () => expect(await screen.findByText('testing123', { exact: false })).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('should not allow posting a response on a closed post', async () => {
|
||||
renderComponent(closedPostId);
|
||||
await waitFor(() => screen.findByText('Thread-2', { exact: false }));
|
||||
expect(screen.queryByRole('button', { name: /add a response/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should allow posting a comment', async () => {
|
||||
renderComponent(discussionPostId);
|
||||
await waitFor(() => screen.findByText('comment number 1', { exact: false }));
|
||||
@@ -206,6 +224,17 @@ describe('CommentsView', () => {
|
||||
expect(screen.queryByTestId('tinymce-editor')).not.toBeInTheDocument();
|
||||
await waitFor(async () => expect(await screen.findByText('testing123', { exact: false })).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('should not allow posting a comment on a closed post', async () => {
|
||||
renderComponent(closedPostId);
|
||||
await waitFor(() => screen.findByText('thread-2', { exact: false }));
|
||||
await act(async () => {
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /add a comment/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow editing an existing comment', async () => {
|
||||
renderComponent(discussionPostId);
|
||||
await waitFor(() => screen.findByText('comment number 1', { exact: false }));
|
||||
@@ -231,7 +260,7 @@ describe('CommentsView', () => {
|
||||
|
||||
async function setupCourseConfig(reasonCodesEnabled = true) {
|
||||
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`).reply(200, {
|
||||
user_is_privileged: true,
|
||||
has_moderation_privileges: true,
|
||||
reason_codes_enabled: reasonCodesEnabled,
|
||||
editReasons: [
|
||||
{ code: 'reason-1', label: 'reason 1' },
|
||||
@@ -423,9 +452,9 @@ describe('CommentsView', () => {
|
||||
describe('for discussion thread', () => {
|
||||
const findLoadMoreCommentsButton = () => screen.findByTestId('load-more-comments');
|
||||
|
||||
it('shown spinner when post isn\'t loaded', async () => {
|
||||
it('shown post not found when post id does not belong to course', async () => {
|
||||
renderComponent('unloaded-id');
|
||||
expect(await screen.findByTestId('loading-indicator'))
|
||||
expect(await screen.findByText('Thread not found', { exact: true }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -4,16 +4,18 @@ import PropTypes from 'prop-types';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import * as timeago from 'timeago.js';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
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,
|
||||
intl,
|
||||
}) {
|
||||
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">
|
||||
@@ -22,8 +24,8 @@ function CommentIcons({
|
||||
onClick={handleLike}
|
||||
voted={comment.voted}
|
||||
/>
|
||||
<div className="d-flex flex-fill text-gray-500 justify-content-end mt-2" title={comment.createdAt}>
|
||||
{timeago.format(comment.createdAt, intl.locale)}
|
||||
<div className="d-flex flex-fill text-gray-500 justify-content-end" title={comment.createdAt}>
|
||||
{timeago.format(comment.createdAt, 'time-locale')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -37,7 +39,6 @@ CommentIcons.propTypes = {
|
||||
voted: PropTypes.bool,
|
||||
createdAt: PropTypes.string,
|
||||
}).isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(CommentIcons);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import classNames from 'classnames';
|
||||
@@ -7,9 +7,13 @@ 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 } from '../../common';
|
||||
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';
|
||||
@@ -23,6 +27,7 @@ function Comment({
|
||||
postType,
|
||||
comment,
|
||||
showFullThread = true,
|
||||
isClosedPost,
|
||||
intl,
|
||||
}) {
|
||||
const dispatch = useDispatch();
|
||||
@@ -34,94 +39,115 @@ function Comment({
|
||||
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 }));
|
||||
await dispatch(fetchThread(comment.threadId));
|
||||
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 }))
|
||||
);
|
||||
const commentClasses = classNames('d-flex flex-column card', { 'my-3': showFullThread });
|
||||
|
||||
return (
|
||||
<div className={commentClasses} data-testid={`comment-${comment.id}`}>
|
||||
<DeleteConfirmation
|
||||
isOpen={isDeleting}
|
||||
title={intl.formatMessage(messages.deleteResponseTitle)}
|
||||
description={intl.formatMessage(messages.deleteResponseDescription)}
|
||||
onClose={hideDeleteConfirmation}
|
||||
onDelete={() => {
|
||||
dispatch(removeComment(comment.id));
|
||||
hideDeleteConfirmation();
|
||||
}}
|
||||
/>
|
||||
<AlertBanner postType={postType} content={comment} />
|
||||
<div className="d-flex flex-column p-4">
|
||||
<CommentHeader comment={comment} actionHandlers={actionHandlers} postType={postType} />
|
||||
{isEditing
|
||||
? (
|
||||
<CommentEditor comment={comment} onCloseEditor={() => setEditing(false)} />
|
||||
)
|
||||
// eslint-disable-next-line react/no-danger
|
||||
: <div className="comment-body px-2" id="comment" dangerouslySetInnerHTML={{ __html: comment.renderedBody }} />}
|
||||
<CommentIcons
|
||||
comment={comment}
|
||||
following={comment.following}
|
||||
onLike={() => dispatch(editComment(comment.id, { voted: !comment.voted }))}
|
||||
createdAt={comment.createdAt}
|
||||
<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();
|
||||
}}
|
||||
/>
|
||||
<div className="d-flex my-2 flex-column">
|
||||
{/* 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 && (
|
||||
<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="my-4"
|
||||
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.loadMoreResponses)}
|
||||
{intl.formatMessage(messages.loadMoreComments)}
|
||||
</Button>
|
||||
)}
|
||||
{!isNested && showFullThread
|
||||
&& (
|
||||
isReplying
|
||||
? (
|
||||
<CommentEditor
|
||||
comment={{
|
||||
threadId: comment.threadId,
|
||||
parentId: comment.id,
|
||||
}}
|
||||
edit={false}
|
||||
onCloseEditor={() => setReplying(false)}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<Button className="d-flex flex-grow " variant="outline-secondary" onClick={() => setReplying(true)}>
|
||||
{intl.formatMessage(messages.addComment)}
|
||||
</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>
|
||||
);
|
||||
@@ -131,11 +157,13 @@ 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);
|
||||
|
||||
@@ -11,8 +11,14 @@ 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 { selectModerationSettings, selectUserIsPrivileged } from '../../data/selectors';
|
||||
import {
|
||||
selectModerationSettings,
|
||||
selectUserHasModerationPrivileges,
|
||||
selectUserIsGroupTa,
|
||||
selectUserIsStaff,
|
||||
} from '../../data/selectors';
|
||||
import { formikCompatibleHandler, isFormikFieldInvalid } from '../../utils';
|
||||
import { addComment, editComment } from '../data/thunks';
|
||||
import messages from '../messages';
|
||||
@@ -22,15 +28,19 @@ function CommentEditor({
|
||||
comment,
|
||||
onCloseEditor,
|
||||
edit,
|
||||
formClasses,
|
||||
}) {
|
||||
const editorRef = useRef(null);
|
||||
const { authenticatedUser } = useContext(AppContext);
|
||||
const userIsPrivileged = useSelector(selectUserIsPrivileged);
|
||||
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
|
||||
const userIsGroupTa = useSelector(selectUserIsGroupTa);
|
||||
const userIsStaff = useSelector(selectUserIsStaff);
|
||||
const { reasonCodesEnabled, editReasons } = useSelector(selectModerationSettings);
|
||||
const [submitting, dispatch] = useDispatchWithState();
|
||||
|
||||
const canDisplayEditReason = (reasonCodesEnabled && userIsPrivileged
|
||||
&& edit && comment.author !== authenticatedUser.username
|
||||
const canDisplayEditReason = (reasonCodesEnabled && edit
|
||||
&& (userHasModerationPrivileges || userIsGroupTa || userIsStaff)
|
||||
&& comment?.author !== authenticatedUser.username
|
||||
);
|
||||
|
||||
const editReasonCodeValidation = canDisplayEditReason && {
|
||||
@@ -45,10 +55,15 @@ function CommentEditor({
|
||||
|
||||
const initialValues = {
|
||||
comment: comment.rawBody,
|
||||
editReasonCode: comment?.lastEdit?.reasonCode || '',
|
||||
editReasonCode: comment?.lastEdit?.reasonCode || (userIsStaff ? 'violates-guidelines' : ''),
|
||||
};
|
||||
|
||||
const saveUpdatedComment = async (values) => {
|
||||
const handleCloseEditor = (resetForm) => {
|
||||
resetForm({ values: initialValues });
|
||||
onCloseEditor();
|
||||
};
|
||||
|
||||
const saveUpdatedComment = async (values, { resetForm }) => {
|
||||
if (comment.id) {
|
||||
const payload = {
|
||||
...values,
|
||||
@@ -62,7 +77,7 @@ function CommentEditor({
|
||||
if (editorRef.current) {
|
||||
editorRef.current.plugins.autosave.removeDraft();
|
||||
}
|
||||
onCloseEditor();
|
||||
handleCloseEditor(resetForm);
|
||||
};
|
||||
// The editorId is used to autosave contents to localstorage. This format means that the autosave is scoped to
|
||||
// the current comment id, or the current comment parent or the curren thread.
|
||||
@@ -81,8 +96,9 @@ function CommentEditor({
|
||||
handleSubmit,
|
||||
handleBlur,
|
||||
handleChange,
|
||||
resetForm,
|
||||
}) => (
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<Form onSubmit={handleSubmit} className={formClasses}>
|
||||
{canDisplayEditReason && (
|
||||
<Form.Group
|
||||
isInvalid={isFormikFieldInvalid('editReasonCode', {
|
||||
@@ -92,7 +108,7 @@ function CommentEditor({
|
||||
>
|
||||
<Form.Control
|
||||
name="editReasonCode"
|
||||
className="mt-2"
|
||||
className="mt-2 mr-0"
|
||||
as="select"
|
||||
value={values.editReasonCode}
|
||||
onChange={handleChange}
|
||||
@@ -132,10 +148,11 @@ function CommentEditor({
|
||||
{intl.formatMessage(messages.commentError)}
|
||||
</Form.Control.Feedback>
|
||||
)}
|
||||
<PostPreviewPane htmlNode={values.comment} />
|
||||
<div className="d-flex py-2 justify-content-end">
|
||||
<Button
|
||||
variant="outline-primary"
|
||||
onClick={onCloseEditor}
|
||||
onClick={() => handleCloseEditor(resetForm)}
|
||||
>
|
||||
{intl.formatMessage(messages.cancel)}
|
||||
</Button>
|
||||
@@ -168,10 +185,12 @@ CommentEditor.propTypes = {
|
||||
onCloseEditor: PropTypes.func.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
edit: PropTypes.bool,
|
||||
formClasses: PropTypes.string,
|
||||
};
|
||||
|
||||
CommentEditor.defaultProps = {
|
||||
edit: true,
|
||||
formClasses: '',
|
||||
};
|
||||
|
||||
export default injectIntl(CommentEditor);
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { injectIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Avatar, Icon } from '@edx/paragon';
|
||||
import { CheckCircle, Verified } from '@edx/paragon/icons';
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
import {
|
||||
Avatar, Icon,
|
||||
} from '@edx/paragon';
|
||||
|
||||
import { AvatarBorderAndLabelColors, ThreadType } from '../../../data/constants';
|
||||
import { AvatarOutlineAndLabelColors, EndorsementStatus, ThreadType } from '../../../data/constants';
|
||||
import { AuthorLabel } from '../../common';
|
||||
import ActionsDropdown from '../../common/ActionsDropdown';
|
||||
import { useAlertBannerVisible } from '../../data/hooks';
|
||||
import { selectAuthorAvatars } from '../../posts/data/selectors';
|
||||
import { useActions } from '../../utils';
|
||||
import { commentShape } from './proptypes';
|
||||
|
||||
function CommentHeader({
|
||||
@@ -19,22 +24,63 @@ function CommentHeader({
|
||||
actionHandlers,
|
||||
}) {
|
||||
const authorAvatars = useSelector(selectAuthorAvatars(comment.author));
|
||||
const colorClass = AvatarBorderAndLabelColors[comment.authorLabel];
|
||||
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="d-flex flex-row justify-content-between">
|
||||
<div className={classNames('d-flex flex-row justify-content-between', {
|
||||
'mt-2': hasAnyAlert,
|
||||
})}
|
||||
>
|
||||
<div className="align-items-center d-flex flex-row">
|
||||
<Avatar
|
||||
className={`m-2 ${colorClass && `border-${colorClass}`}`}
|
||||
style={{ borderWidth: '2px' }}
|
||||
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
|
||||
/>
|
||||
<AuthorLabel author={comment.author} authorLabel={comment.authorLabel} labelColor={colorClass && `text-${colorClass}`} />
|
||||
</div>
|
||||
<div className="d-flex align-items-center">
|
||||
{comment.endorsed && (postType === 'question'
|
||||
? <Icon src={CheckCircle} className="text-success" data-testid="check-icon" />
|
||||
: <Icon src={Verified} data-testid="verified-icon" />)}
|
||||
|
||||
{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,
|
||||
|
||||
@@ -7,6 +7,7 @@ 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;
|
||||
@@ -15,7 +16,11 @@ function renderComponent(comment, postType, actionHandlers) {
|
||||
return render(
|
||||
<IntlProvider locale="en">
|
||||
<AppProvider store={store}>
|
||||
<CommentHeader comment={comment} postType={postType} actionHandlers={actionHandlers} />
|
||||
<DiscussionContext.Provider
|
||||
value={{ courseId: 'course-v1:edX+TestX+Test_Course' }}
|
||||
>
|
||||
<CommentHeader comment={comment} postType={postType} actionHandlers={actionHandlers} />
|
||||
</DiscussionContext.Provider>
|
||||
</AppProvider>
|
||||
</IntlProvider>,
|
||||
);
|
||||
@@ -25,7 +30,7 @@ const mockComment = {
|
||||
author: 'abc123',
|
||||
authorLabel: 'ABC 123',
|
||||
endorsed: true,
|
||||
editableFields: [],
|
||||
editableFields: ['endorsed'],
|
||||
};
|
||||
|
||||
describe('Comment Header', () => {
|
||||
@@ -43,7 +48,7 @@ describe('Comment Header', () => {
|
||||
|
||||
it('should render verified icon for endorsed discussion posts', () => {
|
||||
renderComponent(mockComment, 'discussion', {});
|
||||
expect(screen.queryAllByTestId('verified-icon')).toHaveLength(1);
|
||||
expect(screen.queryAllByTestId('check-icon')).toHaveLength(1);
|
||||
});
|
||||
it('should render check icon for endorsed question posts', () => {
|
||||
renderComponent(mockComment, 'question', {});
|
||||
|
||||
@@ -7,10 +7,13 @@ import * as timeago from 'timeago.js';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Avatar, useToggle } from '@edx/paragon';
|
||||
|
||||
import { AvatarBorderAndLabelColors, ContentActions } from '../../../data/constants';
|
||||
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';
|
||||
@@ -22,19 +25,26 @@ function 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]: () => 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 = AvatarBorderAndLabelColors[reply.authorLabel];
|
||||
const colorClass = AvatarOutlineAndLabelColors[reply.authorLabel];
|
||||
const hasAnyAlert = useAlertBannerVisible(reply);
|
||||
|
||||
return (
|
||||
<div className="d-flex my-2 flex-column" data-testid={`reply-${reply.id}`}>
|
||||
<div className="d-flex flex-column mt-4.5" data-testid={`reply-${reply.id}`} role="listitem">
|
||||
<DeleteConfirmation
|
||||
isOpen={isDeleting}
|
||||
title={intl.formatMessage(messages.deleteCommentTitle)}
|
||||
@@ -46,27 +56,35 @@ function Reply({
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="d-flex">
|
||||
<div className="d-flex mx-3 invisible">
|
||||
<Avatar className="m-2" />
|
||||
{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="w-100">
|
||||
<AlertBanner postType={null} content={reply} intl={intl} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="d-flex">
|
||||
<div className="d-flex m-3">
|
||||
<div className="d-flex mr-3 mt-2.5">
|
||||
<Avatar
|
||||
className={`m-2 ${colorClass && `border-${colorClass}`}`}
|
||||
style={{ borderWidth: '2px' }}
|
||||
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="rounded bg-light-300 px-4 py-2 flex-fill">
|
||||
<div className="d-flex flex-row justify-content-between align-items-center">
|
||||
<AuthorLabel author={reply.author} authorLabel={reply.authorLabel} labelColor={colorClass && `text-${colorClass}`} />
|
||||
<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,
|
||||
@@ -77,12 +95,11 @@ function Reply({
|
||||
</div>
|
||||
{isEditing
|
||||
? <CommentEditor comment={reply} onCloseEditor={() => setEditing(false)} />
|
||||
// eslint-disable-next-line react/no-danger
|
||||
: <div id="reply" dangerouslySetInnerHTML={{ __html: reply.renderedBody }} />}
|
||||
: <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, intl.locale)}
|
||||
{timeago.format(reply.createdAt, 'time-locale')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,27 +1,52 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { injectIntl, 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
|
||||
? (
|
||||
<CommentEditor
|
||||
comment={{ threadId: postId }}
|
||||
edit={false}
|
||||
onCloseEditor={() => setAddingResponse(false)}
|
||||
/>
|
||||
) : (
|
||||
<div className="actions d-flex">
|
||||
<Button variant="primary" onClick={() => setAddingResponse(true)}>
|
||||
<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>
|
||||
@@ -31,6 +56,11 @@ function ResponseEditor({
|
||||
ResponseEditor.propTypes = {
|
||||
postId: PropTypes.string.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
addWrappingDiv: PropTypes.bool,
|
||||
};
|
||||
|
||||
ResponseEditor.defaultProps = {
|
||||
addWrappingDiv: false,
|
||||
};
|
||||
|
||||
export default injectIntl(ResponseEditor);
|
||||
|
||||
@@ -8,9 +8,7 @@ ensureConfig([
|
||||
'LMS_BASE_URL',
|
||||
], 'Comments API service');
|
||||
|
||||
const apiBaseUrl = getConfig().LMS_BASE_URL;
|
||||
|
||||
export const commentsApiUrl = `${apiBaseUrl}/api/discussion/v1/comments/`;
|
||||
export const getCommentsApiUrl = () => `${getConfig().LMS_BASE_URL}/api/discussion/v1/comments/`;
|
||||
|
||||
/**
|
||||
* Returns all the comments for the specified thread.
|
||||
@@ -36,7 +34,7 @@ export async function getThreadComments(
|
||||
});
|
||||
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(commentsApiUrl, { params });
|
||||
.get(getCommentsApiUrl(), { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -53,7 +51,7 @@ export async function getCommentResponses(
|
||||
pageSize,
|
||||
} = {},
|
||||
) {
|
||||
const url = `${commentsApiUrl}${commentId}/`;
|
||||
const url = `${getCommentsApiUrl()}${commentId}/`;
|
||||
const params = snakeCaseObject({
|
||||
page,
|
||||
pageSize,
|
||||
@@ -73,7 +71,7 @@ export async function getCommentResponses(
|
||||
*/
|
||||
export async function postComment(comment, threadId, parentId = null) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(commentsApiUrl, snakeCaseObject({ threadId, raw_body: comment, parentId }));
|
||||
.post(getCommentsApiUrl(), snakeCaseObject({ threadId, raw_body: comment, parentId }));
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -94,7 +92,7 @@ export async function updateComment(commentId, {
|
||||
endorsed,
|
||||
editReasonCode,
|
||||
}) {
|
||||
const url = `${commentsApiUrl}${commentId}/`;
|
||||
const url = `${getCommentsApiUrl()}${commentId}/`;
|
||||
const postData = snakeCaseObject({
|
||||
raw_body: comment,
|
||||
voted,
|
||||
@@ -113,33 +111,7 @@ export async function updateComment(commentId, {
|
||||
* @param {string} commentId ID of comment to delete
|
||||
*/
|
||||
export async function deleteComment(commentId) {
|
||||
const url = `${commentsApiUrl}${commentId}/`;
|
||||
const url = `${getCommentsApiUrl()}${commentId}/`;
|
||||
await getAuthenticatedHttpClient()
|
||||
.delete(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the comments by a specific user in a course's discussions
|
||||
*
|
||||
* comments = responses + comments in the UI
|
||||
*
|
||||
* @param {string} courseId Course ID for the course
|
||||
* @param {string} username Username of the user
|
||||
* @returns API response in the format
|
||||
* {
|
||||
* results: [array of comments],
|
||||
* pagination: {count, num_pages, next, previous}
|
||||
* }
|
||||
|
||||
*/
|
||||
export async function getUserComments(courseId, username, { page }) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(commentsApiUrl, {
|
||||
params: {
|
||||
course_id: courseId,
|
||||
username,
|
||||
page,
|
||||
},
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -7,13 +7,14 @@ import { initializeMockApp } from '@edx/frontend-platform/testing';
|
||||
import { EndorsementStatus } from '../../../data/constants';
|
||||
import { initializeStore } from '../../../store';
|
||||
import { executeThunk } from '../../../test-utils';
|
||||
import { commentsApiUrl } from './api';
|
||||
import { getCommentsApiUrl } from './api';
|
||||
import {
|
||||
addComment, editComment, fetchCommentResponses, fetchThreadComments, removeComment,
|
||||
} from './thunks';
|
||||
|
||||
import './__factories__';
|
||||
|
||||
const commentsApiUrl = getCommentsApiUrl();
|
||||
let axiosMock;
|
||||
let store;
|
||||
|
||||
|
||||
@@ -145,6 +145,18 @@ const commentsSlice = createSlice({
|
||||
state.commentsById[payload.id] = payload;
|
||||
state.commentDraft = null;
|
||||
},
|
||||
updateCommentsList: (state, { payload }) => {
|
||||
const { id: commentId, threadId, endorsed } = payload;
|
||||
const commentAddListtype = endorsed ? EndorsementStatus.ENDORSED : EndorsementStatus.UNENDORSED;
|
||||
const commentRemoveListType = !endorsed ? EndorsementStatus.ENDORSED : EndorsementStatus.UNENDORSED;
|
||||
|
||||
state.commentsInThreads[threadId][commentRemoveListType] = (
|
||||
state.commentsInThreads[threadId]?.[commentRemoveListType]?.filter(item => item !== commentId)
|
||||
);
|
||||
state.commentsInThreads[threadId][commentAddListtype] = [
|
||||
...state.commentsInThreads[threadId][commentAddListtype], payload.id,
|
||||
];
|
||||
},
|
||||
deleteCommentRequest: (state) => {
|
||||
state.postStatus = RequestStatus.IN_PROGRESS;
|
||||
},
|
||||
@@ -189,6 +201,7 @@ export const {
|
||||
updateCommentFailed,
|
||||
updateCommentRequest,
|
||||
updateCommentSuccess,
|
||||
updateCommentsList,
|
||||
deleteCommentDenied,
|
||||
deleteCommentFailed,
|
||||
deleteCommentRequest,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { camelCaseObject } from '@edx/frontend-platform';
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
|
||||
import { EndorsementStatus } from '../../../data/constants';
|
||||
import { ContentActions, EndorsementStatus } from '../../../data/constants';
|
||||
import { getHttpErrorStatus } from '../../utils';
|
||||
import {
|
||||
deleteComment, getCommentResponses, getThreadComments, postComment, updateComment,
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
updateCommentDenied,
|
||||
updateCommentFailed,
|
||||
updateCommentRequest,
|
||||
updateCommentsList,
|
||||
updateCommentSuccess,
|
||||
} from './slices';
|
||||
|
||||
@@ -116,12 +117,15 @@ export function fetchCommentResponses(commentId, { page = 1 } = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
export function editComment(commentId, comment) {
|
||||
export function editComment(commentId, comment, action = null) {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
dispatch(updateCommentRequest({ commentId }));
|
||||
const data = await updateComment(commentId, comment);
|
||||
dispatch(updateCommentSuccess(camelCaseObject(data)));
|
||||
if (action === ContentActions.ENDORSE) {
|
||||
dispatch(updateCommentsList(camelCaseObject(data)));
|
||||
}
|
||||
} catch (error) {
|
||||
if (getHttpErrorStatus(error) === 403) {
|
||||
dispatch(updateCommentDenied());
|
||||
|
||||
@@ -16,6 +16,11 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Content reported for staff to review',
|
||||
description: 'Alert banner over comment that has been reported for abuse',
|
||||
},
|
||||
backAlt: {
|
||||
id: 'discussions.actions.back.alt',
|
||||
defaultMessage: 'Back to list',
|
||||
description: 'Back to Posts list button text',
|
||||
},
|
||||
responseCount: {
|
||||
id: 'discussions.comments.comment.responseCount',
|
||||
defaultMessage: `{num, plural,
|
||||
@@ -55,6 +60,7 @@ const messages = defineMessages({
|
||||
defaultMessage: `{postType, select,
|
||||
discussion {Discussion}
|
||||
question {Question}
|
||||
other {{postType}}
|
||||
} posted {relativeTime} by`,
|
||||
description: 'Timestamp for when a user posted the message followed by username. The relative time is already translated.',
|
||||
},
|
||||
@@ -166,6 +172,21 @@ const messages = defineMessages({
|
||||
id: 'discussions.post.closedBy',
|
||||
defaultMessage: 'Post closed by',
|
||||
},
|
||||
replies: {
|
||||
id: 'discussion.comment.repliesHeading',
|
||||
defaultMessage: '{count} replies for the response added',
|
||||
description: 'Text added for screen reader to understand nesting replies.',
|
||||
},
|
||||
time: {
|
||||
id: 'discussion.comment.time',
|
||||
defaultMessage: '{time} ago',
|
||||
description: 'Time text for endorse banner',
|
||||
},
|
||||
noThreadFound: {
|
||||
id: 'discussion.thread.notFound',
|
||||
defaultMessage: 'Thread not found',
|
||||
description: 'message to show on screen if the request thread is not found in course',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useContext, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
import {
|
||||
Button, Dropdown, Icon, IconButton, ModalPopup,
|
||||
Button, Dropdown, Icon, IconButton, ModalPopup, useToggle,
|
||||
} from '@edx/paragon';
|
||||
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 { postShape } from '../posts/post/proptypes';
|
||||
import { useActions } from '../utils';
|
||||
import { inBlackoutDateRange, useActions } from '../utils';
|
||||
import { DiscussionContext } from './context';
|
||||
|
||||
function ActionsDropdown({
|
||||
intl,
|
||||
@@ -20,9 +24,10 @@ function ActionsDropdown({
|
||||
disabled,
|
||||
actionHandlers,
|
||||
}) {
|
||||
const [isOpen, setOpen] = useState(false);
|
||||
const dropdownIconRef = React.useRef(null);
|
||||
const [isOpen, open, close] = useToggle(false);
|
||||
const [target, setTarget] = useState(null);
|
||||
const actions = useActions(commentOrPost);
|
||||
const { inContext } = useContext(DiscussionContext);
|
||||
const handleActions = (action) => {
|
||||
const actionFunction = actionHandlers[action];
|
||||
if (actionFunction) {
|
||||
@@ -31,22 +36,27 @@ function ActionsDropdown({
|
||||
logError(`Unknown or unimplemented action ${action}`);
|
||||
}
|
||||
};
|
||||
const blackoutDateRange = useSelector(selectBlackoutDate);
|
||||
// Find and remove edit action if in blackout date range.
|
||||
if (inBlackoutDateRange(blackoutDateRange)) {
|
||||
actions.splice(actions.findIndex(action => action.id === 'edit'), 1);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<span ref={dropdownIconRef}>
|
||||
<IconButton
|
||||
onClick={() => setOpen(!isOpen)}
|
||||
alt={intl.formatMessage(messages.actionsAlt)}
|
||||
src={MoreHoriz}
|
||||
iconAs={Icon}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</span>
|
||||
<IconButton
|
||||
onClick={open}
|
||||
alt={intl.formatMessage(messages.actionsAlt)}
|
||||
src={MoreHoriz}
|
||||
iconAs={Icon}
|
||||
disabled={disabled}
|
||||
size="sm"
|
||||
ref={setTarget}
|
||||
/>
|
||||
<ModalPopup
|
||||
onClose={() => setOpen(false)}
|
||||
positionRef={dropdownIconRef}
|
||||
onClose={close}
|
||||
positionRef={target}
|
||||
isOpen={isOpen}
|
||||
placement="auto-start"
|
||||
placement={inContext ? 'left' : 'auto-start'}
|
||||
>
|
||||
<div
|
||||
className="bg-white p-1 shadow d-flex flex-column"
|
||||
@@ -62,7 +72,7 @@ function ActionsDropdown({
|
||||
variant="tertiary"
|
||||
size="inline"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
close();
|
||||
handleActions(action.action);
|
||||
}}
|
||||
className="d-flex justify-content-start py-1.5 mr-4"
|
||||
|
||||
@@ -9,6 +9,7 @@ import { camelCaseObject, initializeMockApp, snakeCaseObject } from '@edx/fronte
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
|
||||
import { ContentActions } from '../../data/constants';
|
||||
import { initializeStore } from '../../store';
|
||||
import messages from '../messages';
|
||||
import { ACTIONS_LIST } from '../utils';
|
||||
import ActionsDropdown from './ActionsDropdown';
|
||||
@@ -126,6 +127,7 @@ describe('ActionsDropdown', () => {
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore();
|
||||
});
|
||||
|
||||
it.each(buildTestContent())('can open drop down if enabled', async (commentOrPost) => {
|
||||
@@ -150,6 +152,36 @@ describe('ActionsDropdown', () => {
|
||||
await waitFor(() => expect(screen.queryByTestId('actions-dropdown-modal-popup')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('copy link action should be visible on posts', async () => {
|
||||
const commentOrPost = {
|
||||
testFor: 'thread',
|
||||
...camelCaseObject(Factory.build('thread', { editable_fields: ['copy_link'] }, null)),
|
||||
};
|
||||
renderComponent(commentOrPost, { disabled: false });
|
||||
|
||||
const openButton = await findOpenActionsDropdownButton();
|
||||
await act(async () => {
|
||||
fireEvent.click(openButton);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('Copy link')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('copy link action should not be visible on a comment', async () => {
|
||||
const commentOrPost = {
|
||||
testFor: 'comments',
|
||||
...camelCaseObject(Factory.build('comment', {}, null)),
|
||||
};
|
||||
renderComponent(commentOrPost, { disabled: false });
|
||||
|
||||
const openButton = await findOpenActionsDropdownButton();
|
||||
await act(async () => {
|
||||
fireEvent.click(openButton);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('Copy link')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
describe.each(canPerformActionTestData)('Actions', ({
|
||||
testFor, action, label, reason, ...commentOrPost
|
||||
}) => {
|
||||
|
||||
@@ -2,86 +2,62 @@ import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { useSelector } from 'react-redux';
|
||||
import * as timeago from 'timeago.js';
|
||||
|
||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Alert } from '@edx/paragon';
|
||||
import { CheckCircle, Error, Verified } from '@edx/paragon/icons';
|
||||
import { Error } from '@edx/paragon/icons';
|
||||
|
||||
import { ThreadType } from '../../data/constants';
|
||||
import { commentShape } from '../comments/comment/proptypes';
|
||||
import messages from '../comments/messages';
|
||||
import { selectModerationSettings, selectUserIsPrivileged } from '../data/selectors';
|
||||
import { selectModerationSettings, selectUserHasModerationPrivileges, selectUserIsGroupTa } from '../data/selectors';
|
||||
import { postShape } from '../posts/post/proptypes';
|
||||
import AuthorLabel from './AuthorLabel';
|
||||
|
||||
function AlertBanner({
|
||||
intl,
|
||||
content,
|
||||
postType,
|
||||
}) {
|
||||
const isQuestion = postType === ThreadType.QUESTION;
|
||||
const classes = isQuestion ? 'bg-success-500 text-white' : 'bg-dark-500 text-white';
|
||||
const iconClass = isQuestion ? CheckCircle : Verified;
|
||||
const userIsPrivileged = useSelector(selectUserIsPrivileged);
|
||||
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
|
||||
const userIsGroupTa = useSelector(selectUserIsGroupTa);
|
||||
const { reasonCodesEnabled } = useSelector(selectModerationSettings);
|
||||
const userIsContentAuthor = getAuthenticatedUser().username === content.author;
|
||||
const canSeeLastEditOrClosedAlert = (userHasModerationPrivileges || userIsContentAuthor || userIsGroupTa);
|
||||
const canSeeReportedBanner = content?.abuseFlagged;
|
||||
|
||||
return (
|
||||
<>
|
||||
{content.endorsed && (
|
||||
<Alert
|
||||
variant="plain"
|
||||
className={`p-3 m-0 align-items-center shadow-none ${classes}`}
|
||||
style={{ borderRadius: '0.375rem 0.375rem 0 0' }}
|
||||
icon={iconClass}
|
||||
>
|
||||
<div className="d-flex justify-content-between">
|
||||
<strong className="lead">{intl.formatMessage(
|
||||
isQuestion
|
||||
? messages.answer
|
||||
: messages.endorsed,
|
||||
)}
|
||||
</strong>
|
||||
<span className="d-flex align-items-center mr-1">
|
||||
<span className="mr-2">
|
||||
{intl.formatMessage(
|
||||
isQuestion
|
||||
? messages.answeredLabel
|
||||
: messages.endorsedLabel,
|
||||
)}
|
||||
</span>
|
||||
<AuthorLabel author={content.endorsedBy} authorLabel={content.endorsedByLabel} />
|
||||
{timeago.format(content.endorsedAt, intl.locale)}
|
||||
</span>
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
{content.abuseFlagged && (
|
||||
<Alert icon={Error} variant="danger" className="p-3 m-0 shadow-none my-1 flex-fill">
|
||||
{canSeeReportedBanner && (
|
||||
<Alert icon={Error} variant="danger" className="px-3 mb-2 py-10px shadow-none flex-fill">
|
||||
{intl.formatMessage(messages.abuseFlaggedMessage)}
|
||||
</Alert>
|
||||
)}
|
||||
{reasonCodesEnabled && userIsPrivileged && content.lastEdit?.reason && (
|
||||
<Alert variant="info" className="p-3 m-0 shadow-none mb-1 bg-light-200">
|
||||
<div className="d-flex align-items-center">
|
||||
{intl.formatMessage(messages.editedBy)}
|
||||
<span className="ml-1 mr-3">
|
||||
<AuthorLabel author={content.lastEdit.editorUsername} linkToProfile />
|
||||
</span>
|
||||
{intl.formatMessage(messages.reason)}: {content.lastEdit.reason}
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
{reasonCodesEnabled && content.closed && (
|
||||
<Alert variant="info" className="p-3 m-0 shadow-none mb-1 bg-light-200">
|
||||
<div className="d-flex align-items-center">
|
||||
{intl.formatMessage(messages.closedBy)}
|
||||
<span className="ml-1 ">
|
||||
<AuthorLabel author={content.closedBy} linkToProfile />
|
||||
</span>
|
||||
<span className="mx-1" />
|
||||
{intl.formatMessage(messages.reason)}: {content.closeReason}
|
||||
</div>
|
||||
</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">
|
||||
{intl.formatMessage(messages.editedBy)}
|
||||
<span className="ml-1 mr-3">
|
||||
<AuthorLabel author={content.lastEdit.editorUsername} linkToProfile />
|
||||
</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">
|
||||
{intl.formatMessage(messages.closedBy)}
|
||||
<span className="ml-1 ">
|
||||
<AuthorLabel author={content.closedBy} linkToProfile />
|
||||
</span>
|
||||
<span className="mx-1" />
|
||||
{content.closeReason && (`${intl.formatMessage(messages.reason)}: ${content.closeReason}`)}
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
@@ -90,11 +66,6 @@ function AlertBanner({
|
||||
AlertBanner.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
content: PropTypes.oneOfType([commentShape.isRequired, postShape.isRequired]).isRequired,
|
||||
postType: PropTypes.string,
|
||||
};
|
||||
|
||||
AlertBanner.defaultProps = {
|
||||
postType: null,
|
||||
};
|
||||
|
||||
export default injectIntl(AlertBanner);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ThreadType } from '../../data/constants';
|
||||
import { initializeStore } from '../../store';
|
||||
import messages from '../comments/messages';
|
||||
import AlertBanner from './AlertBanner';
|
||||
import { DiscussionContext } from './context';
|
||||
|
||||
import '../comments/data/__factories__';
|
||||
import '../posts/data/__factories__';
|
||||
@@ -22,15 +23,17 @@ function buildTestContent(type, buildParams) {
|
||||
|
||||
function renderComponent(
|
||||
content,
|
||||
postType,
|
||||
) {
|
||||
render(
|
||||
<IntlProvider locale="en">
|
||||
<AppProvider store={store}>
|
||||
<AlertBanner
|
||||
content={content}
|
||||
postType={postType}
|
||||
/>
|
||||
<DiscussionContext.Provider
|
||||
value={{ courseId: 'course-v1:edX+TestX+Test_Course' }}
|
||||
>
|
||||
<AlertBanner
|
||||
content={content}
|
||||
/>
|
||||
</DiscussionContext.Provider>
|
||||
</AppProvider>
|
||||
</IntlProvider>,
|
||||
);
|
||||
@@ -44,27 +47,6 @@ describe.each([
|
||||
props: { abuseFlagged: true },
|
||||
expectText: [messages.abuseFlaggedMessage.defaultMessage],
|
||||
},
|
||||
{
|
||||
label: 'Staff endorsed comment in a question thread',
|
||||
type: 'comment',
|
||||
postType: ThreadType.QUESTION,
|
||||
props: { endorsed: true, endorsedBy: 'test-user', endorsedByLabel: 'Staff' },
|
||||
expectText: [messages.answer.defaultMessage, messages.answeredLabel.defaultMessage, 'test-user', 'Staff'],
|
||||
},
|
||||
{
|
||||
label: 'TA endorsed comment in a question thread',
|
||||
type: 'comment',
|
||||
postType: ThreadType.QUESTION,
|
||||
props: { endorsed: true, endorsedBy: 'test-user', endorsedByLabel: 'Community TA' },
|
||||
expectText: [messages.answer.defaultMessage, messages.answeredLabel.defaultMessage, 'test-user', 'TA'],
|
||||
},
|
||||
{
|
||||
label: 'endorsed comment in a discussion thread',
|
||||
type: 'comment',
|
||||
postType: ThreadType.DISCUSSION,
|
||||
props: { endorsed: true, endorsedBy: 'test-user' },
|
||||
expectText: [messages.endorsed.defaultMessage, messages.endorsedLabel.defaultMessage, 'test-user'],
|
||||
},
|
||||
{
|
||||
label: 'flagged thread',
|
||||
type: 'thread',
|
||||
@@ -76,7 +58,7 @@ describe.each([
|
||||
label: 'edited content',
|
||||
type: 'thread',
|
||||
postType: null,
|
||||
props: { last_edit: { reason: 'test-reason', editorUsername: 'editor-user' } },
|
||||
props: { closed: false, last_edit: { reason: 'test-reason', editorUsername: 'editor-user' } },
|
||||
expectText: [messages.editedBy.defaultMessage, messages.reason.defaultMessage, 'editor-user', 'test-reason'],
|
||||
},
|
||||
{
|
||||
@@ -87,7 +69,7 @@ describe.each([
|
||||
expectText: [messages.closedBy.defaultMessage, 'closing-user', 'test-close-reason'],
|
||||
},
|
||||
])('AlertBanner', ({
|
||||
label, type, postType, props, expectText,
|
||||
label, type, props, expectText,
|
||||
}) => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
@@ -100,12 +82,12 @@ describe.each([
|
||||
});
|
||||
store = initializeStore({
|
||||
config: {
|
||||
userIsPrivileged: true,
|
||||
hasModerationPrivileges: true,
|
||||
reasonCodesEnabled: true,
|
||||
},
|
||||
});
|
||||
const content = buildTestContent(type, props);
|
||||
renderComponent(content, postType);
|
||||
renderComponent(content);
|
||||
});
|
||||
|
||||
it(`should show correct banner for a ${label}`, async () => {
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import React from 'react';
|
||||
import React, { useContext } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Icon } from '@edx/paragon';
|
||||
import { Institution, School } from '@edx/paragon/icons';
|
||||
|
||||
import { Routes } from '../../data/constants';
|
||||
import { useShowLearnersTab } from '../data/hooks';
|
||||
import messages from '../messages';
|
||||
import { discussionsPath } from '../utils';
|
||||
import { DiscussionContext } from './context';
|
||||
|
||||
function AuthorLabel({
|
||||
intl,
|
||||
@@ -15,9 +20,13 @@ function AuthorLabel({
|
||||
authorLabel,
|
||||
linkToProfile,
|
||||
labelColor,
|
||||
alert,
|
||||
}) {
|
||||
const location = useLocation();
|
||||
const { courseId } = useContext(DiscussionContext);
|
||||
let icon = null;
|
||||
let authorLabelMessage = null;
|
||||
|
||||
if (authorLabel === 'Staff') {
|
||||
icon = Institution;
|
||||
authorLabelMessage = intl.formatMessage(messages.authorLabelStaff);
|
||||
@@ -26,9 +35,26 @@ function AuthorLabel({
|
||||
icon = School;
|
||||
authorLabelMessage = intl.formatMessage(messages.authorLabelTA);
|
||||
}
|
||||
|
||||
const isRetiredUser = author ? author.startsWith('retired__user') : false;
|
||||
|
||||
const className = classNames('d-flex align-items-center', labelColor);
|
||||
|
||||
const showUserNameAsLink = useShowLearnersTab()
|
||||
&& linkToProfile && author && author !== intl.formatMessage(messages.anonymous);
|
||||
|
||||
const labelContents = (
|
||||
<>
|
||||
<span className="mr-1">{author}</span>
|
||||
<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={{
|
||||
@@ -39,30 +65,48 @@ function AuthorLabel({
|
||||
/>
|
||||
)}
|
||||
{authorLabelMessage && (
|
||||
<span className="mr-3 ml-1">
|
||||
<span
|
||||
className={classNames('mr-1 font-size-14 font-style-normal font-family-inter font-weight-500', {
|
||||
'text-primary-500': !authorLabelMessage && !isRetiredUser && !alert,
|
||||
'text-gray-700': isRetiredUser,
|
||||
})}
|
||||
style={{ marginLeft: '2px' }}
|
||||
>
|
||||
{authorLabelMessage}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
const className = classNames('d-flex align-items-center', labelColor);
|
||||
return linkToProfile
|
||||
? React.createElement('a', { href: '#nowhere', className }, labelContents)
|
||||
: React.createElement('div', { className }, labelContents);
|
||||
|
||||
return showUserNameAsLink
|
||||
? (
|
||||
<Link
|
||||
data-testid="learner-posts-link"
|
||||
id="learner-posts-link"
|
||||
to={discussionsPath(Routes.LEARNERS.POSTS, { learnerUsername: author, courseId })(location)}
|
||||
className="text-decoration-none"
|
||||
style={{ width: 'fit-content' }}
|
||||
>
|
||||
{labelContents}
|
||||
</Link>
|
||||
)
|
||||
: <>{labelContents}</>;
|
||||
}
|
||||
|
||||
AuthorLabel.propTypes = {
|
||||
intl: intlShape,
|
||||
intl: intlShape.isRequired,
|
||||
author: PropTypes.string.isRequired,
|
||||
authorLabel: PropTypes.string,
|
||||
linkToProfile: PropTypes.bool,
|
||||
labelColor: PropTypes.string,
|
||||
alert: PropTypes.bool,
|
||||
};
|
||||
|
||||
AuthorLabel.defaultProps = {
|
||||
linkToProfile: false,
|
||||
authorLabel: null,
|
||||
labelColor: '',
|
||||
alert: false,
|
||||
};
|
||||
|
||||
export default injectIntl(AuthorLabel);
|
||||
|
||||
108
src/discussions/common/AuthorLabel.test.jsx
Normal file
108
src/discussions/common/AuthorLabel.test.jsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import React from 'react';
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { IntlProvider } from 'react-intl';
|
||||
|
||||
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 AuthorLabel from './AuthorLabel';
|
||||
import { DiscussionContext } from './context';
|
||||
|
||||
const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||
const courseConfigApiUrl = getCourseConfigApiUrl();
|
||||
let store;
|
||||
let axiosMock;
|
||||
let container;
|
||||
|
||||
function renderComponent(author, authorLabel, linkToProfile, labelColor) {
|
||||
const wrapper = render(
|
||||
<IntlProvider locale="en">
|
||||
<AppProvider store={store}>
|
||||
<DiscussionContext.Provider value={{ courseId }}>
|
||||
<AuthorLabel
|
||||
author={author}
|
||||
authorLabel={authorLabel}
|
||||
linkToProfile={linkToProfile}
|
||||
labelColor={labelColor}
|
||||
/>
|
||||
</DiscussionContext.Provider>
|
||||
</AppProvider>
|
||||
</IntlProvider>,
|
||||
);
|
||||
container = wrapper.container;
|
||||
return container;
|
||||
}
|
||||
|
||||
describe('Author label', () => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`).reply(200, {
|
||||
learners_tab_enabled: true,
|
||||
has_moderation_privileges: true,
|
||||
});
|
||||
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/settings`).reply(200, {});
|
||||
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
|
||||
});
|
||||
|
||||
describe.each([
|
||||
['anonymous', null, false, ''],
|
||||
['ta_user', 'Community TA', true, 'text-TA-color'],
|
||||
['retired__user', null, false, ''],
|
||||
['staff_user', 'Staff', true, 'text-staff-color'],
|
||||
['learner_user', null, false, ''],
|
||||
])('for %s', (
|
||||
author, authorLabel, linkToProfile, labelColor,
|
||||
) => {
|
||||
it('it has author name text',
|
||||
async () => {
|
||||
renderComponent(author, authorLabel, linkToProfile, labelColor);
|
||||
const authorElement = container.querySelector('[role=heading]');
|
||||
const authorName = author.startsWith('retired__user') ? '[Deactivated]' : author;
|
||||
|
||||
expect(authorElement).toHaveTextContent(authorName);
|
||||
});
|
||||
|
||||
it(`it is "${!linkToProfile && 'not'}" clickable when linkToProfile is ${!!linkToProfile}`,
|
||||
async () => {
|
||||
renderComponent(author, authorLabel, linkToProfile, labelColor);
|
||||
|
||||
if (linkToProfile) {
|
||||
expect(screen.queryByTestId('learner-posts-link')).toBeInTheDocument();
|
||||
} else {
|
||||
expect(screen.queryByTestId('learner-posts-link')).not.toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it(`it has "${!linkToProfile && 'not'}" label text and label color when linkToProfile is ${!!linkToProfile}`,
|
||||
async () => {
|
||||
renderComponent(author, authorLabel, linkToProfile, labelColor);
|
||||
const authorElement = container.querySelector('[role=heading]');
|
||||
const labelElement = authorElement.parentNode.lastChild;
|
||||
const label = ['TA', 'Staff'].includes(labelElement.textContent) && labelElement.textContent;
|
||||
|
||||
if (linkToProfile) {
|
||||
expect(authorElement.parentNode).toHaveClass(labelColor);
|
||||
expect(authorElement.parentNode.lastChild).toHaveTextContent(label);
|
||||
} else {
|
||||
expect(authorElement.parentNode.lastChild).not.toHaveTextContent(label, { exact: true });
|
||||
expect(authorElement.parentNode).not.toHaveClass(labelColor, { exact: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,7 @@ function DeleteConfirmation({
|
||||
onDelete,
|
||||
}) {
|
||||
return (
|
||||
<ModalDialog title={title} isOpen={isOpen} hasCloseButton={false} onClose={onClose}>
|
||||
<ModalDialog title={title} isOpen={isOpen} hasCloseButton={false} onClose={onClose} zIndex={5000}>
|
||||
<ModalDialog.Header>
|
||||
<ModalDialog.Title>
|
||||
{title}
|
||||
|
||||
73
src/discussions/common/EndorsedAlertBanner.jsx
Normal file
73
src/discussions/common/EndorsedAlertBanner.jsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import React from 'react';
|
||||
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 { CheckCircle, Verified } from '@edx/paragon/icons';
|
||||
|
||||
import { ThreadType } from '../../data/constants';
|
||||
import { commentShape } from '../comments/comment/proptypes';
|
||||
import messages from '../comments/messages';
|
||||
import AuthorLabel from './AuthorLabel';
|
||||
import timeLocale from './time-locale';
|
||||
|
||||
function EndorsedAlertBanner({
|
||||
intl,
|
||||
content,
|
||||
postType,
|
||||
}) {
|
||||
timeago.register('time-locale', timeLocale);
|
||||
const isQuestion = postType === ThreadType.QUESTION;
|
||||
const classes = isQuestion ? 'bg-success-500 text-white' : 'bg-dark-500 text-white';
|
||||
const iconClass = isQuestion ? CheckCircle : Verified;
|
||||
|
||||
return (
|
||||
content.endorsed && (
|
||||
<Alert
|
||||
variant="plain"
|
||||
className={`px-3 mb-0 py-10px align-items-center shadow-none ${classes}`}
|
||||
style={{ borderRadius: '0.375rem 0.375rem 0 0' }}
|
||||
icon={iconClass}
|
||||
>
|
||||
<div className="d-flex justify-content-between flex-wrap">
|
||||
<strong className="lead">{intl.formatMessage(
|
||||
isQuestion
|
||||
? messages.answer
|
||||
: messages.endorsed,
|
||||
)}
|
||||
</strong>
|
||||
<span className="d-flex align-items-center mr-1 flex-wrap">
|
||||
<span className="mr-1">
|
||||
{intl.formatMessage(
|
||||
isQuestion
|
||||
? messages.answeredLabel
|
||||
: messages.endorsedLabel,
|
||||
)}
|
||||
</span>
|
||||
<AuthorLabel
|
||||
author={content.endorsedBy}
|
||||
authorLabel={content.endorsedByLabel}
|
||||
linkToProfile
|
||||
alert={content.endorsed}
|
||||
/>
|
||||
{intl.formatMessage(messages.time, { time: timeago.format(content.endorsedAt, 'time-locale') })}
|
||||
</span>
|
||||
</div>
|
||||
</Alert>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
EndorsedAlertBanner.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
content: PropTypes.oneOfType([commentShape.isRequired]).isRequired,
|
||||
postType: PropTypes.string,
|
||||
};
|
||||
|
||||
EndorsedAlertBanner.defaultProps = {
|
||||
postType: null,
|
||||
};
|
||||
|
||||
export default injectIntl(EndorsedAlertBanner);
|
||||
92
src/discussions/common/EndorsedAlertBanner.test.jsx
Normal file
92
src/discussions/common/EndorsedAlertBanner.test.jsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { IntlProvider } from 'react-intl';
|
||||
import { Factory } from 'rosie';
|
||||
|
||||
import { camelCaseObject, initializeMockApp, snakeCaseObject } from '@edx/frontend-platform';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
|
||||
import { ThreadType } from '../../data/constants';
|
||||
import { initializeStore } from '../../store';
|
||||
import messages from '../comments/messages';
|
||||
import { DiscussionContext } from './context';
|
||||
import EndorsedAlertBanner from './EndorsedAlertBanner';
|
||||
|
||||
import '../comments/data/__factories__';
|
||||
import '../posts/data/__factories__';
|
||||
|
||||
let store;
|
||||
|
||||
function buildTestContent(type, buildParams) {
|
||||
const buildParamsSnakeCase = snakeCaseObject(buildParams);
|
||||
return camelCaseObject(Factory.build(type, { ...buildParamsSnakeCase }, null));
|
||||
}
|
||||
|
||||
function renderComponent(
|
||||
content, postType,
|
||||
) {
|
||||
render(
|
||||
<IntlProvider locale="en">
|
||||
<AppProvider store={store}>
|
||||
<DiscussionContext.Provider
|
||||
value={{ courseId: 'course-v1:edX+DemoX+Demo_Course' }}
|
||||
>
|
||||
<EndorsedAlertBanner
|
||||
content={content}
|
||||
postType={postType}
|
||||
/>
|
||||
</DiscussionContext.Provider>
|
||||
</AppProvider>
|
||||
</IntlProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe.each([
|
||||
{
|
||||
label: 'Staff endorsed comment in a question thread',
|
||||
type: 'comment',
|
||||
postType: ThreadType.QUESTION,
|
||||
props: { endorsed: true, endorsedBy: 'test-user', endorsedByLabel: 'Staff' },
|
||||
expectText: [messages.answer.defaultMessage, messages.answeredLabel.defaultMessage, 'test-user', 'Staff'],
|
||||
},
|
||||
{
|
||||
label: 'TA endorsed comment in a question thread',
|
||||
type: 'comment',
|
||||
postType: ThreadType.QUESTION,
|
||||
props: { endorsed: true, endorsedBy: 'test-user', endorsedByLabel: 'Community TA' },
|
||||
expectText: [messages.answer.defaultMessage, messages.answeredLabel.defaultMessage, 'test-user', 'TA'],
|
||||
},
|
||||
{
|
||||
label: 'endorsed comment in a discussion thread',
|
||||
type: 'comment',
|
||||
postType: ThreadType.DISCUSSION,
|
||||
props: { endorsed: true, endorsedBy: 'test-user' },
|
||||
expectText: [messages.endorsed.defaultMessage, messages.endorsedLabel.defaultMessage, 'test-user'],
|
||||
},
|
||||
])('EndorsedAlertBanner', ({
|
||||
label, type, postType, props, expectText,
|
||||
}) => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: false,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore({
|
||||
config: {
|
||||
hasModerationPrivileges: true,
|
||||
reasonCodesEnabled: true,
|
||||
},
|
||||
});
|
||||
const content = buildTestContent(type, props);
|
||||
renderComponent(content, postType);
|
||||
});
|
||||
|
||||
it(`should show correct banner for a ${label}`, async () => {
|
||||
expectText.forEach(message => {
|
||||
expect(screen.queryAllByText(message, { exact: false }).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,3 +2,4 @@ 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 EndorsedAlertBanner } from './EndorsedAlertBanner';
|
||||
|
||||
19
src/discussions/common/time-locale.js
Normal file
19
src/discussions/common/time-locale.js
Normal file
@@ -0,0 +1,19 @@
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
export default function timeLocale(number, index, totalSec) {
|
||||
return [
|
||||
['just now', 'right now'],
|
||||
['%ss', 'in %s seconds'],
|
||||
['1m', 'in 1 minute'],
|
||||
['%sm', 'in %s minutes'],
|
||||
['1h', 'in 1 hour'],
|
||||
['%sh', 'in %s hours'],
|
||||
['1d', 'in 1 day'],
|
||||
['%sd', 'in %s days'],
|
||||
['1w', 'in 1 week'],
|
||||
['%sw', 'in %s weeks'],
|
||||
['4w', 'in 1 month'],
|
||||
[`${number * 4}w`, 'in %s months'],
|
||||
['1y', 'in 1 year'],
|
||||
['%sy', 'in %s years'],
|
||||
][index];
|
||||
}
|
||||
@@ -4,6 +4,6 @@ Factory.define('config')
|
||||
.attrs({
|
||||
allow_anonymous: false,
|
||||
allow_anonymous_to_peers: false,
|
||||
user_is_privileged: false,
|
||||
has_moderation_privileges: false,
|
||||
})
|
||||
.attr('user_roles', ['user_is_privileged'], (userIsPrivileged) => (userIsPrivileged ? ['Student', 'Moderator'] : ['Student']));
|
||||
.attr('user_roles', ['has_moderation_privileges'], (hasModerationPrivileges) => (hasModerationPrivileges ? ['Student', 'Moderator'] : ['Student']));
|
||||
|
||||
@@ -7,16 +7,14 @@ ensureConfig([
|
||||
'LMS_BASE_URL',
|
||||
], 'Posts API service');
|
||||
|
||||
const apiBaseUrl = getConfig().LMS_BASE_URL;
|
||||
|
||||
export const courseConfigApiUrl = `${apiBaseUrl}/api/discussion/v1/courses/`;
|
||||
export const getCourseConfigApiUrl = () => `${getConfig().LMS_BASE_URL}/api/discussion/v1/courses/`;
|
||||
|
||||
/**
|
||||
* Get discussions course config
|
||||
* @param {string} courseId
|
||||
*/
|
||||
export async function getDiscussionsConfig(courseId) {
|
||||
const url = `${courseConfigApiUrl}${courseId}/`;
|
||||
const url = `${getCourseConfigApiUrl()}${courseId}/`;
|
||||
const { data } = await getAuthenticatedHttpClient().get(url);
|
||||
return data;
|
||||
}
|
||||
@@ -26,7 +24,7 @@ export async function getDiscussionsConfig(courseId) {
|
||||
* @param {string} courseId
|
||||
*/
|
||||
export async function getDiscussionsSettings(courseId) {
|
||||
const url = `${courseConfigApiUrl}${courseId}/settings`;
|
||||
const url = `${getCourseConfigApiUrl()}${courseId}/settings`;
|
||||
const { data } = await getAuthenticatedHttpClient().get(url);
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -1,19 +1,30 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import { useContext, useEffect, useRef } from 'react';
|
||||
import {
|
||||
useContext, useEffect, useRef, useState,
|
||||
} from 'react';
|
||||
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useHistory, useLocation, useRouteMatch } from 'react-router';
|
||||
|
||||
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 { selectTopicsUnderCategory } from '../../data/selectors';
|
||||
import { fetchCourseBlocks } from '../../data/thunks';
|
||||
import { DiscussionContext } from '../common/context';
|
||||
import { clearRedirect } from '../posts/data';
|
||||
import { selectTopics } from '../topics/data/selectors';
|
||||
import { fetchCourseTopics } from '../topics/data/thunks';
|
||||
import { discussionsPath, postMessageToParent } from '../utils';
|
||||
import { selectAreThreadsFiltered, selectPostThreadCount } from './selectors';
|
||||
import { discussionsPath } from '../utils';
|
||||
import {
|
||||
selectAreThreadsFiltered, selectLearnersTabEnabled,
|
||||
selectModerationSettings,
|
||||
selectPostThreadCount,
|
||||
selectUserHasModerationPrivileges,
|
||||
selectUserIsGroupTa,
|
||||
} from './selectors';
|
||||
import { fetchCourseConfig } from './thunks';
|
||||
|
||||
export function useTotalTopicThreadCount() {
|
||||
@@ -61,7 +72,7 @@ export function useCourseDiscussionData(courseId) {
|
||||
}, [courseId]);
|
||||
}
|
||||
|
||||
export function useRedirectToThread(courseId) {
|
||||
export function useRedirectToThread(courseId, inContext) {
|
||||
const dispatch = useDispatch();
|
||||
const redirectToThread = useSelector(
|
||||
(state) => state.threads.redirectToThread,
|
||||
@@ -74,9 +85,10 @@ export function useRedirectToThread(courseId) {
|
||||
// stored in redirectToThread
|
||||
if (redirectToThread) {
|
||||
dispatch(clearRedirect());
|
||||
const newLocation = discussionsPath(Routes.COMMENTS.PAGES['my-posts'], {
|
||||
const newLocation = discussionsPath(Routes.COMMENTS.PAGES[inContext ? 'topics' : 'my-posts'], {
|
||||
courseId,
|
||||
postId: redirectToThread.threadId,
|
||||
topicId: redirectToThread.topicId,
|
||||
})(location);
|
||||
history.push(newLocation);
|
||||
}
|
||||
@@ -88,56 +100,77 @@ export function useIsOnDesktop() {
|
||||
return windowSize.width >= breakpoints.large.minWidth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given an element this attempts to get the height of the entire UI.
|
||||
*
|
||||
* @param element
|
||||
* @returns {number}
|
||||
*/
|
||||
function getOuterHeight(element) {
|
||||
// This is the height of the entire document body.
|
||||
const bodyHeight = document.body.offsetHeight;
|
||||
// This is the height of the container that will scroll.
|
||||
const elementContainerHeight = element.parentNode.clientHeight;
|
||||
// The difference between the body height and the container height is the size of the header footer etc.
|
||||
// Add to that the element's own height and we get the size the UI should be to fit everything.
|
||||
return bodyHeight - elementContainerHeight + element.scrollHeight;
|
||||
export function useIsOnXLDesktop() {
|
||||
const windowSize = useWindowSize();
|
||||
return windowSize.width >= breakpoints.extraLarge.minWidth;
|
||||
}
|
||||
|
||||
/**
|
||||
* This hook posts a resize message to the parent window if running in an iframe
|
||||
* @param refContainer reference to the component whose size is to be measured
|
||||
*/
|
||||
export function useContainerSizeForParent(refContainer) {
|
||||
function postResizeMessage(height) {
|
||||
postMessageToParent('plugin.resize', { height });
|
||||
}
|
||||
|
||||
export function useContainerSize(refContainer) {
|
||||
const location = useLocation();
|
||||
const enabled = window.parent !== window;
|
||||
const [height, setHeight] = useState();
|
||||
|
||||
const resizeObserver = useRef(new ResizeObserver(() => {
|
||||
/* istanbul ignore if: ResizeObserver isn't available in the testing env */
|
||||
if (refContainer.current) {
|
||||
postResizeMessage(getOuterHeight(refContainer.current));
|
||||
if (refContainer?.current) {
|
||||
setHeight(refContainer?.current?.clientHeight);
|
||||
}
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
const container = refContainer.current;
|
||||
const observer = resizeObserver.current;
|
||||
if (container && observer && enabled) {
|
||||
const container = refContainer?.current;
|
||||
const observer = resizeObserver?.current;
|
||||
if (container && observer) {
|
||||
observer.observe(container);
|
||||
postResizeMessage(getOuterHeight(container));
|
||||
setHeight(container.clientHeight);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (container && observer && enabled) {
|
||||
if (container && observer) {
|
||||
observer.unobserve(container);
|
||||
// Send a message to reset the size so that navigating to another
|
||||
// page doesn't cause the size to be retained
|
||||
postResizeMessage(null);
|
||||
}
|
||||
};
|
||||
}, [refContainer, resizeObserver, location]);
|
||||
|
||||
return height;
|
||||
}
|
||||
|
||||
export const useAlertBannerVisible = (content) => {
|
||||
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
|
||||
const userIsGroupTa = useSelector(selectUserIsGroupTa);
|
||||
const { reasonCodesEnabled } = useSelector(selectModerationSettings);
|
||||
const userIsContentAuthor = getAuthenticatedUser().username === content.author;
|
||||
const canSeeLastEditOrClosedAlert = (userHasModerationPrivileges || userIsContentAuthor || userIsGroupTa);
|
||||
const canSeeReportedBanner = content.abuseFlagged;
|
||||
|
||||
return (
|
||||
(reasonCodesEnabled && canSeeLastEditOrClosedAlert && (content.lastEdit?.reason || content.closed))
|
||||
|| (content.abuseFlagged && canSeeReportedBanner)
|
||||
);
|
||||
};
|
||||
|
||||
export const useShowLearnersTab = () => useSelector(selectLearnersTabEnabled);
|
||||
|
||||
/**
|
||||
* React hook that gets the current topic ID from the current topic or category.
|
||||
* The topicId in the DiscussionContext only return the direct topicId from the URL.
|
||||
* If the URL has the current block ID it cannot get the topicID from that. This hook
|
||||
* gets the topic ID from the URL if available, or from the current category otherwise.
|
||||
* It only returns an ID if a single ID is available, if navigating a subsection it
|
||||
* returns null.
|
||||
* @returns {null|string} A topic ID if a single one available in the current context.
|
||||
*/
|
||||
export const useCurrentDiscussionTopic = () => {
|
||||
const { topicId, category } = useContext(DiscussionContext);
|
||||
const topics = useSelector(selectTopicsUnderCategory)(category);
|
||||
if (topicId) {
|
||||
return topicId;
|
||||
}
|
||||
if (topics?.length === 1) {
|
||||
return topics[0];
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -1,68 +1,85 @@
|
||||
import { useRef } from 'react';
|
||||
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { IntlProvider } from 'react-intl';
|
||||
import { Context as ResponsiveContext } from 'react-responsive';
|
||||
import { MemoryRouter } from 'react-router';
|
||||
|
||||
import { getConfig, initializeMockApp } from '@edx/frontend-platform';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
|
||||
import { initializeStore } from '../../store';
|
||||
import { useContainerSizeForParent } from './hooks';
|
||||
import { DiscussionContext } from '../common/context';
|
||||
import { useCurrentDiscussionTopic } from './hooks';
|
||||
|
||||
let store;
|
||||
initializeMockApp();
|
||||
describe('Hooks', () => {
|
||||
function ComponentWithHook() {
|
||||
const refContainer = useRef(null);
|
||||
useContainerSizeForParent(refContainer);
|
||||
return (
|
||||
<div>
|
||||
<div ref={refContainer} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
describe('useCurrentDiscussionTopic', () => {
|
||||
function ComponentWithHook() {
|
||||
const topic = useCurrentDiscussionTopic();
|
||||
return (
|
||||
<div>
|
||||
{String(topic)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderComponent() {
|
||||
return render(
|
||||
<IntlProvider locale="en">
|
||||
<ResponsiveContext.Provider value={{ width: 1280 }}>
|
||||
function renderComponent({ topicId, category }) {
|
||||
return render(
|
||||
<IntlProvider locale="en">
|
||||
<AppProvider store={store}>
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<DiscussionContext.Provider
|
||||
value={{
|
||||
topicId,
|
||||
category,
|
||||
}}
|
||||
>
|
||||
<ComponentWithHook />
|
||||
</MemoryRouter>
|
||||
</DiscussionContext.Provider>
|
||||
</AppProvider>
|
||||
</ResponsiveContext.Provider>
|
||||
</IntlProvider>,
|
||||
);
|
||||
}
|
||||
</IntlProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
let parent;
|
||||
beforeEach(() => {
|
||||
store = initializeStore();
|
||||
parent = window.parent;
|
||||
});
|
||||
afterEach(() => {
|
||||
window.parent = parent;
|
||||
});
|
||||
test('useContainerSizeForParent enabled', async () => {
|
||||
delete window.parent;
|
||||
window.parent = { ...window, postMessage: jest.fn() };
|
||||
const { unmount } = renderComponent();
|
||||
// Once for LMS and one for learning MFE
|
||||
await waitFor(() => expect(window.parent.postMessage).toHaveBeenCalledTimes(2));
|
||||
// Test that size is reset on unmount
|
||||
unmount();
|
||||
await waitFor(() => expect(window.parent.postMessage).toHaveBeenCalledTimes(4));
|
||||
expect(window.parent.postMessage).toHaveBeenLastCalledWith(
|
||||
{ type: 'plugin.resize', payload: { height: null } },
|
||||
getConfig().LMS_BASE_URL,
|
||||
);
|
||||
});
|
||||
test('useContainerSizeForParent disabled', async () => {
|
||||
window.parent.postMessage = jest.fn();
|
||||
renderComponent();
|
||||
await waitFor(() => expect(window.parent.postMessage).not.toHaveBeenCalled());
|
||||
beforeEach(() => {
|
||||
store = initializeStore({
|
||||
blocks: {
|
||||
blocks: {
|
||||
'some-unit-key': { topics: ['some-topic-0'], parent: 'some-sequence-key' },
|
||||
'some-sequence-key': { topics: ['some-topic-0'] },
|
||||
'another-sequence-key': { topics: ['some-topic-1', 'some-topic-2'] },
|
||||
'empty-key': { topics: [] },
|
||||
},
|
||||
},
|
||||
config: { provider: 'openedx' },
|
||||
});
|
||||
});
|
||||
|
||||
test('when topicId is in context', () => {
|
||||
const { queryByText } = renderComponent({ topicId: 'some-topic' });
|
||||
expect(queryByText('some-topic')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('when the category is a unit', () => {
|
||||
const { queryByText } = renderComponent({ category: 'some-unit-key' });
|
||||
expect(queryByText('some-topic-0')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('when the category is a sequence with one unit', () => {
|
||||
const { queryByText } = renderComponent({ category: 'some-sequence-key' });
|
||||
expect(queryByText('some-topic-0')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('when the category is a sequence with multiple units', () => {
|
||||
const { queryByText } = renderComponent({ category: 'another-sequence-key' });
|
||||
expect(queryByText('null')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('when the category is invalid', () => {
|
||||
const { queryByText } = renderComponent({ category: 'invalid-key' });
|
||||
expect(queryByText('null')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('when the category has no topics', () => {
|
||||
const { queryByText } = renderComponent({ category: 'empty-key' });
|
||||
expect(queryByText('null')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,14 +6,24 @@ export const selectAnonymousPostingConfig = state => ({
|
||||
allowAnonymousToPeers: state.config.allowAnonymousToPeers,
|
||||
});
|
||||
|
||||
export const selectUserIsPrivileged = state => state.config.userIsPrivileged;
|
||||
export const selectUserHasModerationPrivileges = state => state.config.hasModerationPrivileges;
|
||||
|
||||
export const selectUserIsStaff = state => state.config.isUserAdmin;
|
||||
|
||||
export const selectUserIsGroupTa = state => state.config.isGroupTa;
|
||||
|
||||
export const selectconfigLoadingStatus = state => state.config.status;
|
||||
|
||||
export const selectLearnersTabEnabled = state => state.config.learnersTabEnabled;
|
||||
|
||||
export const selectUserRoles = state => state.config.userRoles;
|
||||
|
||||
export const selectDivisionSettings = state => state.config.settings;
|
||||
|
||||
export const selectBlackoutDate = state => state.config.blackouts;
|
||||
|
||||
export const selectGroupAtSubsection = state => state.config.groupAtSubsection;
|
||||
|
||||
export const selectModerationSettings = state => ({
|
||||
postCloseReasons: state.config.postCloseReasons,
|
||||
editReasons: state.config.editReasons,
|
||||
|
||||
@@ -11,7 +11,10 @@ const configSlice = createSlice({
|
||||
allowAnonymous: false,
|
||||
allowAnonymousToPeers: false,
|
||||
userRoles: [],
|
||||
userIsPrivileged: false,
|
||||
groupAtSubsection: false,
|
||||
hasModerationPrivileges: false,
|
||||
isGroupTa: false,
|
||||
isUserAdmin: false,
|
||||
learnersTabEnabled: false,
|
||||
settings: {
|
||||
divisionScheme: 'none',
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
import { camelCaseObject } from '@edx/frontend-platform';
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
|
||||
import {
|
||||
LearnersOrdering,
|
||||
PostsStatusFilter,
|
||||
} from '../../data/constants';
|
||||
import { setSortedBy } from '../learners/data';
|
||||
import { setStatusFilter } from '../posts/data';
|
||||
import { getHttpErrorStatus } from '../utils';
|
||||
import { getDiscussionsConfig, getDiscussionsSettings } from './api';
|
||||
import {
|
||||
@@ -16,13 +22,23 @@ import {
|
||||
export function fetchCourseConfig(courseId) {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
let learnerSort = LearnersOrdering.BY_LAST_ACTIVITY;
|
||||
const postsFilterStatus = PostsStatusFilter.ALL;
|
||||
dispatch(fetchConfigRequest());
|
||||
|
||||
const config = await getDiscussionsConfig(courseId);
|
||||
if (config.is_user_admin) {
|
||||
if (config.has_moderation_privileges) {
|
||||
const settings = await getDiscussionsSettings(courseId);
|
||||
Object.assign(config, { settings });
|
||||
}
|
||||
|
||||
if ((config.has_moderation_privileges || config.is_group_ta)) {
|
||||
learnerSort = LearnersOrdering.BY_FLAG;
|
||||
}
|
||||
|
||||
dispatch(fetchConfigSuccess(camelCaseObject(config)));
|
||||
dispatch(setSortedBy(learnerSort));
|
||||
dispatch(setStatusFilter(postsFilterStatus));
|
||||
} catch (error) {
|
||||
if (getHttpErrorStatus(error) === 403) {
|
||||
dispatch(fetchConfigDenied());
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { PageBanner } from '@edx/paragon';
|
||||
|
||||
import { selectBlackoutDate } from '../data/selectors';
|
||||
import messages from '../messages';
|
||||
import { inBlackoutDateRange } from '../utils';
|
||||
|
||||
function BlackoutInformationBanner({
|
||||
intl,
|
||||
}) {
|
||||
const isDiscussionsBlackout = inBlackoutDateRange(useSelector(selectBlackoutDate));
|
||||
const [showBanner, setShowBanner] = useState(true);
|
||||
|
||||
return (
|
||||
<PageBanner
|
||||
variant="accentB"
|
||||
show={isDiscussionsBlackout && showBanner}
|
||||
dismissible
|
||||
onDismiss={() => setShowBanner(false)}
|
||||
>
|
||||
<div className="font-weight-500">
|
||||
{intl.formatMessage(messages.blackoutDiscussionInformation)}
|
||||
</div>
|
||||
</PageBanner>
|
||||
);
|
||||
}
|
||||
|
||||
BlackoutInformationBanner.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(BlackoutInformationBanner);
|
||||
@@ -0,0 +1,76 @@
|
||||
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 { fetchConfigSuccess } from '../data/slices';
|
||||
import messages from '../messages';
|
||||
import BlackoutInformationBanner from './BlackoutInformationBanner';
|
||||
|
||||
let store;
|
||||
let container;
|
||||
const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||
let activeStartDate = new Date();
|
||||
activeStartDate.setDate(activeStartDate.getDate() - 2);
|
||||
let activeEndDate = new Date();
|
||||
activeEndDate.setDate(activeEndDate.getDate() + 2);
|
||||
activeStartDate = activeStartDate.toISOString();
|
||||
activeEndDate = activeEndDate.toISOString();
|
||||
|
||||
const getConfigData = (blackouts = []) => ({
|
||||
id: 'course-v1:edX+DemoX+Demo_Course',
|
||||
userRoles: ['Admin', 'Student'],
|
||||
hasModerationPrivileges: false,
|
||||
isGroupTa: false,
|
||||
isUserAdmin: false,
|
||||
blackouts,
|
||||
});
|
||||
|
||||
function renderComponent() {
|
||||
const wrapper = render(
|
||||
<IntlProvider locale="en">
|
||||
<AppProvider store={store}>
|
||||
<DiscussionContext.Provider value={{ courseId }}>
|
||||
<BlackoutInformationBanner />
|
||||
</DiscussionContext.Provider>
|
||||
</AppProvider>
|
||||
</IntlProvider>,
|
||||
);
|
||||
container = wrapper.container;
|
||||
return container;
|
||||
}
|
||||
|
||||
describe('Blackout Information Banner', () => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: false,
|
||||
roles: ['Student'],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ blackouts: [], visibility: false },
|
||||
{ blackouts: ['2021-12-31T10:15', '2021-12-31T10:20'], visibility: false },
|
||||
{ blackouts: [{ start: activeStartDate, end: activeEndDate }], visibility: true },
|
||||
{ blackouts: [{ start: activeEndDate, end: activeEndDate }], visibility: false },
|
||||
])('Test Blackout Banner is visible on app load if blackout date is active', async ({ blackouts, visibility }) => {
|
||||
store = initializeStore();
|
||||
await store.dispatch(fetchConfigSuccess(getConfigData(blackouts)));
|
||||
renderComponent();
|
||||
if (visibility) {
|
||||
const element = await screen.findByRole('alert');
|
||||
expect(element).toBeInTheDocument();
|
||||
expect(element).toHaveTextContent(messages.blackoutDiscussionInformation.defaultMessage);
|
||||
} else {
|
||||
const element = await screen.queryByRole('alert');
|
||||
expect(element).not.toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,26 +1,20 @@
|
||||
import React, { useRef } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Route, Switch } from 'react-router';
|
||||
|
||||
import { injectIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { Routes } from '../../data/constants';
|
||||
import { CommentsView } from '../comments';
|
||||
import { useContainerSizeForParent } from '../data/hooks';
|
||||
import { LearnersContentView } from '../learners';
|
||||
import LearnerPageHeader from '../learners/LearnerPageHeader';
|
||||
import { PostEditor } from '../posts';
|
||||
|
||||
export default function DiscussionContent() {
|
||||
const refContainer = useRef(null);
|
||||
function DiscussionContent() {
|
||||
const postEditorVisible = useSelector((state) => state.threads.postEditorVisible);
|
||||
useContainerSizeForParent(refContainer);
|
||||
|
||||
return (
|
||||
<div className="d-flex bg-light-400 flex-column w-75 w-xs-100 w-xl-75 align-items-center h-100 overflow-auto">
|
||||
<Route path={Routes.LEARNERS.LEARNER}>
|
||||
<LearnerPageHeader />
|
||||
</Route>
|
||||
<div className="d-flex flex-column w-100 mw-xl" ref={refContainer}>
|
||||
<div className="d-flex bg-light-400 flex-column w-75 w-xs-100 w-xl-75 align-items-center">
|
||||
<div className="d-flex flex-column w-100">
|
||||
{postEditorVisible ? (
|
||||
<Route path={Routes.POSTS.NEW_POST}>
|
||||
<PostEditor />
|
||||
@@ -33,12 +27,11 @@ export default function DiscussionContent() {
|
||||
<Route path={Routes.COMMENTS.PATH}>
|
||||
<CommentsView />
|
||||
</Route>
|
||||
<Route path={Routes.LEARNERS.LEARNER}>
|
||||
<LearnersContentView />
|
||||
</Route>
|
||||
</Switch>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default injectIntl(DiscussionContent);
|
||||
|
||||
@@ -1,26 +1,56 @@
|
||||
import React from 'react';
|
||||
import React, { useContext, useEffect, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { useSelector } from 'react-redux';
|
||||
import {
|
||||
Redirect, Route, Switch, useLocation,
|
||||
} from 'react-router';
|
||||
|
||||
import { Routes } from '../../data/constants';
|
||||
import { LearnersView } from '../learners';
|
||||
import { useWindowSize } from '@edx/paragon';
|
||||
|
||||
import { RequestStatus, Routes } from '../../data/constants';
|
||||
import { DiscussionContext } from '../common/context';
|
||||
import {
|
||||
useContainerSize, useIsOnDesktop, useIsOnXLDesktop, useShowLearnersTab,
|
||||
} from '../data/hooks';
|
||||
import { selectconfigLoadingStatus } from '../data/selectors';
|
||||
import { LearnerPostsView, LearnersView } from '../learners';
|
||||
import { PostsView } from '../posts';
|
||||
import { TopicsView } from '../topics';
|
||||
|
||||
export default function DiscussionSidebar({ displaySidebar }) {
|
||||
export default function DiscussionSidebar({ displaySidebar, postActionBarRef }) {
|
||||
const location = useLocation();
|
||||
const isOnDesktop = useIsOnDesktop();
|
||||
const isOnXLDesktop = useIsOnXLDesktop();
|
||||
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 (isOnDesktop) {
|
||||
sidebarRef.current.style.maxHeight = `${windowHeight - postActionBarHeight}px`;
|
||||
}
|
||||
sidebarRef.current.style.minHeight = `${windowHeight - postActionBarHeight}px`;
|
||||
sidebarRef.current.style.top = `${postActionBarHeight}px`;
|
||||
}
|
||||
}, [sidebarRef, postActionBarHeight, inContext]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames('flex-column', {
|
||||
ref={sidebarRef}
|
||||
className={classNames('flex-column position-sticky', {
|
||||
'd-none': !displaySidebar,
|
||||
'd-flex w-25 w-xs-100 w-lg-25 overflow-auto h-100 pb-2': displaySidebar,
|
||||
'd-flex overflow-auto': displaySidebar,
|
||||
'w-100': !isOnDesktop,
|
||||
'sidebar-desktop-width': isOnDesktop && !isOnXLDesktop,
|
||||
'w-25 sidebar-XL-width': isOnXLDesktop,
|
||||
'min-content-height': !inContext,
|
||||
})}
|
||||
style={{ minWidth: '30rem' }}
|
||||
data-testid="sidebar"
|
||||
>
|
||||
<Switch>
|
||||
@@ -29,7 +59,13 @@ export default function DiscussionSidebar({ displaySidebar }) {
|
||||
component={PostsView}
|
||||
/>
|
||||
<Route path={Routes.TOPICS.PATH} component={TopicsView} />
|
||||
<Route path={Routes.LEARNERS.PATH} component={LearnersView} />
|
||||
{redirectToLearnersTab && (
|
||||
<Route path={Routes.LEARNERS.POSTS} component={LearnerPostsView} />
|
||||
)}
|
||||
{redirectToLearnersTab && (
|
||||
<Route path={Routes.LEARNERS.PATH} component={LearnersView} />
|
||||
)}
|
||||
{configStatus === RequestStatus.SUCCESSFUL && (
|
||||
<Redirect
|
||||
from={Routes.DISCUSSIONS.PATH}
|
||||
to={{
|
||||
@@ -37,6 +73,7 @@ export default function DiscussionSidebar({ displaySidebar }) {
|
||||
pathname: Routes.POSTS.ALL_POSTS,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Switch>
|
||||
</div>
|
||||
);
|
||||
@@ -44,8 +81,13 @@ export default function DiscussionSidebar({ displaySidebar }) {
|
||||
|
||||
DiscussionSidebar.defaultProps = {
|
||||
displaySidebar: false,
|
||||
postActionBarRef: null,
|
||||
};
|
||||
|
||||
DiscussionSidebar.propTypes = {
|
||||
displaySidebar: PropTypes.bool,
|
||||
postActionBarRef: PropTypes.oneOfType([
|
||||
PropTypes.func,
|
||||
PropTypes.shape({ current: PropTypes.instanceOf(Element) }),
|
||||
]),
|
||||
};
|
||||
|
||||
@@ -11,7 +11,9 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
|
||||
import { initializeStore } from '../../store';
|
||||
import { threadsApiUrl } from '../posts/data/api';
|
||||
import { DiscussionContext } from '../common/context';
|
||||
import { fetchConfigSuccess } from '../data/slices';
|
||||
import { getThreadsApiUrl } from '../posts/data/api';
|
||||
import DiscussionSidebar from './DiscussionSidebar';
|
||||
|
||||
import '../posts/data/__factories__';
|
||||
@@ -19,6 +21,7 @@ import '../posts/data/__factories__';
|
||||
let store;
|
||||
let container;
|
||||
const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||
const threadsApiUrl = getThreadsApiUrl();
|
||||
let axiosMock;
|
||||
|
||||
function renderComponent(displaySidebar = true, location = `/${courseId}/`) {
|
||||
@@ -26,9 +29,11 @@ function renderComponent(displaySidebar = true, location = `/${courseId}/`) {
|
||||
<IntlProvider locale="en">
|
||||
<ResponsiveContext.Provider value={{ width: 1280 }}>
|
||||
<AppProvider store={store}>
|
||||
<MemoryRouter initialEntries={[location]}>
|
||||
<DiscussionSidebar displaySidebar={displaySidebar} />
|
||||
</MemoryRouter>
|
||||
<DiscussionContext.Provider value={{ courseId }}>
|
||||
<MemoryRouter initialEntries={[location]}>
|
||||
<DiscussionSidebar displaySidebar={displaySidebar} postActionBarRef={null} />
|
||||
</MemoryRouter>
|
||||
</DiscussionContext.Provider>
|
||||
</AppProvider>
|
||||
</ResponsiveContext.Provider>
|
||||
</IntlProvider>,
|
||||
@@ -51,6 +56,7 @@ describe('DiscussionSidebar', () => {
|
||||
store = initializeStore({
|
||||
blocks: { blocks: { 'test-usage-key': { topics: ['some-topic-2', 'some-topic-0'] } } },
|
||||
});
|
||||
store.dispatch(fetchConfigSuccess({}));
|
||||
Factory.resetAll();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
});
|
||||
@@ -73,7 +79,10 @@ describe('DiscussionSidebar', () => {
|
||||
test('User will be redirected to "All Posts" by default', async () => {
|
||||
axiosMock.onGet(threadsApiUrl)
|
||||
.reply(({ params }) => [200, Factory.build('threadsResult', {}, {
|
||||
threadAttrs: { title: `Thread by ${params.author || 'other users'}` },
|
||||
threadAttrs: {
|
||||
title: `Thread by ${params.author || 'other users'}`,
|
||||
previewBody: 'thread preview body',
|
||||
},
|
||||
})]);
|
||||
renderComponent();
|
||||
await act(async () => expect(await screen.findAllByText('Thread by other users')).toBeTruthy());
|
||||
@@ -85,7 +94,10 @@ describe('DiscussionSidebar', () => {
|
||||
axiosMock.onGet(threadsApiUrl)
|
||||
.reply(({ params }) => [200, Factory.build('threadsResult', {}, {
|
||||
count: postCount,
|
||||
threadAttrs: { title: `Thread by ${params.author || 'other users'}` },
|
||||
threadAttrs: {
|
||||
title: `Thread by ${params.author || 'other users'}`,
|
||||
previewBody: 'thread preview body',
|
||||
},
|
||||
})]);
|
||||
renderComponent();
|
||||
await act(async () => expect(await screen.findAllByText('Thread by other users')).toBeTruthy());
|
||||
|
||||
@@ -1,34 +1,47 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { useSelector } from 'react-redux';
|
||||
import {
|
||||
Route, Switch, useLocation, useRouteMatch,
|
||||
} from 'react-router';
|
||||
|
||||
import Footer from '@edx/frontend-component-footer';
|
||||
import { LearningHeader as Header } from '@edx/frontend-component-header';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
import { PostActionsBar } from '../../components';
|
||||
import { CourseTabsNavigation } from '../../components/NavigationBar';
|
||||
import { ALL_ROUTES, DiscussionProvider, Routes } from '../../data/constants';
|
||||
import { DiscussionContext } from '../common/context';
|
||||
import {
|
||||
useCourseDiscussionData, useIsOnDesktop, useRedirectToThread, useSidebarVisible,
|
||||
useCourseDiscussionData, useIsOnDesktop, useRedirectToThread, useShowLearnersTab, useSidebarVisible,
|
||||
} from '../data/hooks';
|
||||
import { selectDiscussionProvider } from '../data/selectors';
|
||||
import { EmptyPosts, EmptyTopics } from '../empty-posts';
|
||||
import { EmptyLearners, EmptyPosts, EmptyTopics } from '../empty-posts';
|
||||
import messages from '../messages';
|
||||
import { BreadcrumbMenu, LegacyBreadcrumbMenu, NavigationBar } from '../navigation';
|
||||
import { postMessageToParent } from '../utils';
|
||||
import BlackoutInformationBanner from './BlackoutInformationBanner';
|
||||
import DiscussionContent from './DiscussionContent';
|
||||
import DiscussionSidebar from './DiscussionSidebar';
|
||||
import InformationBanner from './InformationsBanner';
|
||||
|
||||
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 { 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,
|
||||
@@ -37,14 +50,13 @@ export default function DiscussionsHome() {
|
||||
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;
|
||||
|
||||
const displayContentArea = postId || postEditorVisible || (learnerUsername && postId);
|
||||
let displaySidebar = useSidebarVisible();
|
||||
|
||||
const isOnDesktop = useIsOnDesktop();
|
||||
|
||||
const { courseNumber, courseTitle, org } = useSelector((state) => state.courseTabs);
|
||||
if (displayContentArea) {
|
||||
// If the window is larger than a particular size, show the sidebar for navigating between posts/topics.
|
||||
// However, for smaller screens or embeds, only show the sidebar if the content area isn't displayed.
|
||||
@@ -53,7 +65,7 @@ export default function DiscussionsHome() {
|
||||
|
||||
const provider = useSelector(selectDiscussionProvider);
|
||||
useCourseDiscussionData(courseId);
|
||||
useRedirectToThread(courseId);
|
||||
useRedirectToThread(courseId, inContext);
|
||||
useEffect(() => {
|
||||
if (path && path !== 'undefined') {
|
||||
postMessageToParent('discussions.navigate', { path });
|
||||
@@ -71,38 +83,50 @@ export default function DiscussionsHome() {
|
||||
learnerUsername,
|
||||
}}
|
||||
>
|
||||
<main className="container-fluid d-flex flex-column p-0 h-100 w-100 overflow-hidden">
|
||||
{!inContext && <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} />}
|
||||
<div
|
||||
className="d-flex flex-row justify-content-between navbar fixed-top"
|
||||
style={{ boxShadow: '0px 2px 4px rgb(0 0 0 / 15%), 0px 2px 8px rgb(0 0 0 / 15%)' }}
|
||||
className={classNames('header-action-bar', { 'shadow-none border-light-300 border-bottom': inContext })}
|
||||
ref={postActionBarRef}
|
||||
>
|
||||
{!inContext && (
|
||||
<Route path={Routes.DISCUSSIONS.PATH} component={NavigationBar} />
|
||||
)}
|
||||
<PostActionsBar inContext={inContext} />
|
||||
<div
|
||||
className={classNames('d-flex flex-row justify-content-between navbar fixed-top', {
|
||||
'pl-4 pr-2.5 py-1.5': inContext,
|
||||
})}
|
||||
>
|
||||
{!inContext && <Route path={Routes.DISCUSSIONS.PATH} component={NavigationBar} />}
|
||||
<PostActionsBar inContext={inContext} />
|
||||
</div>
|
||||
{isFeedbackBannerVisible && <InformationBanner />}
|
||||
<BlackoutInformationBanner />
|
||||
</div>
|
||||
<Route
|
||||
path={[Routes.POSTS.PATH, Routes.TOPICS.CATEGORY]}
|
||||
component={provider === DiscussionProvider.LEGACY ? LegacyBreadcrumbMenu : BreadcrumbMenu}
|
||||
/>
|
||||
<div className="d-flex flex-row overflow-hidden flex-grow-1">
|
||||
<DiscussionSidebar displaySidebar={displaySidebar} />
|
||||
{!inContext && (
|
||||
<Route
|
||||
path={[Routes.POSTS.PATH, Routes.TOPICS.CATEGORY]}
|
||||
component={provider === DiscussionProvider.LEGACY ? LegacyBreadcrumbMenu : BreadcrumbMenu}
|
||||
/>
|
||||
)}
|
||||
<div className="d-flex flex-row">
|
||||
<DiscussionSidebar displaySidebar={displaySidebar} postActionBarRef={postActionBarRef} />
|
||||
{displayContentArea && <DiscussionContent />}
|
||||
{!displayContentArea && (
|
||||
<Switch>
|
||||
<Route path={Routes.TOPICS.PATH} component={EmptyTopics} />
|
||||
<Route
|
||||
path={Routes.POSTS.MY_POSTS}
|
||||
render={routeProps => <EmptyPosts {...routeProps} subTitleMessage={messages.emptyMyPosts} />}
|
||||
/>
|
||||
<Route
|
||||
path={[Routes.POSTS.PATH, Routes.POSTS.ALL_POSTS]}
|
||||
render={routeProps => <EmptyPosts {...routeProps} subTitleMessage={messages.emptyAllPosts} />}
|
||||
/>
|
||||
</Switch>
|
||||
<Switch>
|
||||
<Route path={Routes.TOPICS.PATH} component={EmptyTopics} />
|
||||
<Route
|
||||
path={Routes.POSTS.MY_POSTS}
|
||||
render={routeProps => <EmptyPosts {...routeProps} subTitleMessage={messages.emptyMyPosts} />}
|
||||
/>
|
||||
<Route
|
||||
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} /> }
|
||||
</Switch>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
{!inContext && <Footer />}
|
||||
</DiscussionContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -87,4 +87,11 @@ describe('DiscussionsHome', () => {
|
||||
await waitFor(() => expect(window.parent.postMessage).toHaveBeenCalled());
|
||||
window.parent = parent;
|
||||
});
|
||||
|
||||
test('header, course navigation bar and footer are visible', async () => {
|
||||
renderComponent();
|
||||
expect(screen.queryByRole('banner')).toBeInTheDocument();
|
||||
expect(document.getElementById('courseTabsNavigation')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('contentinfo')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user