Compare commits
193 Commits
jawayria/n
...
inf-554-2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
034638f093 | ||
|
|
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 | ||
|
|
007972f61a | ||
|
|
2ed6d14b98 | ||
|
|
4fa31fedbb | ||
|
|
3719e08321 | ||
|
|
e5886c0e04 | ||
|
|
786c278a2d | ||
|
|
e247bf859a | ||
|
|
3be40852eb | ||
|
|
847a3b25ec | ||
|
|
e2407e53e3 | ||
|
|
a550cfd30b | ||
|
|
af670ec1ab | ||
|
|
4b7145dccd | ||
|
|
11f98d32a1 | ||
|
|
bea2390b4d | ||
|
|
a77b947e8a | ||
|
|
34a0ae8939 | ||
|
|
dfec88de20 | ||
|
|
c57dfc1fc5 | ||
|
|
d1dce4f2ea | ||
|
|
e8a3e4eaa8 | ||
|
|
7e5ae2a298 | ||
|
|
36ff2fad27 | ||
|
|
7a864ed14e | ||
|
|
dbade5dbd1 | ||
|
|
d9f085279e | ||
|
|
59f97fff7d |
4
.env
4
.env
@@ -15,8 +15,10 @@ 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: ''
|
||||
@@ -16,8 +16,10 @@ 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'
|
||||
14
.env.test
14
.env.test
@@ -8,14 +8,16 @@ 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'
|
||||
@@ -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
|
||||
|
||||
13
.github/workflows/lockfileversion-check.yml
vendored
Normal file
13
.github/workflows/lockfileversion-check.yml
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
#check package-lock file version
|
||||
|
||||
name: Lockfile Version check
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
version-check:
|
||||
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 -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',
|
||||
],
|
||||
|
||||
37489
package-lock.json
generated
37489
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
21
package.json
21
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,16 +25,18 @@
|
||||
},
|
||||
"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/frontend-component-footer": "11.2.0",
|
||||
"@edx/frontend-component-header": "3.2.0",
|
||||
"@edx/frontend-platform": "2.6.1",
|
||||
"@edx/paragon": "19.10.1",
|
||||
"@reduxjs/toolkit": "1.8.0",
|
||||
"@tinymce/tinymce-react": "3.13.1",
|
||||
@@ -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.2",
|
||||
"@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">
|
||||
<body class="vh-100 vw-100 h-100 m-0">
|
||||
<div id="root" class="vh-100 vw-100 small"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
60
src/components/HTMLLoader.jsx
Normal file
60
src/components/HTMLLoader.jsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import MathJax from 'react-mathjax-preview';
|
||||
|
||||
const baseConfig = {
|
||||
showMathMenu: true,
|
||||
tex2jax: {
|
||||
inlineMath: [
|
||||
['$', '$'],
|
||||
['\\\\(', '\\\\)'],
|
||||
['\\(', '\\)'],
|
||||
['[mathjaxinline]', '[/mathjaxinline]'],
|
||||
],
|
||||
displayMath: [
|
||||
['[mathjax]', '[/mathjax]'],
|
||||
['$$', '$$'],
|
||||
['\\\\[', '\\\\]'],
|
||||
['\\[', '\\]'],
|
||||
],
|
||||
},
|
||||
|
||||
skipStartupTypeset: true,
|
||||
};
|
||||
|
||||
function HTMLLoader({ htmlNode, componentId, cssClassName }) {
|
||||
const isLatex = htmlNode.match(/(\${1,2})((?:\\.|.)*)\1/)
|
||||
|| htmlNode.match(/(\[mathjax](.+?)\[\/mathjax])+/)
|
||||
|| htmlNode.match(/(\[mathjaxinline](.+?)\[\/mathjaxinline])+/)
|
||||
|| 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 { API_BASE_URL } 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 = `${API_BASE_URL}/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={`p-2 bg-light-200 rounded box-shadow-down-1 post-preview ${isPost ? 'mt-2 mb-5' : 'my-3'}`}
|
||||
style={{ maxHeight: '200px', overflow: 'scroll' }}
|
||||
>
|
||||
<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);
|
||||
47
src/components/SearchInfo.jsx
Normal file
47
src/components/SearchInfo.jsx
Normal file
@@ -0,0 +1,47 @@
|
||||
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,
|
||||
}) {
|
||||
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">
|
||||
{
|
||||
loadingStatus === RequestStatus.SUCCESSFUL
|
||||
? intl.formatMessage(messages.searchInfo, { count, text })
|
||||
: intl.formatMessage(messages.searchInfoSearching)
|
||||
}
|
||||
</Button>
|
||||
<Button variant="link" size="inline" className="ml-auto mr-4" onClick={onClear}>
|
||||
{intl.formatMessage(messages.clearSearch)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
SearchInfo.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
count: PropTypes.number.isRequired,
|
||||
text: PropTypes.string.isRequired,
|
||||
loadingStatus: PropTypes.string.isRequired,
|
||||
onClear: PropTypes.func,
|
||||
};
|
||||
|
||||
SearchInfo.defaultProps = {
|
||||
onClear: () => {},
|
||||
};
|
||||
|
||||
export default injectIntl(SearchInfo);
|
||||
@@ -6,6 +6,7 @@ import { useParams } from 'react-router';
|
||||
// eslint-disable-next-line no-unused-vars,import/no-extraneous-dependencies
|
||||
import tinymce from 'tinymce/tinymce';
|
||||
|
||||
import { MAX_UPLOAD_FILE_SIZE } from '../data/constants';
|
||||
import { uploadFile } from '../discussions/posts/data/api';
|
||||
|
||||
import 'tinymce/plugins/code';
|
||||
@@ -23,6 +24,9 @@ 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 +35,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,6 +51,7 @@ 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
|
||||
@@ -55,6 +61,11 @@ export default function TinyMCEEditor(props) {
|
||||
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');
|
||||
success(location);
|
||||
@@ -81,17 +92,21 @@ export default function TinyMCEEditor(props) {
|
||||
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'
|
||||
autosave_restore_when_empty: false,
|
||||
plugins: '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'
|
||||
+ ' | undo redo',
|
||||
+ ' | emoticons'
|
||||
+ ' | charmap',
|
||||
content_css: false,
|
||||
content_style: contentStyle,
|
||||
body_class: 'm-2',
|
||||
body_class: 'm-2 text-editor',
|
||||
default_link_target: '_blank',
|
||||
target_list: false,
|
||||
images_upload_handler: uploadHandler,
|
||||
setup,
|
||||
}}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
24
src/components/icons/ReportGmailerrorred.jsx
Normal file
24
src/components/icons/ReportGmailerrorred.jsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function ReportGmailerrorred() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
fill="none"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<g clipPath="url(#clip0_6935_1296)">
|
||||
<path d="M13.1083 2.5H6.89167L2.5 6.89167V13.1083L6.89167 17.5H13.1083L17.5 13.1083V6.89167L13.1083 2.5ZM15.8333 12.4167L12.4167 15.8333H7.58333L4.16667 12.4167V7.58333L7.58333 4.16667H12.4167L15.8333 7.58333V12.4167Z" fill="#00262B" />
|
||||
<path d="M9.99996 14.1667C10.4602 14.1667 10.8333 13.7936 10.8333 13.3333C10.8333 12.8731 10.4602 12.5 9.99996 12.5C9.53972 12.5 9.16663 12.8731 9.16663 13.3333C9.16663 13.7936 9.53972 14.1667 9.99996 14.1667Z" fill="#00262B" />
|
||||
<path d="M9.16663 5.83331H10.8333V11.6666H9.16663V5.83331Z" fill="#00262B" />
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_6935_1296">
|
||||
<rect width="20" height="20" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
12
src/components/icons/index.js
Normal file
12
src/components/icons/index.js
Normal file
@@ -0,0 +1,12 @@
|
||||
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 ReportGmailerrorred } from './ReportGmailerrorred';
|
||||
export { default as StarFilled } from './StarFilled';
|
||||
export { default as StarOutline } from './StarOutline';
|
||||
export { default as ThumbUpFilled } from './ThumbUpFilled';
|
||||
export { default as ThumbUpOutline } from './ThumbUpOutline';
|
||||
@@ -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';
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -134,17 +135,6 @@ export const LearnersOrdering = {
|
||||
BY_LAST_ACTIVITY: 'activity',
|
||||
};
|
||||
|
||||
/**
|
||||
* Enum for Learner content tabs
|
||||
* @readonly
|
||||
* @enum {string}
|
||||
*/
|
||||
export const LearnerTabs = {
|
||||
POSTS: 'posts',
|
||||
COMMENTS: 'comments',
|
||||
RESPONSES: 'responses',
|
||||
};
|
||||
|
||||
/**
|
||||
* Enum for discussion provider types supported by the MFE.
|
||||
* @type {{OPEN_EDX: string, LEGACY: string}}
|
||||
@@ -162,12 +152,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`,
|
||||
@@ -179,38 +164,57 @@ export const Routes = {
|
||||
`${BASE_PATH}`,
|
||||
],
|
||||
EDIT_POST: [
|
||||
`${BASE_PATH}/category/:category/posts/:postId/edit`,
|
||||
`${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: {
|
||||
PATH: [
|
||||
`${BASE_PATH}/category/:category/posts/:postId`,
|
||||
`${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: {
|
||||
category: `${BASE_PATH}/category/:category/posts/:postId`,
|
||||
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: {
|
||||
PATH: [
|
||||
`${BASE_PATH}/topics/:topicId?`,
|
||||
`${BASE_PATH}/category/:category`,
|
||||
`${BASE_PATH}/topics`,
|
||||
],
|
||||
ALL: `${BASE_PATH}/topics`,
|
||||
CATEGORY: `${BASE_PATH}/category/:category`,
|
||||
CATEGORY_POST: `${BASE_PATH}/category/:category/posts/:postId`,
|
||||
TOPIC: `${BASE_PATH}/topics/:topicId`,
|
||||
},
|
||||
};
|
||||
|
||||
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])
|
||||
.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;
|
||||
|
||||
@@ -8,6 +8,7 @@ import { initializeStore } from '../store';
|
||||
import { executeThunk } from '../test-utils';
|
||||
import { getBlocksAPIResponse } from './__factories__';
|
||||
import { blocksAPIURL } from './api';
|
||||
import { RequestStatus } from './constants';
|
||||
import { fetchCourseBlocks } from './thunks';
|
||||
|
||||
const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||
@@ -35,7 +36,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 +78,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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,4 +32,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;
|
||||
|
||||
@@ -58,19 +58,21 @@ function normaliseCourseBlocks({
|
||||
} else {
|
||||
blocks[verticalId].children?.forEach(discussionId => {
|
||||
const discussion = camelCaseObject(blocks[discussionId]);
|
||||
const { topicId } = discussion.studentViewData;
|
||||
blockData[discussionId] = discussion;
|
||||
// Add this topic id to the list of topics for the current chapter, sequential, and vertical
|
||||
chapterData.topics.push(topicId);
|
||||
blockData[sequentialId].topics.push(topicId);
|
||||
blockData[verticalId].topics.push(topicId);
|
||||
// Store the topic's context in the course in a map
|
||||
topics[topicId] = {
|
||||
chapterName: blockData[chapterId].displayName,
|
||||
verticalName: blockData[sequentialId].displayName,
|
||||
unitName: blockData[verticalId].displayName,
|
||||
unitLink: blockData[verticalId].lmsWebUrl,
|
||||
};
|
||||
const { topicId } = discussion.studentViewData || {};
|
||||
if (topicId) {
|
||||
blockData[discussionId] = discussion;
|
||||
// Add this topic id to the list of topics for the current chapter, sequential, and vertical
|
||||
chapterData.topics.push(topicId);
|
||||
blockData[sequentialId].topics.push(topicId);
|
||||
blockData[verticalId].topics.push(topicId);
|
||||
// Store the topic's context in the course in a map
|
||||
topics[topicId] = {
|
||||
chapterName: blockData[chapterId].displayName,
|
||||
verticalName: blockData[sequentialId].displayName,
|
||||
unitName: blockData[verticalId].displayName,
|
||||
unitLink: blockData[verticalId].lmsWebUrl,
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useParams } from 'react-router';
|
||||
|
||||
import { ensureConfig, getConfig } from '@edx/frontend-platform';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Button, Spinner } from '@edx/paragon';
|
||||
|
||||
@@ -12,27 +11,23 @@ import { EndorsementStatus, ThreadType } from '../../data/constants';
|
||||
import { useDispatchWithState } from '../../data/hooks';
|
||||
import { Post } from '../posts';
|
||||
import { selectThread } from '../posts/data/selectors';
|
||||
import { markThreadAsRead } from '../posts/data/thunks';
|
||||
import { fetchThread, markThreadAsRead } from '../posts/data/thunks';
|
||||
import { 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 +59,7 @@ function DiscussionCommentsView({
|
||||
postId,
|
||||
intl,
|
||||
endorsed,
|
||||
isClosed,
|
||||
}) {
|
||||
const {
|
||||
comments,
|
||||
@@ -71,41 +67,48 @@ function DiscussionCommentsView({
|
||||
isLoading,
|
||||
handleLoadMoreResponses,
|
||||
} = usePostComments(postId, endorsed);
|
||||
const sortedComments = useMemo(() => [...filterPosts(comments, 'endorsed'),
|
||||
...filterPosts(comments, 'unendorsed')], [comments]);
|
||||
return (
|
||||
<div className="m-3">
|
||||
<div className="my-3">
|
||||
<>
|
||||
<div className="mx-4 text-primary-700" role="heading" aria-level="2" style={{ lineHeight: '28px' }}>
|
||||
{endorsed === EndorsementStatus.ENDORSED
|
||||
? intl.formatMessage(messages.endorsedResponseCount, { num: comments.length })
|
||||
: intl.formatMessage(messages.responseCount, { num: comments.length })}
|
||||
? intl.formatMessage(messages.endorsedResponseCount, { num: sortedComments.length })
|
||||
: intl.formatMessage(messages.responseCount, { num: sortedComments.length })}
|
||||
</div>
|
||||
{comments.map(comment => (
|
||||
<Comment comment={comment} key={comment.id} postType={postType} />
|
||||
))}
|
||||
|
||||
{hasMorePages && !isLoading && (
|
||||
<Button
|
||||
onClick={handleLoadMoreResponses}
|
||||
variant="link"
|
||||
block="true"
|
||||
className="card p-4"
|
||||
data-testid="load-more-comments"
|
||||
>
|
||||
{intl.formatMessage(messages.loadMoreResponses)}
|
||||
</Button>
|
||||
)}
|
||||
{isLoading
|
||||
<div className="mx-4" role="list">
|
||||
{sortedComments.map(comment => (
|
||||
<Comment comment={comment} key={comment.id} postType={postType} isClosedPost={isClosed} />
|
||||
))}
|
||||
{!!sortedComments.length && !isClosed
|
||||
&& <ResponseEditor postId={postId} addWrappingDiv />}
|
||||
{hasMorePages && !isLoading && (
|
||||
<Button
|
||||
onClick={handleLoadMoreResponses}
|
||||
variant="link"
|
||||
block="true"
|
||||
className="card p-4"
|
||||
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>
|
||||
</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,
|
||||
@@ -115,16 +118,18 @@ DiscussionCommentsView.propTypes = {
|
||||
function CommentsView({ intl }) {
|
||||
const { postId } = useParams();
|
||||
const thread = usePost(postId);
|
||||
const dispatch = useDispatch();
|
||||
if (!thread) {
|
||||
dispatch(fetchThread(postId, true));
|
||||
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">
|
||||
<div className="discussion-comments d-flex flex-column m-4 p-4.5 card">
|
||||
<Post post={thread} />
|
||||
<ResponseEditor postId={postId} />
|
||||
{!thread.closed && <ResponseEditor postId={postId} /> }
|
||||
</div>
|
||||
{thread.type === ThreadType.DISCUSSION
|
||||
&& (
|
||||
@@ -133,6 +138,7 @@ function CommentsView({ intl }) {
|
||||
intl={intl}
|
||||
postType={thread.type}
|
||||
endorsed={EndorsementStatus.DISCUSSION}
|
||||
isClosed={thread.closed}
|
||||
/>
|
||||
)}
|
||||
{thread.type === ThreadType.QUESTION && (
|
||||
@@ -142,12 +148,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,6 +12,7 @@ import { AppProvider } from '@edx/frontend-platform/react';
|
||||
|
||||
import { initializeStore } from '../../store';
|
||||
import { executeThunk } from '../../test-utils';
|
||||
import { DiscussionContext } from '../common/context';
|
||||
import { courseConfigApiUrl } from '../data/api';
|
||||
import { fetchCourseConfig } from '../data/thunks';
|
||||
import DiscussionContent from '../discussions-home/DiscussionContent';
|
||||
@@ -81,16 +82,20 @@ 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>,
|
||||
);
|
||||
@@ -155,9 +160,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 +172,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 +194,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 +221,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 +257,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' },
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -7,9 +7,12 @@ 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 { 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 +26,7 @@ function Comment({
|
||||
postType,
|
||||
comment,
|
||||
showFullThread = true,
|
||||
isClosedPost,
|
||||
intl,
|
||||
}) {
|
||||
const dispatch = useDispatch();
|
||||
@@ -34,93 +38,110 @@ function Comment({
|
||||
const [isReplying, setReplying] = useState(false);
|
||||
const hasMorePages = useSelector(selectCommentHasMorePages(comment.id));
|
||||
const currentPage = useSelector(selectCommentCurrentPage(comment.id));
|
||||
const blackoutDateRange = useSelector(selectBlackoutDate);
|
||||
|
||||
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(editComment(comment.id, { endorsed: !comment.endorsed }, ContentActions.ENDORSE));
|
||||
await dispatch(fetchThread(comment.threadId));
|
||||
},
|
||||
[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" 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)} />
|
||||
)
|
||||
: <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)}
|
||||
</Button>
|
||||
)}
|
||||
{!isNested && showFullThread
|
||||
&& (
|
||||
isReplying
|
||||
? (
|
||||
<CommentEditor
|
||||
comment={{
|
||||
threadId: comment.threadId,
|
||||
parentId: comment.id,
|
||||
}}
|
||||
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-4.5"
|
||||
variant="outline-primary"
|
||||
onClick={() => setReplying(true)}
|
||||
>
|
||||
{intl.formatMessage(messages.addComment)}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -130,11 +151,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);
|
||||
|
||||
@@ -10,8 +10,14 @@ import { AppContext } from '@edx/frontend-platform/react';
|
||||
import { Button, Form, StatefulButton } from '@edx/paragon';
|
||||
|
||||
import { TinyMCEEditor } from '../../../components';
|
||||
import FormikErrorFeedback from '../../../components/FormikErrorFeedback';
|
||||
import PostPreviewPane from '../../../components/PostPreviewPane';
|
||||
import { useDispatchWithState } from '../../../data/hooks';
|
||||
import { selectModerationSettings, selectUserIsPrivileged } from '../../data/selectors';
|
||||
import {
|
||||
selectModerationSettings,
|
||||
selectUserHasModerationPrivileges,
|
||||
selectUserIsGroupTa,
|
||||
} from '../../data/selectors';
|
||||
import { formikCompatibleHandler, isFormikFieldInvalid } from '../../utils';
|
||||
import { addComment, editComment } from '../data/thunks';
|
||||
import messages from '../messages';
|
||||
@@ -20,15 +26,46 @@ function CommentEditor({
|
||||
intl,
|
||||
comment,
|
||||
onCloseEditor,
|
||||
edit,
|
||||
}) {
|
||||
const editorRef = useRef(null);
|
||||
const { authenticatedUser } = useContext(AppContext);
|
||||
const userIsPrivileged = useSelector(selectUserIsPrivileged);
|
||||
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
|
||||
const userIsGroupTa = useSelector(selectUserIsGroupTa);
|
||||
const { reasonCodesEnabled, editReasons } = useSelector(selectModerationSettings);
|
||||
const [submitting, dispatch] = useDispatchWithState();
|
||||
const editorRef = useRef(null);
|
||||
const saveUpdatedComment = async (values) => {
|
||||
|
||||
const canDisplayEditReason = (reasonCodesEnabled && (userHasModerationPrivileges || userIsGroupTa)
|
||||
&& edit && comment.author !== authenticatedUser.username
|
||||
);
|
||||
|
||||
const editReasonCodeValidation = canDisplayEditReason && {
|
||||
editReasonCode: Yup.string().required(intl.formatMessage(messages.editReasonCodeError)),
|
||||
};
|
||||
|
||||
const validationSchema = Yup.object().shape({
|
||||
comment: Yup.string()
|
||||
.required(),
|
||||
...editReasonCodeValidation,
|
||||
});
|
||||
|
||||
const initialValues = {
|
||||
comment: comment.rawBody,
|
||||
editReasonCode: comment?.lastEdit?.reasonCode || '',
|
||||
};
|
||||
|
||||
const handleCloseEditor = (resetForm) => {
|
||||
resetForm({ values: initialValues });
|
||||
onCloseEditor();
|
||||
};
|
||||
|
||||
const saveUpdatedComment = async (values, { resetForm }) => {
|
||||
if (comment.id) {
|
||||
await dispatch(editComment(comment.id, values));
|
||||
const payload = {
|
||||
...values,
|
||||
editReasonCode: values.editReasonCode || undefined,
|
||||
};
|
||||
await dispatch(editComment(comment.id, payload));
|
||||
} else {
|
||||
await dispatch(addComment(values.comment, comment.threadId, comment.parentId));
|
||||
}
|
||||
@@ -36,22 +73,16 @@ 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.
|
||||
const editorId = `comment-editor-${comment.id || comment.parentId || comment.threadId}`;
|
||||
|
||||
return (
|
||||
<Formik
|
||||
initialValues={{ comment: comment.rawBody }}
|
||||
validationSchema={Yup.object()
|
||||
.shape({
|
||||
comment: Yup.string()
|
||||
.required(),
|
||||
editReasonCode: Yup.string()
|
||||
.nullable()
|
||||
.default(undefined),
|
||||
})}
|
||||
initialValues={initialValues}
|
||||
validationSchema={validationSchema}
|
||||
onSubmit={saveUpdatedComment}
|
||||
>
|
||||
{({
|
||||
@@ -61,12 +92,16 @@ function CommentEditor({
|
||||
handleSubmit,
|
||||
handleBlur,
|
||||
handleChange,
|
||||
resetForm,
|
||||
}) => (
|
||||
<Form onSubmit={handleSubmit}>
|
||||
{(reasonCodesEnabled
|
||||
&& userIsPrivileged
|
||||
&& comment.author !== authenticatedUser.username) && (
|
||||
<Form.Group>
|
||||
{canDisplayEditReason && (
|
||||
<Form.Group
|
||||
isInvalid={isFormikFieldInvalid('editReasonCode', {
|
||||
errors,
|
||||
touched,
|
||||
})}
|
||||
>
|
||||
<Form.Control
|
||||
name="editReasonCode"
|
||||
className="mt-2"
|
||||
@@ -85,6 +120,7 @@ function CommentEditor({
|
||||
<option key={code} value={code}>{label}</option>
|
||||
))}
|
||||
</Form.Control>
|
||||
<FormikErrorFeedback name="editReasonCode" />
|
||||
</Form.Group>
|
||||
)}
|
||||
<TinyMCEEditor
|
||||
@@ -108,10 +144,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>
|
||||
@@ -139,9 +176,15 @@ CommentEditor.propTypes = {
|
||||
parentId: PropTypes.string,
|
||||
rawBody: PropTypes.string,
|
||||
author: PropTypes.string,
|
||||
lastEdit: PropTypes.object,
|
||||
}).isRequired,
|
||||
onCloseEditor: PropTypes.func.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
edit: PropTypes.bool,
|
||||
};
|
||||
|
||||
CommentEditor.defaultProps = {
|
||||
edit: true,
|
||||
};
|
||||
|
||||
export default injectIntl(CommentEditor);
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
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 { AvatarBorderAndLabelColors, ThreadType } from '../../../data/constants';
|
||||
import { AvatarOutlineAndLabelColors, 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 { commentShape } from './proptypes';
|
||||
|
||||
@@ -19,22 +21,32 @@ function CommentHeader({
|
||||
actionHandlers,
|
||||
}) {
|
||||
const authorAvatars = useSelector(selectAuthorAvatars(comment.author));
|
||||
const colorClass = AvatarBorderAndLabelColors[comment.authorLabel];
|
||||
const colorClass = AvatarOutlineAndLabelColors[comment.authorLabel];
|
||||
const hasAnyAlert = useAlertBannerVisible(comment);
|
||||
|
||||
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}`} />
|
||||
<AuthorLabel author={comment.author} authorLabel={comment.authorLabel} labelColor={colorClass && `text-${colorClass}`} linkToProfile />
|
||||
</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" />)}
|
||||
<span className="btn-icon btn-icon-sm mr-1 align-items-center">
|
||||
{comment.endorsed && (postType === 'question'
|
||||
? <Icon src={CheckCircle} className="text-success" data-testid="check-icon" />
|
||||
: <Icon src={Verified} className="text-dark-500" data-testid="verified-icon" />)}
|
||||
</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>,
|
||||
);
|
||||
|
||||
@@ -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)}
|
||||
@@ -45,22 +55,36 @@ function Reply({
|
||||
hideDeleteConfirmation();
|
||||
}}
|
||||
/>
|
||||
<div className="d-flex flex-fill ml-6">
|
||||
<AlertBanner postType={null} content={reply} intl={intl} />
|
||||
</div>
|
||||
<div className="d-flex">
|
||||
|
||||
<div className="d-flex m-3">
|
||||
{hasAnyAlert && (
|
||||
<div className="d-flex">
|
||||
<div className="d-flex invisible">
|
||||
<Avatar />
|
||||
</div>
|
||||
<div className="w-100">
|
||||
<AlertBanner content={reply} intl={intl} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="d-flex">
|
||||
<div className="d-flex mr-3 mt-2.5">
|
||||
<Avatar
|
||||
className={`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,
|
||||
@@ -71,13 +95,11 @@ function Reply({
|
||||
</div>
|
||||
{isEditing
|
||||
? <CommentEditor comment={reply} onCloseEditor={() => setEditing(false)} />
|
||||
// eslint-disable-next-line react/no-danger
|
||||
: <div 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,23 +1,43 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { 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 { selectBlackoutDate } from '../../data/selectors';
|
||||
import { inBlackoutDateRange } from '../../utils';
|
||||
import messages from '../messages';
|
||||
import CommentEditor from './CommentEditor';
|
||||
|
||||
function ResponseEditor({
|
||||
postId,
|
||||
intl,
|
||||
addWrappingDiv,
|
||||
}) {
|
||||
const [addingResponse, setAddingResponse] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setAddingResponse(false);
|
||||
}, [postId]);
|
||||
|
||||
const blackoutDateRange = useSelector(selectBlackoutDate);
|
||||
|
||||
return addingResponse
|
||||
? (
|
||||
<CommentEditor comment={{ threadId: postId }} 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="px-2.5 py-2" onClick={() => setAddingResponse(true)}>
|
||||
{intl.formatMessage(messages.addResponse)}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -27,6 +47,11 @@ function ResponseEditor({
|
||||
ResponseEditor.propTypes = {
|
||||
postId: PropTypes.string.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
addWrappingDiv: PropTypes.bool,
|
||||
};
|
||||
|
||||
ResponseEditor.defaultProps = {
|
||||
addWrappingDiv: false,
|
||||
};
|
||||
|
||||
export default injectIntl(ResponseEditor);
|
||||
|
||||
@@ -117,28 +117,3 @@ export async function deleteComment(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) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(commentsApiUrl, {
|
||||
params: {
|
||||
course_id: courseId,
|
||||
username,
|
||||
},
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -26,7 +26,7 @@ const messages = defineMessages({
|
||||
},
|
||||
endorsedResponseCount: {
|
||||
id: 'discussions.comments.comment.endorsedResponseCount',
|
||||
defaultMessage: `{num, plural,
|
||||
defaultMessage: `{num, plural,
|
||||
=0 {No endorsed responses}
|
||||
one {Showing # endorsed response}
|
||||
other {Showing # endorsed responses}
|
||||
@@ -55,6 +55,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.',
|
||||
},
|
||||
@@ -147,6 +148,11 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Reason for editing',
|
||||
description: 'Label for field visible to moderators that allows them to select a reason for editing another user\'s response',
|
||||
},
|
||||
editReasonCodeError: {
|
||||
id: 'discussions.editor.posts.editReasonCode.error',
|
||||
defaultMessage: 'Select reason for editing',
|
||||
description: 'Error message visible to moderators when they submit the post/response/comment without select reason for editing',
|
||||
},
|
||||
editedBy: {
|
||||
id: 'discussions.comment.comments.editedBy',
|
||||
defaultMessage: 'Edited by',
|
||||
@@ -161,6 +167,16 @@ 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',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React, { 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 {
|
||||
@@ -10,9 +12,10 @@ 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';
|
||||
|
||||
function ActionsDropdown({
|
||||
intl,
|
||||
@@ -31,17 +34,22 @@ 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={() => setOpen(!isOpen)}
|
||||
alt={intl.formatMessage(messages.actionsAlt)}
|
||||
src={MoreHoriz}
|
||||
iconAs={Icon}
|
||||
disabled={disabled}
|
||||
size="sm"
|
||||
ref={dropdownIconRef}
|
||||
/>
|
||||
<ModalPopup
|
||||
onClose={() => setOpen(false)}
|
||||
positionRef={dropdownIconRef}
|
||||
|
||||
@@ -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,63 @@ 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 isReportedByCurrentUser = getAuthenticatedUser().username === content?.abuseFlaggedBy;
|
||||
const canSeeReportedBanner = (userHasModerationPrivileges || userIsGroupTa || isReportedByCurrentUser);
|
||||
|
||||
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 mb-1 flex-fill">
|
||||
{content.abuseFlagged && 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 +67,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,
|
||||
@@ -16,8 +21,11 @@ function AuthorLabel({
|
||||
linkToProfile,
|
||||
labelColor,
|
||||
}) {
|
||||
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 +34,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 !== 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-primary-500': !authorLabelMessage && !isRetiredUser,
|
||||
'text-gray-700': isRetiredUser,
|
||||
})}
|
||||
role="heading"
|
||||
aria-level="2"
|
||||
>
|
||||
{isRetiredUser ? '[Deactivated]' : author }
|
||||
</span>
|
||||
{icon && (
|
||||
<Icon
|
||||
style={{
|
||||
@@ -39,20 +64,35 @@ function AuthorLabel({
|
||||
/>
|
||||
)}
|
||||
{authorLabelMessage && (
|
||||
<span className="mr-3 ml-1">
|
||||
<span
|
||||
className={classNames('mr-3 font-size-14 font-style-normal font-family-inter font-weight-500', {
|
||||
'text-primary-500': !authorLabelMessage,
|
||||
})}
|
||||
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,
|
||||
|
||||
68
src/discussions/common/EndorsedAlertBanner.jsx
Normal file
68
src/discussions/common/EndorsedAlertBanner.jsx
Normal file
@@ -0,0 +1,68 @@
|
||||
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-2">
|
||||
{intl.formatMessage(
|
||||
isQuestion
|
||||
? messages.answeredLabel
|
||||
: messages.endorsedLabel,
|
||||
)}
|
||||
</span>
|
||||
<AuthorLabel author={content.endorsedBy} authorLabel={content.endorsedByLabel} linkToProfile />
|
||||
{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,10 +2,11 @@
|
||||
import React from 'react';
|
||||
|
||||
export const DiscussionContext = React.createContext({
|
||||
page: null,
|
||||
courseId: null,
|
||||
postId: null,
|
||||
category: null,
|
||||
commentId: null,
|
||||
learnerUsername: null,
|
||||
topicId: null,
|
||||
inContext: false,
|
||||
category: null,
|
||||
learnerUsername: null,
|
||||
});
|
||||
|
||||
@@ -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']));
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
/* 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';
|
||||
|
||||
@@ -12,8 +15,14 @@ import { fetchCourseBlocks } from '../../data/thunks';
|
||||
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, selectUserIsStaff, selectUserRoles,
|
||||
} from './selectors';
|
||||
import { fetchCourseConfig } from './thunks';
|
||||
|
||||
export function useTotalTopicThreadCount() {
|
||||
@@ -84,60 +93,68 @@ export function useRedirectToThread(courseId) {
|
||||
}
|
||||
|
||||
export function useIsOnDesktop() {
|
||||
const windowSize = useWindowSize();
|
||||
return windowSize.width >= breakpoints.large.minWidth;
|
||||
return window.outerWidth >= 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 isReportedByCurrentUser = getAuthenticatedUser().username === content?.abuseFlaggedBy;
|
||||
const canSeeReportedBanner = (userHasModerationPrivileges || userIsGroupTa || isReportedByCurrentUser);
|
||||
|
||||
return (
|
||||
(reasonCodesEnabled && canSeeLastEditOrClosedAlert && (content.lastEdit?.reason || content.closed))
|
||||
|| (content.abuseFlagged && canSeeReportedBanner)
|
||||
);
|
||||
};
|
||||
|
||||
export const useShowLearnersTab = () => {
|
||||
const learnersTabEnabled = useSelector(selectLearnersTabEnabled);
|
||||
const userRoles = useSelector(selectUserRoles);
|
||||
const isAdmin = useSelector(selectUserIsStaff);
|
||||
const IsGroupTA = useSelector(selectUserIsGroupTa);
|
||||
const privileged = useSelector(selectUserHasModerationPrivileges);
|
||||
const allowedUsers = isAdmin || IsGroupTA || privileged || (userRoles.includes('Student') && userRoles.length > 1);
|
||||
return learnersTabEnabled && allowedUsers;
|
||||
};
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import { useRef } from 'react';
|
||||
|
||||
import { render, waitFor } 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 { AppProvider } from '@edx/frontend-platform/react';
|
||||
|
||||
import { initializeStore } from '../../store';
|
||||
import { useContainerSizeForParent } from './hooks';
|
||||
|
||||
let store;
|
||||
initializeMockApp();
|
||||
describe('Hooks', () => {
|
||||
function ComponentWithHook() {
|
||||
const refContainer = useRef(null);
|
||||
useContainerSizeForParent(refContainer);
|
||||
return (
|
||||
<div>
|
||||
<div ref={refContainer} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderComponent() {
|
||||
return render(
|
||||
<IntlProvider locale="en">
|
||||
<ResponsiveContext.Provider value={{ width: 1280 }}>
|
||||
<AppProvider store={store}>
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<ComponentWithHook />
|
||||
</MemoryRouter>
|
||||
</AppProvider>
|
||||
</ResponsiveContext.Provider>
|
||||
</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());
|
||||
});
|
||||
});
|
||||
@@ -6,14 +6,22 @@ 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 selectModerationSettings = state => ({
|
||||
postCloseReasons: state.config.postCloseReasons,
|
||||
editReasons: state.config.editReasons,
|
||||
|
||||
@@ -11,7 +11,9 @@ const configSlice = createSlice({
|
||||
allowAnonymous: false,
|
||||
allowAnonymousToPeers: false,
|
||||
userRoles: [],
|
||||
userIsPrivileged: 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());
|
||||
|
||||
@@ -1,22 +1,46 @@
|
||||
import React, { useRef } from 'react';
|
||||
import React, { useContext } from 'react';
|
||||
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Route, Switch } from 'react-router';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
|
||||
import { Routes } from '../../data/constants';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Icon, IconButton } from '@edx/paragon';
|
||||
import { ArrowBack } from '@edx/paragon/icons';
|
||||
|
||||
import { PostsPages, Routes } from '../../data/constants';
|
||||
import { CommentsView } from '../comments';
|
||||
import { useContainerSizeForParent } from '../data/hooks';
|
||||
import { LearnersContentView } from '../learners';
|
||||
import { DiscussionContext } from '../common/context';
|
||||
import { useIsOnDesktop } from '../data/hooks';
|
||||
import messages from '../messages';
|
||||
import { PostEditor } from '../posts';
|
||||
import { discussionsPath } from '../utils';
|
||||
|
||||
export default function DiscussionContent() {
|
||||
const refContainer = useRef(null);
|
||||
function DiscussionContent({ intl }) {
|
||||
const location = useLocation();
|
||||
const history = useHistory();
|
||||
const postEditorVisible = useSelector((state) => state.threads.postEditorVisible);
|
||||
useContainerSizeForParent(refContainer);
|
||||
const isOnDesktop = useIsOnDesktop();
|
||||
const {
|
||||
courseId, learnerUsername, category, topicId, page,
|
||||
} = useContext(DiscussionContext);
|
||||
|
||||
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">
|
||||
<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">
|
||||
{!isOnDesktop && (
|
||||
<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)}
|
||||
/>
|
||||
)}
|
||||
{postEditorVisible ? (
|
||||
<Route path={Routes.POSTS.NEW_POST}>
|
||||
<PostEditor />
|
||||
@@ -29,12 +53,15 @@ export default function DiscussionContent() {
|
||||
<Route path={Routes.COMMENTS.PATH}>
|
||||
<CommentsView />
|
||||
</Route>
|
||||
<Route path={Routes.LEARNERS.LEARNER}>
|
||||
<LearnersContentView />
|
||||
</Route>
|
||||
</Switch>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
DiscussionContent.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(DiscussionContent);
|
||||
|
||||
@@ -1,38 +1,66 @@
|
||||
import React from 'react';
|
||||
import React, { 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 { RequestStatus, Routes } from '../../data/constants';
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
if (sidebarRef && postActionBarHeight) {
|
||||
if (isOnDesktop) {
|
||||
sidebarRef.current.style.maxHeight = `${document.body.offsetHeight - postActionBarHeight}px`;
|
||||
}
|
||||
sidebarRef.current.style.minHeight = `${document.body.offsetHeight - postActionBarHeight}px`;
|
||||
sidebarRef.current.style.top = `${postActionBarHeight}px`;
|
||||
}
|
||||
}, [sidebarRef, postActionBarHeight]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames('flex-column', {
|
||||
ref={sidebarRef}
|
||||
className={classNames('flex-column min-content-height 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,
|
||||
})}
|
||||
style={{ minWidth: '30rem' }}
|
||||
data-testid="sidebar"
|
||||
>
|
||||
<Switch>
|
||||
<Route path={Routes.POSTS.MY_POSTS}>
|
||||
<PostsView showOwnPosts />
|
||||
</Route>
|
||||
<Route
|
||||
path={[Routes.POSTS.PATH, Routes.POSTS.ALL_POSTS, Routes.TOPICS.CATEGORY]}
|
||||
path={[Routes.POSTS.PATH, Routes.POSTS.ALL_POSTS, Routes.TOPICS.CATEGORY, Routes.POSTS.MY_POSTS]}
|
||||
component={PostsView}
|
||||
/>
|
||||
<Route path={Routes.TOPICS.PATH} component={TopicsView} />
|
||||
{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={{
|
||||
@@ -40,6 +68,7 @@ export default function DiscussionSidebar({ displaySidebar }) {
|
||||
pathname: Routes.POSTS.ALL_POSTS,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Switch>
|
||||
</div>
|
||||
);
|
||||
@@ -47,8 +76,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,6 +11,8 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
|
||||
import { initializeStore } from '../../store';
|
||||
import { DiscussionContext } from '../common/context';
|
||||
import { fetchConfigSuccess } from '../data/slices';
|
||||
import { threadsApiUrl } from '../posts/data/api';
|
||||
import DiscussionSidebar from './DiscussionSidebar';
|
||||
|
||||
@@ -26,9 +28,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 +55,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 +78,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 +93,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,43 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
|
||||
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 { 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 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 {
|
||||
courseId,
|
||||
postId,
|
||||
@@ -37,14 +46,16 @@ export default function DiscussionsHome() {
|
||||
learnerUsername,
|
||||
} = params;
|
||||
const inContext = new URLSearchParams(location.search).get('inContext') !== null;
|
||||
|
||||
const inIframe = new URLSearchParams(location.search).get('inIframe')?.toLowerCase() === 'true';
|
||||
// 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.
|
||||
@@ -71,22 +82,27 @@ export default function DiscussionsHome() {
|
||||
learnerUsername,
|
||||
}}
|
||||
>
|
||||
<main className="container-fluid d-flex flex-column p-0 h-100 w-100 overflow-hidden">
|
||||
<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%)' }}
|
||||
>
|
||||
{!inContext && (
|
||||
<Route path={Routes.DISCUSSIONS.PATH} component={NavigationBar} />
|
||||
)}
|
||||
<PostActionsBar inContext={inContext} />
|
||||
{!inIframe && <Header courseOrg={org} courseNumber={courseNumber} courseTitle={courseTitle} />}
|
||||
<main className="container-fluid d-flex flex-column p-0 w-100" id="main" tabIndex="-1">
|
||||
{!inIframe
|
||||
&& <CourseTabsNavigation activeTab="discussion" courseId={courseId} />}
|
||||
<div className="header-action-bar" ref={postActionBarRef}>
|
||||
<div
|
||||
className="d-flex flex-row justify-content-between navbar fixed-top"
|
||||
>
|
||||
{!inContext && (
|
||||
<Route path={Routes.DISCUSSIONS.PATH} component={NavigationBar} />
|
||||
)}
|
||||
<PostActionsBar inContext={inContext} />
|
||||
</div>
|
||||
<InformationBanner />
|
||||
</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} />
|
||||
<div className="d-flex flex-row">
|
||||
<DiscussionSidebar displaySidebar={displaySidebar} postActionBarRef={postActionBarRef} />
|
||||
{displayContentArea && <DiscussionContent />}
|
||||
{!displayContentArea && (
|
||||
<Switch>
|
||||
@@ -96,13 +112,15 @@ export default function DiscussionsHome() {
|
||||
render={routeProps => <EmptyPosts {...routeProps} subTitleMessage={messages.emptyMyPosts} />}
|
||||
/>
|
||||
<Route
|
||||
path={[Routes.POSTS.PATH, Routes.POSTS.ALL_POSTS]}
|
||||
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>
|
||||
{!inIframe && <Footer />}
|
||||
</DiscussionContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -87,4 +87,36 @@ describe('DiscussionsHome', () => {
|
||||
await waitFor(() => expect(window.parent.postMessage).toHaveBeenCalled());
|
||||
window.parent = parent;
|
||||
});
|
||||
|
||||
describe.each([
|
||||
{
|
||||
queryParam: 'inIframe=True',
|
||||
iframeView: true,
|
||||
},
|
||||
{
|
||||
queryParam: 'inIframe=False',
|
||||
iframeView: false,
|
||||
},
|
||||
{
|
||||
queryParam: '',
|
||||
iframeView: false,
|
||||
},
|
||||
])(
|
||||
'Header/Footer visibility',
|
||||
({
|
||||
queryParam,
|
||||
iframeView,
|
||||
}) => {
|
||||
test(`inIframe query param ${queryParam}`, async () => {
|
||||
renderComponent(`/${courseId}/topics?${queryParam}`);
|
||||
if (iframeView) {
|
||||
expect(screen.queryByRole('banner')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('contentinfo')).not.toBeInTheDocument();
|
||||
} else {
|
||||
expect(screen.queryByRole('banner')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('contentinfo')).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
136
src/discussions/discussions-home/InformationBanner.test.jsx
Normal file
136
src/discussions/discussions-home/InformationBanner.test.jsx
Normal file
@@ -0,0 +1,136 @@
|
||||
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 InformationBanner from './InformationsBanner';
|
||||
|
||||
import '../posts/data/__factories__';
|
||||
|
||||
let store;
|
||||
let container;
|
||||
const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||
|
||||
const getConfigData = (isAdmin = true, roles = []) => ({
|
||||
id: 'course-v1:edX+DemoX+Demo_Course',
|
||||
userRoles: roles,
|
||||
hasModerationPrivileges: false,
|
||||
isGroupTa: false,
|
||||
isUserAdmin: isAdmin,
|
||||
});
|
||||
|
||||
function renderComponent() {
|
||||
const wrapper = render(
|
||||
<IntlProvider locale="en">
|
||||
<AppProvider store={store}>
|
||||
<DiscussionContext.Provider value={{ courseId }}>
|
||||
<InformationBanner />
|
||||
</DiscussionContext.Provider>
|
||||
</AppProvider>
|
||||
</IntlProvider>,
|
||||
);
|
||||
container = wrapper.container;
|
||||
return container;
|
||||
}
|
||||
|
||||
describe('Information Banner learner view', () => {
|
||||
let element;
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: false,
|
||||
roles: ['Student'],
|
||||
},
|
||||
});
|
||||
store = initializeStore();
|
||||
store.dispatch(fetchConfigSuccess(getConfigData(false, ['Student'])));
|
||||
renderComponent(true);
|
||||
element = await screen.findByRole('alert');
|
||||
});
|
||||
|
||||
test('Test Banner is visible on app load', async () => {
|
||||
expect(element).toHaveTextContent(messages.bannerMessage.defaultMessage);
|
||||
});
|
||||
|
||||
test('Test Banner do not have learn more button', async () => {
|
||||
expect(element).not.toHaveTextContent(messages.learnMoreBannerLink.defaultMessage);
|
||||
});
|
||||
test('Test Banner has share feedback button', async () => {
|
||||
expect(element).toHaveTextContent(messages.shareFeedback.defaultMessage);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Information Banner moderators/staff/admin view', () => {
|
||||
let element;
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
store = initializeStore();
|
||||
store.dispatch(fetchConfigSuccess(getConfigData(true, ['Student', 'Moderator'])));
|
||||
renderComponent(true);
|
||||
element = await screen.findByRole('alert');
|
||||
});
|
||||
|
||||
test('Test Banner is visible on app load', async () => {
|
||||
expect(element).toHaveTextContent(messages.bannerMessage.defaultMessage);
|
||||
});
|
||||
|
||||
test('Test Banner has learn more button', async () => {
|
||||
expect(element).toHaveTextContent(messages.learnMoreBannerLink.defaultMessage);
|
||||
});
|
||||
test('Test Banner has share feedback button', async () => {
|
||||
expect(element).toHaveTextContent(messages.shareFeedback.defaultMessage);
|
||||
});
|
||||
});
|
||||
|
||||
describe('User is redirected according to url according to role', () => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore();
|
||||
});
|
||||
|
||||
test('TAs are redirected to learners feedback form', async () => {
|
||||
store.dispatch(fetchConfigSuccess(getConfigData(false, ['Student', 'Community TA'])));
|
||||
renderComponent(true);
|
||||
expect(screen.getByText(messages.shareFeedback.defaultMessage)
|
||||
.closest('a'))
|
||||
.toHaveAttribute('href', process.env.TA_FEEDBACK_FORM);
|
||||
});
|
||||
|
||||
test('moderators/administrators are redirected to moderators feedback form', async () => {
|
||||
store.dispatch(fetchConfigSuccess(getConfigData(false, ['Student', 'Moderator', 'Administrator'])));
|
||||
renderComponent(true);
|
||||
expect(screen.getByText(messages.shareFeedback.defaultMessage)
|
||||
.closest('a'))
|
||||
.toHaveAttribute('href', process.env.STAFF_FEEDBACK_FORM);
|
||||
});
|
||||
|
||||
test('user with only isAdmin true are redirected to moderators feedback form', async () => {
|
||||
store.dispatch(fetchConfigSuccess(getConfigData(true, ['Student'])));
|
||||
renderComponent(true);
|
||||
expect(screen.getByText(messages.shareFeedback.defaultMessage)
|
||||
.closest('a'))
|
||||
.toHaveAttribute('href', process.env.STAFF_FEEDBACK_FORM);
|
||||
});
|
||||
});
|
||||
64
src/discussions/discussions-home/InformationsBanner.jsx
Normal file
64
src/discussions/discussions-home/InformationsBanner.jsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Hyperlink, PageBanner } from '@edx/paragon';
|
||||
|
||||
import { selectUserIsStaff, selectUserRoles } from '../data/selectors';
|
||||
import messages from '../messages';
|
||||
|
||||
function InformationBanner({
|
||||
intl,
|
||||
}) {
|
||||
const [showBanner, setShowBanner] = useState(true);
|
||||
const userRoles = useSelector(selectUserRoles);
|
||||
const isAdmin = useSelector(selectUserIsStaff);
|
||||
const learnMoreLink = 'https://openedx.atlassian.net/wiki/spaces/COMM/pages/3509551260/Overview+New+discussions+experience';
|
||||
const TAFeedbackLink = process.env.TA_FEEDBACK_FORM;
|
||||
const staffFeedbackLink = process.env.STAFF_FEEDBACK_FORM;
|
||||
const hideLearnMoreButton = ((userRoles.includes('Student') && userRoles.length === 1) || !userRoles.length) && !isAdmin;
|
||||
const showStaffLink = isAdmin || userRoles.includes('Moderator') || userRoles.includes('Administrator');
|
||||
|
||||
return (
|
||||
<PageBanner
|
||||
variant="light"
|
||||
show={showBanner}
|
||||
dismissible
|
||||
onDismiss={() => setShowBanner(false)}
|
||||
>
|
||||
<div style={{ fontWeight: '500' }}>
|
||||
{intl.formatMessage(messages.bannerMessage)}
|
||||
{!hideLearnMoreButton
|
||||
&& (
|
||||
<Hyperlink
|
||||
destination={learnMoreLink}
|
||||
target="_blank"
|
||||
showLaunchIcon={false}
|
||||
className="pl-2.5"
|
||||
variant="muted"
|
||||
isInline
|
||||
>
|
||||
{intl.formatMessage(messages.learnMoreBannerLink)}
|
||||
</Hyperlink>
|
||||
)}
|
||||
<Hyperlink
|
||||
destination={showStaffLink ? staffFeedbackLink : TAFeedbackLink}
|
||||
target="_blank"
|
||||
showLaunchIcon={false}
|
||||
variant="muted"
|
||||
className="pl-2.5"
|
||||
isInline
|
||||
>
|
||||
{intl.formatMessage(messages.shareFeedback)}
|
||||
</Hyperlink>
|
||||
</div>
|
||||
</PageBanner>
|
||||
);
|
||||
}
|
||||
|
||||
InformationBanner.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(InformationBanner);
|
||||
25
src/discussions/empty-posts/EmptyLearners.jsx
Normal file
25
src/discussions/empty-posts/EmptyLearners.jsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { useIsOnDesktop } from '../data/hooks';
|
||||
import messages from '../messages';
|
||||
import EmptyPage from './EmptyPage';
|
||||
|
||||
function EmptyLearners({ intl }) {
|
||||
const isOnDesktop = useIsOnDesktop();
|
||||
|
||||
if (!isOnDesktop) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<EmptyPage title={intl.formatMessage(messages.emptyTitle)} />
|
||||
);
|
||||
}
|
||||
|
||||
EmptyLearners.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(EmptyLearners);
|
||||
@@ -15,7 +15,7 @@ function EmptyPage({
|
||||
fullWidth = false,
|
||||
}) {
|
||||
const containerClasses = classNames(
|
||||
'justify-content-center align-items-center d-flex w-100 flex-column pt-5',
|
||||
'min-content-height justify-content-center align-items-center d-flex w-100 flex-column pt-5',
|
||||
{ 'bg-light-400': !fullWidth },
|
||||
);
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { default as EmptyLearners } from './EmptyLearners';
|
||||
export { default as EmptyPage } from './EmptyPage';
|
||||
export { default as EmptyPosts } from './EmptyPosts';
|
||||
export { default as EmptyTopics } from './EmptyTopics';
|
||||
|
||||
105
src/discussions/learners/LearnerPostsView.jsx
Normal file
105
src/discussions/learners/LearnerPostsView.jsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import React, {
|
||||
useCallback, useContext, useEffect, useMemo,
|
||||
} from 'react';
|
||||
|
||||
import capitalize from 'lodash/capitalize';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Button, Icon, IconButton, Spinner,
|
||||
} from '@edx/paragon';
|
||||
import { ArrowBack } from '@edx/paragon/icons';
|
||||
|
||||
import { RequestStatus, Routes } from '../../data/constants';
|
||||
import { DiscussionContext } from '../common/context';
|
||||
import {
|
||||
selectAllThreads,
|
||||
selectThreadNextPage,
|
||||
threadsLoadingStatus,
|
||||
} from '../posts/data/selectors';
|
||||
import NoResults from '../posts/NoResults';
|
||||
import { PostLink } from '../posts/post';
|
||||
import { discussionsPath, filterPosts } from '../utils';
|
||||
import { fetchUserPosts } from './data/thunks';
|
||||
import messages from './messages';
|
||||
|
||||
function LearnerPostsView({ intl }) {
|
||||
const location = useLocation();
|
||||
const history = useHistory();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const posts = useSelector(selectAllThreads);
|
||||
const loadingStatus = useSelector(threadsLoadingStatus());
|
||||
const { courseId, learnerUsername: username } = useContext(DiscussionContext);
|
||||
const nextPage = useSelector(selectThreadNextPage());
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchUserPosts(courseId, username));
|
||||
}, [courseId, username]);
|
||||
|
||||
const loadMorePosts = () => (
|
||||
dispatch(fetchUserPosts(courseId, username, {
|
||||
page: nextPage,
|
||||
}))
|
||||
);
|
||||
|
||||
const checkIsSelected = (id) => window.location.pathname.includes(id);
|
||||
const pinnedPosts = useMemo(() => filterPosts(posts, 'pinned'), [posts]);
|
||||
const unpinnedPosts = useMemo(() => filterPosts(posts, 'unpinned'), [posts]);
|
||||
|
||||
const postInstances = useCallback((sortedPosts) => (
|
||||
sortedPosts.map((post, idx) => (
|
||||
<PostLink
|
||||
post={post}
|
||||
key={post.id}
|
||||
isSelected={checkIsSelected}
|
||||
idx={idx}
|
||||
showDivider={(sortedPosts.length - 1) !== idx}
|
||||
/>
|
||||
))
|
||||
), []);
|
||||
|
||||
return (
|
||||
<div className="discussion-posts d-flex flex-column">
|
||||
<div className="d-flex align-items-center justify-content-between px-2.5">
|
||||
<IconButton
|
||||
src={ArrowBack}
|
||||
iconAs={Icon}
|
||||
style={{ padding: '18px' }}
|
||||
size="inline"
|
||||
onClick={() => history.push(discussionsPath(Routes.LEARNERS.PATH, { courseId })(location))}
|
||||
alt={intl.formatMessage(messages.back)}
|
||||
/>
|
||||
<div className="text-primary-500 font-style-normal font-family-inter font-weight-bold py-2.5">
|
||||
{intl.formatMessage(messages.activityForLearner, { username: capitalize(username) })}
|
||||
</div>
|
||||
<div style={{ padding: '18px' }} />
|
||||
</div>
|
||||
<div className="bg-light-400 border border-light-300" />
|
||||
<div className="list-group list-group-flush">
|
||||
{postInstances(pinnedPosts)}
|
||||
{postInstances(unpinnedPosts)}
|
||||
{loadingStatus !== RequestStatus.IN_PROGRESS && posts?.length === 0 && <NoResults />}
|
||||
{loadingStatus === RequestStatus.IN_PROGRESS ? (
|
||||
<div className="d-flex justify-content-center p-4">
|
||||
<Spinner animation="border" variant="primary" size="lg" />
|
||||
</div>
|
||||
) : (
|
||||
nextPage && loadingStatus === RequestStatus.SUCCESSFUL && (
|
||||
<Button onClick={() => loadMorePosts()} variant="primary" size="md">
|
||||
{intl.formatMessage(messages.loadMore)}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
LearnerPostsView.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(LearnerPostsView);
|
||||
@@ -1,110 +0,0 @@
|
||||
import React, { useContext } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { useSelector } from 'react-redux';
|
||||
import {
|
||||
generatePath, NavLink, Redirect, Route, Switch,
|
||||
} from 'react-router-dom';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Avatar, ButtonGroup, Card, Icon, IconButton, Spinner,
|
||||
} from '@edx/paragon';
|
||||
import { MoreHoriz, Report } from '@edx/paragon/icons';
|
||||
|
||||
import { LearnerTabs, RequestStatus, Routes } from '../../data/constants';
|
||||
import { DiscussionContext } from '../common/context';
|
||||
import {
|
||||
learnersLoadingStatus, selectLearner, selectLearnerAvatar, selectLearnerProfile,
|
||||
} from './data/selectors';
|
||||
import CommentsTabContent from './learner/CommentsTabContent';
|
||||
import PostsTabContent from './learner/PostsTabContent';
|
||||
import messages from './messages';
|
||||
|
||||
function LearnersContentView({ intl }) {
|
||||
const { courseId, learnerUsername } = useContext(DiscussionContext);
|
||||
const params = { courseId, learnerUsername };
|
||||
const apiStatus = useSelector(learnersLoadingStatus());
|
||||
const learner = useSelector(selectLearner(learnerUsername));
|
||||
const profile = useSelector(selectLearnerProfile(learnerUsername));
|
||||
const avatar = useSelector(selectLearnerAvatar(learnerUsername));
|
||||
|
||||
const activeTabClass = (active) => classNames('btn', { 'btn-primary': active, 'btn-outline-primary': !active });
|
||||
|
||||
return (
|
||||
<div className="learner-content d-flex flex-column">
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<div className="d-flex flex-row align-items-center m-3">
|
||||
<Avatar src={avatar} alt={learnerUsername} />
|
||||
<span className="font-weight-bold mx-3">
|
||||
{profile.username}
|
||||
</span>
|
||||
<div className="ml-auto">
|
||||
<IconButton iconAs={Icon} src={MoreHoriz} alt="Options" />
|
||||
</div>
|
||||
</div>
|
||||
</Card.Body>
|
||||
<Card.Footer className="pb-0 bg-light-200 justify-content-center">
|
||||
<ButtonGroup className="my-2">
|
||||
<NavLink
|
||||
className={activeTabClass}
|
||||
to={generatePath(Routes.LEARNERS.TABS.posts, params)}
|
||||
>
|
||||
{intl.formatMessage(messages.postsTab)} <span className="ml-3">{learner.threads}</span>
|
||||
{
|
||||
learner.activeFlags ? (
|
||||
<span className="ml-3">
|
||||
<Icon src={Report} />
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
</NavLink>
|
||||
<NavLink
|
||||
className={activeTabClass}
|
||||
to={generatePath(Routes.LEARNERS.TABS.responses, params)}
|
||||
>
|
||||
{intl.formatMessage(messages.responsesTab)} <span className="ml-3">{learner.responses}</span>
|
||||
</NavLink>
|
||||
<NavLink
|
||||
className={activeTabClass}
|
||||
to={generatePath(Routes.LEARNERS.TABS.comments, params)}
|
||||
>
|
||||
{intl.formatMessage(messages.commentsTab)} <span className="ml-3">{learner.replies}</span>
|
||||
</NavLink>
|
||||
</ButtonGroup>
|
||||
</Card.Footer>
|
||||
</Card>
|
||||
|
||||
<Switch>
|
||||
<Route path={Routes.LEARNERS.LEARNER} exact>
|
||||
<Redirect to={generatePath(Routes.LEARNERS.TABS.posts, params)} />
|
||||
</Route>
|
||||
<Route
|
||||
path={Routes.LEARNERS.TABS.posts}
|
||||
component={PostsTabContent}
|
||||
/>
|
||||
<Route path={Routes.LEARNERS.TABS.responses}>
|
||||
<CommentsTabContent tab={LearnerTabs.RESPONSES} />
|
||||
</Route>
|
||||
<Route path={Routes.LEARNERS.TABS.comments}>
|
||||
<CommentsTabContent tab={LearnerTabs.COMMENTS} />
|
||||
</Route>
|
||||
</Switch>
|
||||
|
||||
{
|
||||
apiStatus === RequestStatus.IN_PROGRESS && (
|
||||
<div className="my-3 text-center">
|
||||
<Spinner animation="border" className="mie-3" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
LearnersContentView.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(LearnersContentView);
|
||||
@@ -1,172 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { IntlProvider } from 'react-intl';
|
||||
import { MemoryRouter, Route } from 'react-router';
|
||||
import { Factory } from 'rosie';
|
||||
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
|
||||
import { LearnerTabs } from '../../data/constants';
|
||||
import { initializeStore } from '../../store';
|
||||
import { executeThunk } from '../../test-utils';
|
||||
import { commentsApiUrl } from '../comments/data/api';
|
||||
import { DiscussionContext } from '../common/context';
|
||||
import { threadsApiUrl } from '../posts/data/api';
|
||||
import { coursesApiUrl, userProfileApiUrl } from './data/api';
|
||||
import { fetchLearners, fetchUserComments } from './data/thunks';
|
||||
import LearnersContentView from './LearnersContentView';
|
||||
|
||||
import './data/__factories__';
|
||||
import '../comments/data/__factories__';
|
||||
import '../posts/data/__factories__';
|
||||
|
||||
let store;
|
||||
let axiosMock;
|
||||
const courseId = 'course-v1:edX+TestX+Test_Course';
|
||||
const testUsername = 'leaner-1';
|
||||
|
||||
function renderComponent(username = testUsername) {
|
||||
return render(
|
||||
<IntlProvider locale="en">
|
||||
<AppProvider store={store}>
|
||||
<DiscussionContext.Provider value={{ learnerUsername: username, courseId }}>
|
||||
<MemoryRouter initialEntries={[`/${courseId}/learners/${username}/${LearnerTabs.POSTS}`]}>
|
||||
<Route path="/:courseId/learners/:learnerUsername">
|
||||
<LearnersContentView />
|
||||
</Route>
|
||||
</MemoryRouter>
|
||||
</DiscussionContext.Provider>
|
||||
</AppProvider>
|
||||
</IntlProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('LearnersContentView', () => {
|
||||
const learnerCount = 1;
|
||||
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
store = initializeStore({});
|
||||
Factory.resetAll();
|
||||
|
||||
axiosMock.onGet(`${coursesApiUrl}${courseId}/activity_stats/`)
|
||||
.reply(() => [200, Factory.build('learnersResult', {}, {
|
||||
count: learnerCount,
|
||||
pageSize: 5,
|
||||
})]);
|
||||
|
||||
axiosMock.onGet(`${userProfileApiUrl}?username=${testUsername}`)
|
||||
.reply(() => [200, Factory.build('learnersProfile', {}, {
|
||||
username: [testUsername],
|
||||
}).profiles]);
|
||||
await executeThunk(fetchLearners(courseId), store.dispatch, store.getState);
|
||||
|
||||
axiosMock.onGet(threadsApiUrl, { params: { course_id: courseId, author: testUsername } })
|
||||
.reply(200, Factory.build('threadsResult', {}, {
|
||||
topicId: undefined,
|
||||
count: 5,
|
||||
pageSize: 6,
|
||||
}));
|
||||
|
||||
axiosMock.onGet(commentsApiUrl, { params: { course_id: courseId, username: testUsername } })
|
||||
.reply(200, Factory.build('commentsResult', {}, {
|
||||
count: 8,
|
||||
pageSize: 10,
|
||||
}));
|
||||
});
|
||||
|
||||
test('it loads the posts view by default', async () => {
|
||||
await act(async () => {
|
||||
await renderComponent();
|
||||
});
|
||||
expect(screen.queryAllByTestId('post')).toHaveLength(5);
|
||||
expect(screen.queryAllByText('This is Thread', { exact: false })).toHaveLength(5);
|
||||
});
|
||||
|
||||
test('it renders all the comments WITHOUT parent id in responses tab', async () => {
|
||||
await act(async () => {
|
||||
await renderComponent();
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText('Responses', { exact: false }));
|
||||
});
|
||||
|
||||
expect(screen.queryAllByText('comment number', { exact: false })).toHaveLength(8);
|
||||
});
|
||||
|
||||
test('it renders all the comments with parent id in comments tab', async () => {
|
||||
axiosMock.onGet(commentsApiUrl, { params: { course_id: courseId, username: testUsername } })
|
||||
.reply(200, Factory.build('commentsResult', {}, {
|
||||
count: 4,
|
||||
parentId: 'test_parent_id',
|
||||
}));
|
||||
executeThunk(fetchUserComments(courseId, testUsername), store.dispatch, store.state);
|
||||
await act(async () => {
|
||||
await renderComponent();
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText('Comments', { exact: false }));
|
||||
});
|
||||
|
||||
expect(screen.queryAllByText('comment number', { exact: false })).toHaveLength(4);
|
||||
});
|
||||
|
||||
test('it can switch back to the posts tab', async () => {
|
||||
await act(async () => {
|
||||
await renderComponent();
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText('Responses', { exact: false }));
|
||||
});
|
||||
expect(screen.queryAllByText('comment number', { exact: false })).toHaveLength(8);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText('Posts', { exact: false }));
|
||||
});
|
||||
expect(screen.queryAllByTestId('post')).toHaveLength(5);
|
||||
});
|
||||
|
||||
describe('Posts Tab Button', () => {
|
||||
it('does not show Report Icon when the learner has NO active flags', async () => {
|
||||
await act(async () => {
|
||||
await renderComponent('leaner-2');
|
||||
});
|
||||
const button = screen.getByText('Posts', { exact: false });
|
||||
expect(button.innerHTML).not.toContain('svg');
|
||||
});
|
||||
|
||||
it('shows the Report Icon when the learner has active Flags', async () => {
|
||||
axiosMock.onGet(`${coursesApiUrl}${courseId}/activity_stats/`)
|
||||
.reply(() => [200, Factory.build('learnersResult', {}, {
|
||||
count: 1,
|
||||
pageSize: 5,
|
||||
activeFlags: 1,
|
||||
})]);
|
||||
axiosMock.onGet(`${userProfileApiUrl}?username=leaner-2`)
|
||||
.reply(() => [200, Factory.build('learnersProfile', {}, {
|
||||
username: ['leaner-2'],
|
||||
}).profiles]);
|
||||
await executeThunk(fetchLearners(courseId), store.dispatch, store.getState);
|
||||
|
||||
await act(async () => {
|
||||
await renderComponent('leaner-2');
|
||||
});
|
||||
const button = screen.getByText('Posts', { exact: false });
|
||||
expect(button.innerHTML).toContain('svg');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,49 +5,70 @@ import {
|
||||
Redirect, useLocation, useParams,
|
||||
} from 'react-router';
|
||||
|
||||
import { Spinner } from '@edx/paragon';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Button, Spinner } from '@edx/paragon';
|
||||
|
||||
import ScrollThreshold from '../../components/ScrollThreshold';
|
||||
import SearchInfo from '../../components/SearchInfo';
|
||||
import { RequestStatus, Routes } from '../../data/constants';
|
||||
import { selectconfigLoadingStatus, selectLearnersTabEnabled } from '../data/selectors';
|
||||
import NoResults from '../posts/NoResults';
|
||||
import {
|
||||
learnersLoadingStatus,
|
||||
selectAllLearners,
|
||||
selectLearnerNextPage,
|
||||
selectLearnerSorting,
|
||||
selectUsernameSearch,
|
||||
} from './data/selectors';
|
||||
import { setUsernameSearch } from './data/slices';
|
||||
import { fetchLearners } from './data/thunks';
|
||||
import { LearnerCard } from './learner';
|
||||
import { LearnerCard, LearnerFilterBar } from './learner';
|
||||
import messages from './messages';
|
||||
|
||||
function LearnersView() {
|
||||
const {
|
||||
courseId,
|
||||
} = useParams();
|
||||
function LearnersView({ intl }) {
|
||||
const { courseId } = useParams();
|
||||
const location = useLocation();
|
||||
const dispatch = useDispatch();
|
||||
const orderBy = useSelector(selectLearnerSorting());
|
||||
const nextPage = useSelector(selectLearnerNextPage());
|
||||
const loadingStatus = useSelector(learnersLoadingStatus());
|
||||
const usernameSearch = useSelector(selectUsernameSearch());
|
||||
const courseConfigLoadingStatus = useSelector(selectconfigLoadingStatus);
|
||||
const learnersTabEnabled = useSelector(selectLearnersTabEnabled);
|
||||
const learners = useSelector(selectAllLearners);
|
||||
|
||||
useEffect(() => {
|
||||
if (learnersTabEnabled) {
|
||||
dispatch(fetchLearners(courseId, { orderBy }));
|
||||
if (usernameSearch) {
|
||||
dispatch(fetchLearners(courseId, { orderBy, usernameSearch }));
|
||||
} else {
|
||||
dispatch(fetchLearners(courseId, { orderBy }));
|
||||
}
|
||||
}
|
||||
}, [courseId, orderBy, learnersTabEnabled]);
|
||||
}, [courseId, orderBy, learnersTabEnabled, usernameSearch]);
|
||||
|
||||
const loadPage = async () => {
|
||||
if (nextPage) {
|
||||
dispatch(fetchLearners(courseId, {
|
||||
orderBy,
|
||||
page: nextPage,
|
||||
usernameSearch,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="d-flex flex-column">
|
||||
<div className="list-group list-group-flush">
|
||||
<div className="d-flex flex-column border-right border-light-400">
|
||||
{!usernameSearch && <LearnerFilterBar /> }
|
||||
<div className="border-bottom border-light-400" />
|
||||
{usernameSearch && (
|
||||
<SearchInfo
|
||||
text={usernameSearch}
|
||||
count={learners.length}
|
||||
loadingStatus={loadingStatus}
|
||||
onClear={() => dispatch(setUsernameSearch(''))}
|
||||
/>
|
||||
)}
|
||||
<div className="list-group list-group-flush learner" role="list">
|
||||
{courseConfigLoadingStatus === RequestStatus.SUCCESSFUL && !learnersTabEnabled && (
|
||||
<Redirect
|
||||
to={{
|
||||
@@ -56,21 +77,31 @@ function LearnersView() {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{courseConfigLoadingStatus === RequestStatus.SUCCESSFUL && learnersTabEnabled && learners.map((learner) => (
|
||||
<LearnerCard learner={learner} key={learner.username} courseId={courseId} />
|
||||
))}
|
||||
{courseConfigLoadingStatus === RequestStatus.SUCCESSFUL
|
||||
&& learnersTabEnabled
|
||||
&& learners.map((learner, index) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<LearnerCard learner={learner} key={index} courseId={courseId} />
|
||||
))}
|
||||
{loadingStatus === RequestStatus.IN_PROGRESS ? (
|
||||
<div className="d-flex justify-content-center p-4">
|
||||
<Spinner animation="border" variant="primary" size="lg" />
|
||||
</div>
|
||||
) : (
|
||||
nextPage && (
|
||||
<ScrollThreshold onScroll={loadPage} />
|
||||
nextPage && loadingStatus === RequestStatus.SUCCESSFUL && (
|
||||
<Button onClick={() => loadPage()} variant="primary" size="md">
|
||||
{intl.formatMessage(messages.loadMore)}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
{ usernameSearch !== '' && learners.length === 0 && loadingStatus === RequestStatus.SUCCESSFUL && <NoResults />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LearnersView;
|
||||
LearnersView.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(LearnersView);
|
||||
|
||||
@@ -85,7 +85,6 @@ describe('LearnersView', () => {
|
||||
await act(async () => {
|
||||
await renderComponent();
|
||||
});
|
||||
expect(screen.queryAllByText(/Last active/i, { exact: false }).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,19 +10,16 @@ const apiBaseUrl = getConfig().LMS_BASE_URL;
|
||||
|
||||
export const coursesApiUrl = `${apiBaseUrl}/api/discussion/v1/courses/`;
|
||||
export const userProfileApiUrl = `${apiBaseUrl}/api/user/v1/accounts`;
|
||||
export const postsApiUrl = `${apiBaseUrl}/api/discussion/v1/threads/`;
|
||||
export const commentsApiUrl = `${apiBaseUrl}/api/discussion/v1/comments/`;
|
||||
|
||||
/**
|
||||
* Fetches all the learners in the given course.
|
||||
* @param {string} courseId
|
||||
* @param {object} params {page, order_by}
|
||||
* @returns {Promise<{}>}
|
||||
*/
|
||||
export async function getLearners(
|
||||
courseId,
|
||||
) {
|
||||
export async function getLearners(courseId, params) {
|
||||
const url = `${coursesApiUrl}${courseId}/activity_stats/`;
|
||||
const { data } = await getAuthenticatedHttpClient().get(url);
|
||||
const { data } = await getAuthenticatedHttpClient().get(url, { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -35,3 +32,23 @@ export async function getUserProfiles(usernames) {
|
||||
const { data } = await getAuthenticatedHttpClient().get(url);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the posts by a specific user in a course's discussions
|
||||
*
|
||||
* @param {string} courseId Course ID of the course
|
||||
* @param {string} username Username of the user
|
||||
* @param {number} page
|
||||
* @returns API Response object in the format
|
||||
* {
|
||||
* results: [array of posts],
|
||||
* pagination: {count, num_pages, next, previous}
|
||||
* }
|
||||
*/
|
||||
export async function getUserPosts(courseId, username, { page }) {
|
||||
const learnerPostsApiUrl = `${coursesApiUrl}${courseId}/learner/`;
|
||||
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(learnerPostsApiUrl, { params: { username, page } });
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -2,23 +2,21 @@
|
||||
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
|
||||
import { LearnerTabs } from '../../../data/constants';
|
||||
|
||||
export const selectAllLearners = createSelector(
|
||||
state => state.learners,
|
||||
learners => learners.learners,
|
||||
state => state.learners.pages,
|
||||
pages => pages.flat(),
|
||||
);
|
||||
|
||||
export const learnersLoadingStatus = () => state => state.learners.status;
|
||||
|
||||
export const selectLearnerSorting = () => state => state.learners.sortedBy;
|
||||
export const selectUsernameSearch = () => state => state.learners.usernameSearch;
|
||||
|
||||
export const selectLearnerFilters = () => state => state.learners.filters;
|
||||
export const selectLearnerSorting = () => state => state.learners.sortedBy;
|
||||
|
||||
export const selectLearnerNextPage = () => state => state.learners.nextPage;
|
||||
|
||||
export const selectLearnerAvatar = author => state => (
|
||||
state.learners.learnerProfiles[author]?.profileImage?.imageUrlSmall
|
||||
state.learners.learnerProfiles[author]?.profileImage?.imageUrlLarge
|
||||
);
|
||||
|
||||
export const selectLearnerLastLogin = author => state => (
|
||||
@@ -29,21 +27,3 @@ export const selectLearner = (username) => createSelector(
|
||||
[selectAllLearners],
|
||||
learners => learners.find(l => l.username === username) || {},
|
||||
);
|
||||
|
||||
export const selectLearnerProfile = (username) => state => state.learners.learnerProfiles[username] || {};
|
||||
|
||||
export const selectUserPosts = username => state => state.learners.postsByUser[username] || [];
|
||||
|
||||
/**
|
||||
* Get the comments of a post.
|
||||
* @param {string} username Username of the learner to get the comments of
|
||||
* @param {LearnerTabs} commentType Type of comments to get
|
||||
* @returns {Array} Array of comments
|
||||
*/
|
||||
export const selectUserComments = (username, commentType) => state => (
|
||||
commentType === LearnerTabs.COMMENTS
|
||||
? (state.learners.commentsByUser[username] || []).filter(c => c.parentId)
|
||||
: (state.learners.commentsByUser[username] || []).filter(c => !c.parentId)
|
||||
);
|
||||
|
||||
export const flaggedCommentCount = (username) => state => state.learners.flaggedCommentsByUser[username] || 0;
|
||||
|
||||
@@ -10,36 +10,23 @@ const learnersSlice = createSlice({
|
||||
name: 'learner',
|
||||
initialState: {
|
||||
status: RequestStatus.IN_PROGRESS,
|
||||
avatars: {},
|
||||
learners: [],
|
||||
learnerProfiles: {},
|
||||
pages: [],
|
||||
nextPage: null,
|
||||
totalPages: null,
|
||||
totalLearners: null,
|
||||
sortedBy: LearnersOrdering.BY_LAST_ACTIVITY,
|
||||
commentsByUser: {
|
||||
// Map username to comments
|
||||
},
|
||||
postsByUser: {
|
||||
// Map username to posts
|
||||
},
|
||||
commentCountByUser: {
|
||||
// Map of username and comment count
|
||||
},
|
||||
postCountByUser: {
|
||||
// Map of username and post count
|
||||
},
|
||||
usernameSearch: null,
|
||||
},
|
||||
reducers: {
|
||||
fetchLearnersSuccess: (state, { payload }) => {
|
||||
state.status = RequestStatus.SUCCESSFUL;
|
||||
state.learners = payload.results;
|
||||
state.pages[payload.page - 1] = payload.results;
|
||||
state.learnerProfiles = {
|
||||
...state.learnerProfiles,
|
||||
...(payload.learnerProfiles || {}),
|
||||
};
|
||||
state.nextPage = payload.pagination.next;
|
||||
state.nextPage = (payload.page < payload.pagination.numPages) ? payload.page + 1 : null;
|
||||
state.totalPages = payload.pagination.numPages;
|
||||
state.totalLearners = payload.pagination.count;
|
||||
},
|
||||
@@ -53,32 +40,13 @@ const learnersSlice = createSlice({
|
||||
state.status = RequestStatus.IN_PROGRESS;
|
||||
},
|
||||
setSortedBy: (state, { payload }) => {
|
||||
state.pages = [];
|
||||
state.sortedBy = payload;
|
||||
},
|
||||
setUsernameSearch: (state, { payload }) => {
|
||||
state.usernameSearch = payload;
|
||||
state.pages = [];
|
||||
},
|
||||
fetchUserCommentsRequest: (state) => {
|
||||
state.status = RequestStatus.IN_PROGRESS;
|
||||
},
|
||||
fetchUserCommentsSuccess: (state, { payload }) => {
|
||||
state.commentsByUser[payload.username] = payload.comments;
|
||||
state.commentCountByUser[payload.username] = payload.pagination.count;
|
||||
state.status = RequestStatus.SUCCESS;
|
||||
},
|
||||
fetchUserCommentsDenied: (state) => {
|
||||
state.status = RequestStatus.DENIED;
|
||||
},
|
||||
fetchUserPostsRequest: (state) => {
|
||||
state.status = RequestStatus.IN_PROGRESS;
|
||||
},
|
||||
fetchUserPostsSuccess: (state, { payload }) => {
|
||||
state.postsByUser[payload.username] = payload.posts;
|
||||
state.postCountByUser[payload.username] = payload.pagination.count;
|
||||
state.status = RequestStatus.SUCCESS;
|
||||
},
|
||||
fetchUserPostsDenied: (state) => {
|
||||
state.status = RequestStatus.DENIED;
|
||||
},
|
||||
|
||||
},
|
||||
});
|
||||
|
||||
@@ -88,13 +56,7 @@ export const {
|
||||
fetchLearnersSuccess,
|
||||
fetchLearnersDenied,
|
||||
setSortedBy,
|
||||
fetchUserCommentsRequest,
|
||||
fetchUserCommentsDenied,
|
||||
fetchUserCommentsSuccess,
|
||||
fetchUserPostsRequest,
|
||||
fetchUserPostsDenied,
|
||||
fetchUserPostsSuccess,
|
||||
|
||||
setUsernameSearch,
|
||||
} = learnersSlice.actions;
|
||||
|
||||
export const learnersReducer = learnersSlice.reducer;
|
||||
|
||||
@@ -1,43 +1,44 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import { camelCaseObject } from '@edx/frontend-platform';
|
||||
import { camelCaseObject, snakeCaseObject } from '@edx/frontend-platform';
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
|
||||
import { getUserComments } from '../../comments/data/api';
|
||||
import { getUserPosts } from '../../posts/data/api';
|
||||
import { getHttpErrorStatus } from '../../utils';
|
||||
import {
|
||||
getLearners, getUserProfiles,
|
||||
} from './api';
|
||||
fetchLearnerThreadsRequest,
|
||||
fetchThreadsDenied,
|
||||
fetchThreadsFailed,
|
||||
fetchThreadsSuccess,
|
||||
} from '../../posts/data/slices';
|
||||
import { normaliseThreads } from '../../posts/data/thunks';
|
||||
import { getHttpErrorStatus } from '../../utils';
|
||||
import { getLearners, getUserPosts, getUserProfiles } from './api';
|
||||
import {
|
||||
fetchLearnersDenied,
|
||||
fetchLearnersFailed,
|
||||
fetchLearnersRequest,
|
||||
fetchLearnersSuccess,
|
||||
fetchUserCommentsDenied,
|
||||
fetchUserCommentsRequest,
|
||||
fetchUserCommentsSuccess,
|
||||
fetchUserPostsDenied,
|
||||
fetchUserPostsRequest,
|
||||
fetchUserPostsSuccess,
|
||||
} from './slices';
|
||||
|
||||
/**
|
||||
* Fetches the learners for the course courseId.
|
||||
* @param {string} courseId The course ID for the course to fetch data for.
|
||||
* @param {string} orderBy
|
||||
* @param {number} page
|
||||
* @param {usernameSearch} username
|
||||
* @returns {(function(*): Promise<void>)|*}
|
||||
*/
|
||||
export function fetchLearners(courseId, {
|
||||
orderBy,
|
||||
page = 1,
|
||||
usernameSearch = null,
|
||||
} = {}) {
|
||||
const options = {
|
||||
orderBy,
|
||||
page,
|
||||
};
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
const params = snakeCaseObject({ orderBy, page });
|
||||
if (usernameSearch) {
|
||||
params.username = usernameSearch;
|
||||
}
|
||||
dispatch(fetchLearnersRequest({ courseId }));
|
||||
const learnerStats = await getLearners(courseId, options);
|
||||
const learnerStats = await getLearners(courseId, params);
|
||||
const learnerProfilesData = await getUserProfiles(learnerStats.results.map((l) => l.username));
|
||||
const learnerProfiles = {};
|
||||
learnerProfilesData.forEach(
|
||||
@@ -45,7 +46,7 @@ export function fetchLearners(courseId, {
|
||||
learnerProfiles[learnerProfile.username] = camelCaseObject(learnerProfile);
|
||||
},
|
||||
);
|
||||
dispatch(fetchLearnersSuccess({ ...camelCaseObject(learnerStats), learnerProfiles }));
|
||||
dispatch(fetchLearnersSuccess({ ...camelCaseObject(learnerStats), learnerProfiles, page }));
|
||||
} catch (error) {
|
||||
if (getHttpErrorStatus(error) === 403) {
|
||||
dispatch(fetchLearnersDenied());
|
||||
@@ -57,52 +58,31 @@ export function fetchLearners(courseId, {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the comments of a user for the specified course and update the
|
||||
* redux state
|
||||
*
|
||||
* @param {string} courseId Course ID of the course eg., course-v1:X+Y+Z
|
||||
* @param {string} username Username of the learner
|
||||
* @returns a promise that will update the state with the learner's comments
|
||||
*/
|
||||
export function fetchUserComments(courseId, username) {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
dispatch(fetchUserCommentsRequest());
|
||||
const data = await getUserComments(courseId, username);
|
||||
dispatch(fetchUserCommentsSuccess(camelCaseObject({
|
||||
username,
|
||||
comments: data.results,
|
||||
pagination: data.pagination,
|
||||
})));
|
||||
} catch (error) {
|
||||
if (getHttpErrorStatus(error) === 403) {
|
||||
dispatch(fetchUserCommentsDenied());
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the posts of a user for the specified course and update the
|
||||
* redux state
|
||||
*
|
||||
* @param {sting} courseId Course ID of the course eg., course-v1:X+Y+Z
|
||||
* @param {string} username Username of the learner
|
||||
* @param {string} courseId Course ID of the course eg., course-v1:X+Y+Z
|
||||
* @param {string} username name of the learner
|
||||
* @param page
|
||||
* @returns a promise that will update the state with the learner's posts
|
||||
*/
|
||||
export function fetchUserPosts(courseId, username) {
|
||||
export function fetchUserPosts(courseId, username, { page = 1 } = {}) {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
dispatch(fetchUserPostsRequest());
|
||||
const data = await getUserPosts(courseId, username, true);
|
||||
dispatch(fetchUserPostsSuccess(camelCaseObject({
|
||||
username, posts: data.results, pagination: data.pagination,
|
||||
})));
|
||||
dispatch(fetchLearnerThreadsRequest({ courseId, author: username }));
|
||||
|
||||
const data = await getUserPosts(courseId, username, { page });
|
||||
const normalisedData = normaliseThreads(camelCaseObject(data));
|
||||
|
||||
dispatch(fetchThreadsSuccess({ ...normalisedData, page, author: username }));
|
||||
} catch (error) {
|
||||
if (getHttpErrorStatus(error) === 403) {
|
||||
dispatch(fetchUserPostsDenied());
|
||||
dispatch(fetchThreadsDenied());
|
||||
} else {
|
||||
dispatch(fetchThreadsFailed());
|
||||
}
|
||||
logError(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user