Compare commits
89 Commits
aansari/si
...
frontend-b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac471e2dd7 | ||
|
|
f04429f6f7 | ||
|
|
bad12462f5 | ||
|
|
ec915f622b | ||
|
|
60da5eafc4 | ||
|
|
05cf174335 | ||
|
|
ff72dab001 | ||
|
|
c38887ec2b | ||
|
|
58aa512f47 | ||
|
|
62a5c11f52 | ||
|
|
3ef8515891 | ||
|
|
3cc39d83c4 | ||
|
|
af6cd1853c | ||
|
|
79a2fa404b | ||
|
|
472bbe2d96 | ||
|
|
dc5f097736 | ||
|
|
5e8c8254b4 | ||
|
|
0d6692cf8c | ||
|
|
3391e966f3 | ||
|
|
4297a96102 | ||
|
|
db883ca7cd | ||
|
|
422fbf6173 | ||
|
|
e862ee6fb1 | ||
|
|
eeae6d45ce | ||
|
|
71b88bcea3 | ||
|
|
c808069fe1 | ||
|
|
b9543c6d9c | ||
|
|
a545d0b9f6 | ||
|
|
8d86e6dcc0 | ||
|
|
37781566f5 | ||
|
|
50948acfeb | ||
|
|
4de1011780 | ||
|
|
d7474782b4 | ||
|
|
e1c78dda6e | ||
|
|
f282da52c1 | ||
|
|
d7fcc86847 | ||
|
|
c0873df575 | ||
|
|
12fbe7eebd | ||
|
|
7db4fde252 | ||
|
|
4914f51b6e | ||
|
|
80073e3f83 | ||
|
|
3aacdda7a1 | ||
|
|
1a2068d52f | ||
|
|
3a7b7054e7 | ||
|
|
6875165eb3 | ||
|
|
8fc666500a | ||
|
|
ddb6c96f1d | ||
|
|
ac17fd7294 | ||
|
|
f0a4586eed | ||
|
|
9eaed2b873 | ||
|
|
71a18c532e | ||
|
|
e845804cce | ||
|
|
f69b2c118f | ||
|
|
88a985da35 | ||
|
|
3aef3b0c7e | ||
|
|
095d4296e3 | ||
|
|
fa8357035b | ||
|
|
bb341df70e | ||
|
|
ffb386472d | ||
|
|
5df51f2389 | ||
|
|
92adec3a2a | ||
|
|
ce786af2dc | ||
|
|
4dbb4f5fea | ||
|
|
733046f852 | ||
|
|
6cacde4367 | ||
|
|
8015f6c1c0 | ||
|
|
035d766886 | ||
|
|
ee61d1c95d | ||
|
|
9e95458168 | ||
|
|
b467298d9a | ||
|
|
b5d036a54d | ||
|
|
bc997108ef | ||
|
|
6ae5130c14 | ||
|
|
67d79cb3aa | ||
|
|
f31a0e71f3 | ||
|
|
9761787c89 | ||
|
|
e5a21f4a75 | ||
|
|
1d89e9556a | ||
|
|
b35632df64 | ||
|
|
b36c0266fd | ||
|
|
0d5df18ab2 | ||
|
|
c61435546d | ||
|
|
df4a3c2a73 | ||
|
|
ac635edcb8 | ||
|
|
c4f7115732 | ||
|
|
5cc8ba43fe | ||
|
|
68505821bb | ||
|
|
c6d953fe7b | ||
|
|
a479f5ae5b |
@@ -2,3 +2,4 @@ coverage/*
|
||||
dist/
|
||||
node_modules/
|
||||
jest.config.js
|
||||
src/i18n/messages/
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const { createConfig } = require('@edx/frontend-build');
|
||||
const { createConfig } = require('@openedx/frontend-build');
|
||||
|
||||
module.exports = createConfig(
|
||||
'eslint',
|
||||
|
||||
14
.github/workflows/ci.yml
vendored
14
.github/workflows/ci.yml
vendored
@@ -9,17 +9,16 @@ on:
|
||||
jobs:
|
||||
tests:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Setup Nodejs Env
|
||||
run: echo "NODE_VER=`cat .nvmrc`" >> $GITHUB_ENV
|
||||
- name: Setup Nodejs
|
||||
uses: actions/setup-node@v2
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VER }}
|
||||
node-version-file: '.nvmrc'
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Validate package-lock.json changes
|
||||
@@ -33,4 +32,7 @@ jobs:
|
||||
- name: i18n_extract
|
||||
run: npm run i18n_extract
|
||||
- name: Coverage
|
||||
uses: codecov/codecov-action@v3
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
fail_ci_if_error: true
|
||||
|
||||
2
.github/workflows/lockfileversion-check.yml
vendored
2
.github/workflows/lockfileversion-check.yml
vendored
@@ -10,4 +10,4 @@ on:
|
||||
|
||||
jobs:
|
||||
version-check:
|
||||
uses: openedx/.github/.github/workflows/lockfile-check.yml@master
|
||||
uses: openedx/.github/.github/workflows/lockfileversion-check-v3.yml@master
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -6,6 +6,7 @@ node_modules
|
||||
npm-debug.log
|
||||
coverage
|
||||
module.config.js
|
||||
env.config.*
|
||||
|
||||
dist/
|
||||
src/i18n/transifex_input.json
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
[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
|
||||
19
Makefile
19
Makefile
@@ -1,7 +1,3 @@
|
||||
export TRANSIFEX_RESOURCE = frontend-app-discussions
|
||||
transifex_resource = frontend-app-discussions
|
||||
transifex_langs = "ar,cs,de_DE,es_419,es_AR,es_ES,fa_IR,fr,fr_CA,fr_FR,hi,it_IT,pl,pt_PT,tr_TR,uk,ru,zh_CN"
|
||||
|
||||
intl_imports = ./node_modules/.bin/intl-imports.js
|
||||
transifex_utils = ./node_modules/.bin/transifex-utils.js
|
||||
i18n = ./src/i18n
|
||||
@@ -56,26 +52,21 @@ push_translations:
|
||||
# Pushing comments to Transifex...
|
||||
./node_modules/@edx/reactifex/bash_scripts/put_comments_v3.sh
|
||||
|
||||
ifeq ($(OPENEDX_ATLAS_PULL),)
|
||||
# Pulls translations from Transifex.
|
||||
pull_translations:
|
||||
tx pull -t -f --mode reviewed --languages=$(transifex_langs)
|
||||
else
|
||||
# Experimental: OEP-58 Pulls translations using atlas
|
||||
pull_translations:
|
||||
rm -rf src/i18n/messages
|
||||
mkdir src/i18n/messages
|
||||
cd src/i18n/messages \
|
||||
&& atlas pull --filter=$(transifex_langs) \
|
||||
&& atlas pull $(ATLAS_OPTIONS) \
|
||||
translations/frontend-component-header/src/i18n/messages:frontend-component-header \
|
||||
translations/frontend-component-footer/src/i18n/messages:frontend-component-footer \
|
||||
translations/frontend-platform/src/i18n/messages:frontend-platform \
|
||||
translations/paragon/src/i18n/messages:paragon \
|
||||
translations/frontend-app-discussions/src/i18n/messages:frontend-app-discussions
|
||||
|
||||
$(intl_imports) frontend-component-header frontend-component-footer paragon frontend-app-discussions
|
||||
endif
|
||||
$(intl_imports) frontend-component-header frontend-component-footer frontend-platform paragon frontend-app-discussions
|
||||
# endif
|
||||
|
||||
# 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
|
||||
|
||||
10
README.rst
10
README.rst
@@ -52,6 +52,12 @@ Cloning and Startup
|
||||
|
||||
The dev server is running at `http://localhost:2002 <http://localhost:2002>`_.
|
||||
|
||||
Plugins
|
||||
=======
|
||||
This MFE can be customized using `Frontend Plugin Framework <https://github.com/openedx/frontend-plugin-framework>`_.
|
||||
|
||||
The parts of this MFE that can be customized in that manner are documented `here </src/plugin-slots>`_.
|
||||
|
||||
Getting Help
|
||||
============
|
||||
Please tag **@openedx/edx-infinity ** on any PRs or issues. Thanks.
|
||||
@@ -70,7 +76,7 @@ How to Contribute
|
||||
|
||||
Details about how to become a contributor to the Open edX project may be found in the wiki at `How to contribute`_
|
||||
|
||||
.. _How to contribute: https://edx.readthedocs.io/projects/edx-developer-guide/en/latest/process/index.html
|
||||
.. _How to contribute: https://docs.openedx.org/en/latest/developers/references/developer_guide/process/index.html
|
||||
|
||||
PR description template should be automatically applied if you are sending PR from github interface; otherwise you
|
||||
can find it it at `PULL_REQUEST_TEMPLATE.md <https://github.com/openedx/frontend-app-discussions/blob/master/.github/pull_request_template.md>`_
|
||||
@@ -119,4 +125,4 @@ Please see `edx/frontend-platform's i18n module <https://edx.github.io/frontend-
|
||||
Reporting Security Issues
|
||||
=========================
|
||||
|
||||
Please do not report security issues in public. Please email security@openedx.org.
|
||||
Please do not report security issues in public. Please email security@openedx.org.
|
||||
|
||||
@@ -12,6 +12,7 @@ metadata:
|
||||
icon: "Web"
|
||||
annotations:
|
||||
openedx.org/arch-interest-groups: ""
|
||||
openedx.org/release: "master"
|
||||
spec:
|
||||
owner: group:edx-infinity
|
||||
type: 'website'
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
const { createConfig } = require('@edx/frontend-build');
|
||||
const { createConfig } = require('@openedx/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.
|
||||
setupFiles: ['<rootDir>/.env.test'],
|
||||
setupFilesAfterEnv: [
|
||||
'<rootDir>/src/setupTest.js',
|
||||
'<rootDir>/src/setupTest.jsx',
|
||||
],
|
||||
coveragePathIgnorePatterns: [
|
||||
'src/setupTest.js',
|
||||
'src/setupTest.jsx',
|
||||
'src/i18n',
|
||||
],
|
||||
});
|
||||
|
||||
11
openedx.yaml
11
openedx.yaml
@@ -1,11 +0,0 @@
|
||||
# This file describes this Open edX repo, as described in OEP-2:
|
||||
# http://open-edx-proposals.readthedocs.io/en/latest/oeps/oep-0002.html#specification
|
||||
|
||||
nick: tmpa
|
||||
oeps: {}
|
||||
owner: edx/arch-team
|
||||
openedx-release:
|
||||
# The openedx-release key is described in OEP-10:
|
||||
# https://open-edx-proposals.readthedocs.io/en/latest/oep-0010-proc-openedx-releases.html
|
||||
# The FAQ might also be helpful: https://openedx.atlassian.net/wiki/spaces/COMM/pages/1331268879/Open+edX+Release+FAQ
|
||||
ref: master
|
||||
22529
package-lock.json
generated
22529
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
48
package.json
48
package.json
@@ -16,13 +16,9 @@
|
||||
"lint:fix": "fedx-scripts eslint --ext .js --ext .jsx . --fix",
|
||||
"snapshot": "fedx-scripts jest --updateSnapshot",
|
||||
"start": "fedx-scripts webpack-dev-server --progress",
|
||||
"dev": "PUBLIC_PATH=/discussions/ MFE_CONFIG_API_URL='http://localhost:8000/api/mfe_config/v1' fedx-scripts webpack-dev-server --progress --host apps.local.openedx.io",
|
||||
"test": "fedx-scripts jest --coverage --passWithNoTests"
|
||||
},
|
||||
"husky": {
|
||||
"hooks": {
|
||||
"pre-commit": "npm run lint"
|
||||
}
|
||||
},
|
||||
"author": "edX",
|
||||
"license": "AGPL-3.0",
|
||||
"homepage": "https://github.com/openedx/frontend-app-discussions#readme",
|
||||
@@ -34,44 +30,46 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
|
||||
"@edx/frontend-component-footer": "12.5.1",
|
||||
"@edx/frontend-component-header": "4.9.1",
|
||||
"@edx/frontend-platform": "4.6.3",
|
||||
"@edx/paragon": "20.44.0",
|
||||
"@reduxjs/toolkit": "1.8.0",
|
||||
"@tinymce/tinymce-react": "3.13.1",
|
||||
"@edx/frontend-component-footer": "^14.6.0",
|
||||
"@edx/frontend-component-header": "^6.2.0",
|
||||
"@edx/frontend-platform": "^8.3.3",
|
||||
"@edx/openedx-atlas": "^0.6.0",
|
||||
"@openedx/paragon": "^22.16.0",
|
||||
"@reduxjs/toolkit": "1.9.7",
|
||||
"@tinymce/tinymce-react": "5.1.1",
|
||||
"babel-polyfill": "6.26.0",
|
||||
"classnames": "2.3.2",
|
||||
"classnames": "2.5.1",
|
||||
"core-js": "3.21.1",
|
||||
"dompurify": "^2.4.3",
|
||||
"formik": "2.2.9",
|
||||
"formik": "2.4.5",
|
||||
"lodash.snakecase": "4.1.1",
|
||||
"prop-types": "15.8.1",
|
||||
"raw-loader": "4.0.2",
|
||||
"react": "17.0.2",
|
||||
"react-dom": "17.0.2",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-helmet": "6.1.0",
|
||||
"react-redux": "7.2.9",
|
||||
"react-router": "5.2.1",
|
||||
"react-router-dom": "5.3.0",
|
||||
"redux": "4.1.2",
|
||||
"regenerator-runtime": "0.13.9",
|
||||
"react-router": "6.18.0",
|
||||
"react-router-dom": "6.18.0",
|
||||
"redux": "4.2.1",
|
||||
"regenerator-runtime": "0.14.1",
|
||||
"timeago.js": "4.0.2",
|
||||
"tinymce": "5.10.7",
|
||||
"yup": "0.31.1"
|
||||
"yup": "0.32.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@edx/browserslist-config": "1.2.0",
|
||||
"@edx/frontend-build": "13.0.5",
|
||||
"@edx/reactifex": "1.1.0",
|
||||
"@openedx/frontend-build": "^14.3.3",
|
||||
"@testing-library/jest-dom": "5.17.0",
|
||||
"@testing-library/react": "12.1.5",
|
||||
"@testing-library/react": "14.3.1",
|
||||
"@testing-library/user-event": "13.5.0",
|
||||
"axios": "^0.28.0",
|
||||
"axios-mock-adapter": "1.22.0",
|
||||
"babel-plugin-react-intl": "8.2.25",
|
||||
"eslint-plugin-simple-import-sort": "7.0.0",
|
||||
"glob": "7.2.0",
|
||||
"husky": "7.0.4",
|
||||
"jest": "27.5.1",
|
||||
"rosie": "2.1.0"
|
||||
"jest": "29.7.0",
|
||||
"rosie": "2.1.1"
|
||||
}
|
||||
}
|
||||
|
||||
14
src/assets/ContentUnavailable.jsx
Normal file
14
src/assets/ContentUnavailable.jsx
Normal file
@@ -0,0 +1,14 @@
|
||||
const ContentUnavailable = () => (
|
||||
<svg width="229" height="167" viewBox="0 0 229 167" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M15.9664 67.649C1.9299 88.4776 -5.31519 112.805 4.55784 135.123C22.5467 175.788 120.573 164.359 163.26 148.39C283.487 103.415 225.675 -14.6 95.6636 14.5816C59.2626 22.7519 30.003 46.8204 15.9664 67.649Z" fill="#E1DDDB" fillOpacity="0.3" />
|
||||
<path d="M101.264 120.672L101.13 120.486H100.9H58.4969C54.0932 120.486 50.45 116.531 50.45 111.548V60.3944C50.45 55.4164 54.0937 51.45 58.4969 51.45H170.468C174.872 51.45 178.522 55.4171 178.55 60.3969V111.548C178.55 116.531 174.901 120.486 170.497 120.486H126.838H126.636L126.502 120.637L112.568 136.283L101.264 120.672Z" fill="white" stroke="#454545" strokeWidth="0.9" />
|
||||
<path d="M99.363 99.6098L93.9175 94.6162L82.0459 107.565L87.4913 112.558L99.363 99.6098Z" fill="#002121" />
|
||||
<path d="M87.3976 112.877C87.3486 112.862 87.3041 112.836 87.268 112.8L81.7927 107.803C81.76 107.774 81.7334 107.739 81.7145 107.7C81.6956 107.661 81.6848 107.619 81.6828 107.575C81.6807 107.532 81.6874 107.488 81.7025 107.447C81.7175 107.407 81.7407 107.369 81.7705 107.338L93.645 94.3885C93.6737 94.3558 93.7088 94.3292 93.7481 94.3106C93.7875 94.292 93.8302 94.2817 93.8737 94.2803C93.9601 94.2699 94.047 94.2939 94.1158 94.3472L99.5894 99.3501C99.6214 99.3792 99.6472 99.4144 99.6654 99.4537C99.6835 99.4929 99.6937 99.5354 99.6951 99.5786C99.6966 99.6219 99.6894 99.665 99.6739 99.7054C99.6585 99.7458 99.6351 99.7827 99.6052 99.8139L87.7445 112.787C87.6839 112.848 87.6031 112.884 87.5175 112.889C87.4771 112.894 87.4361 112.89 87.3976 112.877ZM82.5076 107.548L87.4698 112.094L98.9201 99.6383L93.966 95.0875L82.5076 107.548Z" fill="#002121" />
|
||||
<path d="M90.5786 108.62L85.6982 104.144C85.0283 103.53 83.9874 103.575 83.3732 104.245L62.9753 126.494C62.3611 127.164 62.4062 128.205 63.076 128.819L67.9565 133.294C68.6263 133.909 69.6672 133.863 70.2814 133.193L90.6793 110.945C91.2935 110.275 91.2484 109.234 90.5786 108.62Z" fill="#03C7E8" />
|
||||
<path d="M68.543 133.99C68.2434 133.908 67.9682 133.754 67.7412 133.542L62.8495 129.063C62.4689 128.709 62.2426 128.22 62.2195 127.7C62.1963 127.181 62.3781 126.673 62.7256 126.286L83.1283 104.037C83.4824 103.656 83.9719 103.43 84.4913 103.407C85.0107 103.384 85.5184 103.565 85.905 103.913L90.7904 108.39C91.171 108.744 91.3972 109.234 91.4204 109.753C91.4435 110.273 91.2618 110.78 90.9142 111.167L70.5243 133.42C70.2781 133.687 69.963 133.882 69.6136 133.983C69.2641 134.083 68.8938 134.086 68.543 133.99ZM84.937 104.091C84.8052 104.054 84.6678 104.039 84.5309 104.048C84.3574 104.056 84.1873 104.099 84.0304 104.173C83.8734 104.247 83.7325 104.352 83.616 104.48L63.2151 126.723C62.9827 126.981 62.8613 127.321 62.8771 127.668C62.8928 128.015 63.0444 128.341 63.2992 128.577L68.1845 133.054C68.4417 133.287 68.78 133.409 69.1265 133.395C69.473 133.38 69.7998 133.23 70.0366 132.976L90.434 110.746C90.6663 110.488 90.7878 110.149 90.772 109.802C90.7563 109.455 90.6046 109.128 90.3499 108.892L85.4645 104.415C85.3185 104.265 85.1372 104.154 84.937 104.091Z" fill="#002121" />
|
||||
<path d="M119.367 71.6959C116.6 69.1548 113.141 67.492 109.428 66.9178C105.715 66.3436 101.916 66.8839 98.51 68.4703C95.1043 70.0567 92.2457 72.6179 90.296 75.8298C88.3463 79.0416 87.3932 82.7597 87.5572 86.5134C87.7212 90.2671 88.9951 93.8877 91.2175 96.9169C93.44 99.9461 96.5111 102.248 100.042 103.531C103.573 104.813 107.406 105.02 111.054 104.123C114.702 103.227 118.003 101.268 120.538 98.4946C123.931 94.7829 125.713 89.8768 125.494 84.8527C125.274 79.8287 123.071 75.097 119.367 71.6959ZM96.9839 96.0996C94.9233 94.2099 93.4694 91.7515 92.8059 89.0353C92.1424 86.3191 92.2992 83.467 93.2565 80.8399C94.2138 78.2127 95.9285 75.9283 98.1839 74.2757C100.439 72.6231 103.134 71.6765 105.927 71.5556C108.72 71.4346 111.487 72.1447 113.876 73.5962C116.266 75.0477 118.171 77.1752 119.352 79.7098C120.532 82.2445 120.934 85.0723 120.508 87.8357C120.081 90.5991 118.845 93.174 116.955 95.2348C114.421 97.9979 110.893 99.6412 107.148 99.8034C103.403 99.9656 99.7468 98.6333 96.9839 96.0996Z" fill="white" />
|
||||
<path d="M101.371 104.313C96.6651 103.025 92.6175 100.01 90.0365 95.8695C87.4556 91.7285 86.5312 86.7663 87.4479 81.9735C88.3647 77.1806 91.055 72.9097 94.982 70.0134C98.9089 67.117 103.784 65.8084 108.633 66.3486C113.482 66.8888 117.949 69.2382 121.142 72.9277C124.335 76.6173 126.019 81.3755 125.858 86.2526C125.697 91.1296 123.703 95.7667 120.273 99.2381C116.844 102.709 112.232 104.76 107.358 104.98C105.34 105.074 103.319 104.848 101.371 104.313ZM111.529 67.7055C109.642 67.1867 107.687 66.9654 105.732 67.0496C101.063 67.2601 96.6449 69.2229 93.3586 72.5465C90.0724 75.8701 88.1594 80.3103 88.0012 84.9817C87.843 89.653 89.4512 94.2123 92.505 97.7502C95.5589 101.288 99.834 103.545 104.478 104.07C109.122 104.596 113.793 103.351 117.56 100.585C121.328 97.8185 123.914 93.7337 124.804 89.1451C125.694 84.5565 124.821 79.8012 122.361 75.8275C119.9 71.8538 116.033 68.9537 111.529 67.7055ZM102.661 99.6222C100.464 99.0148 98.4423 97.8945 96.7628 96.3534C94.1466 93.9593 92.5 90.6882 92.1352 87.1605C91.7704 83.6327 92.7128 80.0937 94.7836 77.2145C96.8544 74.3353 99.9096 72.3161 103.37 71.54C106.83 70.7638 110.455 71.2847 113.556 73.0037C116.658 74.7227 119.021 77.5203 120.197 80.8661C121.373 84.2118 121.281 87.873 119.937 91.1553C118.594 94.4375 116.093 97.1126 112.908 98.6733C109.724 100.234 106.077 100.572 102.661 99.6222ZM97.2188 95.8692C99.2303 97.7107 101.742 98.9152 104.437 99.3306C107.133 99.746 109.89 99.3537 112.363 98.2033C114.836 97.0529 116.912 95.1958 118.331 92.8664C119.75 90.5371 120.447 87.8397 120.334 85.1146C120.215 82.3875 119.291 79.7568 117.678 77.5551C116.064 75.3534 113.835 73.6794 111.27 72.7447C108.706 71.81 105.922 71.6565 103.27 72.3037C100.618 72.9509 98.2181 74.3696 96.3723 76.3807C93.9071 79.0806 92.612 82.6474 92.7707 86.3C92.9294 89.9527 94.5288 93.3936 97.2188 95.8692Z" fill="#002121" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default ContentUnavailable;
|
||||
@@ -1,23 +1,22 @@
|
||||
/* eslint-disable react/forbid-prop-types */
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import {
|
||||
Collapsible, Form, Icon, Spinner,
|
||||
} from '@openedx/paragon';
|
||||
import { Tune } from '@openedx/paragon/icons';
|
||||
import { capitalize, toString } from 'lodash';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Collapsible, Form, Icon, Spinner,
|
||||
} from '@edx/paragon';
|
||||
import { Tune } from '@edx/paragon/icons';
|
||||
|
||||
import {
|
||||
PostsStatusFilter, RequestStatus,
|
||||
ThreadOrdering, ThreadType,
|
||||
} from '../data/constants';
|
||||
import { selectCourseCohorts } from '../discussions/cohorts/data/selectors';
|
||||
import selectCourseCohorts from '../discussions/cohorts/data/selectors';
|
||||
import messages from '../discussions/posts/post-filter-bar/messages';
|
||||
import ActionItem from '../discussions/posts/post-filter-bar/PostFilterBar';
|
||||
import { ActionItem } from '../discussions/posts/post-filter-bar/PostFilterBar';
|
||||
|
||||
const FilterBar = ({
|
||||
intl,
|
||||
@@ -93,10 +92,15 @@ const FilterBar = ({
|
||||
},
|
||||
];
|
||||
|
||||
const handleFilterToggle = useCallback((event) => {
|
||||
onFilterChange(event);
|
||||
setOpen(false);
|
||||
}, [onFilterChange]);
|
||||
|
||||
return (
|
||||
<Collapsible.Advanced
|
||||
open={isOpen}
|
||||
onToggle={() => setOpen(!isOpen)}
|
||||
onToggle={setOpen}
|
||||
className="filter-bar collapsible-card-lg border-0"
|
||||
>
|
||||
<Collapsible.Trigger className="collapsible-trigger border-0">
|
||||
@@ -126,7 +130,7 @@ const FilterBar = ({
|
||||
name={value.name}
|
||||
className="d-flex flex-column list-group list-group-flush"
|
||||
value={selectedFilters[value.name]}
|
||||
onChange={onFilterChange}
|
||||
onChange={handleFilterToggle}
|
||||
>
|
||||
{value.filters.map(filterName => {
|
||||
const element = allFilters.find(obj => obj.id === filterName);
|
||||
@@ -159,7 +163,7 @@ const FilterBar = ({
|
||||
name="cohort"
|
||||
className="d-flex flex-column list-group list-group-flush w-100"
|
||||
value={selectedFilters.cohort}
|
||||
onChange={onFilterChange}
|
||||
onChange={handleFilterToggle}
|
||||
>
|
||||
<ActionItem
|
||||
id="all-groups"
|
||||
@@ -189,8 +193,16 @@ const FilterBar = ({
|
||||
|
||||
FilterBar.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
filters: PropTypes.array.isRequired,
|
||||
selectedFilters: PropTypes.object.isRequired,
|
||||
filters: PropTypes.arrayOf(PropTypes.shape({
|
||||
name: PropTypes.string,
|
||||
filters: PropTypes.arrayOf(PropTypes.string),
|
||||
})).isRequired,
|
||||
selectedFilters: PropTypes.shape({
|
||||
postType: ThreadType,
|
||||
status: PostsStatusFilter,
|
||||
orderBy: ThreadOrdering,
|
||||
cohort: PropTypes.string,
|
||||
}).isRequired,
|
||||
onFilterChange: PropTypes.func.isRequired,
|
||||
showCohortsFilter: PropTypes.bool,
|
||||
};
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Form, TransitionReplace } from '@openedx/paragon';
|
||||
import { getIn, useFormikContext } from 'formik';
|
||||
|
||||
import { Form, TransitionReplace } from '@edx/paragon';
|
||||
|
||||
const FormikErrorFeedback = ({ name }) => {
|
||||
const { touched, errors } = useFormikContext();
|
||||
const fieldTouched = getIn(touched, name);
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useDebounce } from '../discussions/data/hooks';
|
||||
|
||||
const defaultSanitizeOptions = {
|
||||
USE_PROFILES: { html: true },
|
||||
ADD_ATTR: ['columnalign'],
|
||||
ADD_ATTR: ['columnalign', 'target'],
|
||||
};
|
||||
|
||||
const HTMLLoader = ({
|
||||
|
||||
23
src/components/Head/Head.jsx
Normal file
23
src/components/Head/Head.jsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
const Head = ({ intl }) => (
|
||||
<Helmet>
|
||||
<title>
|
||||
{intl.formatMessage(messages['discussions.page.title'], { siteName: getConfig().SITE_NAME })}
|
||||
</title>
|
||||
<link rel="shortcut icon" href={getConfig().FAVICON_URL} type="image/x-icon" />
|
||||
</Helmet>
|
||||
);
|
||||
|
||||
Head.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(Head);
|
||||
20
src/components/Head/Head.test.jsx
Normal file
20
src/components/Head/Head.test.jsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
|
||||
import { render } from '@testing-library/react';
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import Head from './Head';
|
||||
|
||||
describe('Head', () => {
|
||||
const props = {};
|
||||
it('should match render title tag and favicon with the site configuration values', () => {
|
||||
render(<IntlProvider locale="en"><Head {...props} /></IntlProvider>);
|
||||
const helmet = Helmet.peek();
|
||||
expect(helmet.title).toEqual(`Discussions | ${getConfig().SITE_NAME}`);
|
||||
expect(helmet.linkTags[0].rel).toEqual('shortcut icon');
|
||||
expect(helmet.linkTags[0].href).toEqual(getConfig().FAVICON_URL);
|
||||
});
|
||||
});
|
||||
11
src/components/Head/messages.js
Normal file
11
src/components/Head/messages.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
'discussions.page.title': {
|
||||
id: 'discussions.page.title',
|
||||
defaultMessage: 'Discussions | {siteName}',
|
||||
description: 'Title tag',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -1,35 +1,21 @@
|
||||
import React, { memo, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import withConditionalInContextRendering from '../../discussions/common/withConditionalInContextRendering';
|
||||
import { useCourseId } from '../../discussions/data/hooks';
|
||||
import { fetchTab } from './data/thunks';
|
||||
import Tabs from './tabs/Tabs';
|
||||
import messages from './messages';
|
||||
|
||||
import './navBar.scss';
|
||||
|
||||
const CourseTabsNavigation = ({ activeTab, className, rootSlug }) => {
|
||||
const dispatch = useDispatch();
|
||||
const CourseTabsNavigation = () => {
|
||||
const intl = useIntl();
|
||||
const courseId = useCourseId();
|
||||
const tabs = useSelector(state => state.courseTabs.tabs);
|
||||
|
||||
useEffect(() => {
|
||||
if (courseId) {
|
||||
dispatch(fetchTab(courseId, rootSlug));
|
||||
}
|
||||
}, [courseId]);
|
||||
|
||||
console.log('CourseTabsNavigation');
|
||||
|
||||
return (
|
||||
<div id="courseTabsNavigation" tabIndex="-1" className={classNames('course-tabs-navigation px-4', className)}>
|
||||
<div id="courseTabsNavigation" className="course-tabs-navigation px-4 bg-white">
|
||||
{!!tabs.length && (
|
||||
<Tabs
|
||||
className="nav-underline-tabs"
|
||||
@@ -38,7 +24,7 @@ const CourseTabsNavigation = ({ activeTab, className, rootSlug }) => {
|
||||
{tabs.map(({ url, title, slug }) => (
|
||||
<a
|
||||
key={slug}
|
||||
className={classNames('nav-item flex-shrink-0 nav-link', { active: slug === activeTab })}
|
||||
className={classNames('nav-item flex-shrink-0 nav-link', { active: slug === 'discussion' })}
|
||||
href={url}
|
||||
>
|
||||
{title}
|
||||
@@ -50,16 +36,4 @@ const CourseTabsNavigation = ({ activeTab, className, rootSlug }) => {
|
||||
);
|
||||
};
|
||||
|
||||
CourseTabsNavigation.propTypes = {
|
||||
activeTab: PropTypes.string,
|
||||
className: PropTypes.string,
|
||||
rootSlug: PropTypes.string,
|
||||
};
|
||||
|
||||
CourseTabsNavigation.defaultProps = {
|
||||
activeTab: 'discussion',
|
||||
className: null,
|
||||
rootSlug: 'outline',
|
||||
};
|
||||
|
||||
export default memo(withConditionalInContextRendering(CourseTabsNavigation, false));
|
||||
export default React.memo(CourseTabsNavigation);
|
||||
|
||||
@@ -19,7 +19,7 @@ Factory.define('navigationBar')
|
||||
user_message: null,
|
||||
}))
|
||||
.option('course_id', null, 'course-v1:edX+DemoX+Demo_Course')
|
||||
.attr('is_enrolled', null, false)
|
||||
.sequence('is_enrolled', ['isEnrolled'], (idx, isEnrolled) => isEnrolled)
|
||||
.attr('is_self_paced', null, false)
|
||||
.attr('is_staff', null, true)
|
||||
.attr('number', null, 'DemoX')
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import { camelCaseObject } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
@@ -6,15 +5,12 @@ import { getApiBaseUrl } from '../../../data/constants';
|
||||
|
||||
export const getCourseMetadataApiUrl = (courseId) => `${getApiBaseUrl()}/api/course_home/course_metadata/${courseId}`;
|
||||
|
||||
function normalizeCourseHomeCourseMetadata(metadata, rootSlug) {
|
||||
function normalizeCourseHomeCourseMetadata(metadata) {
|
||||
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,
|
||||
slug: tab.tabId === 'courseware' ? 'outline' : tab.tabId,
|
||||
title: tab.title,
|
||||
url: tab.url,
|
||||
})),
|
||||
@@ -22,10 +18,9 @@ function normalizeCourseHomeCourseMetadata(metadata, rootSlug) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function getCourseHomeCourseMetadata(courseId, rootSlug) {
|
||||
export async function getCourseHomeCourseMetadata(courseId) {
|
||||
const url = getCourseMetadataApiUrl(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);
|
||||
|
||||
return normalizeCourseHomeCourseMetadata(data);
|
||||
}
|
||||
|
||||
@@ -5,9 +5,9 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { initializeMockApp } from '@edx/frontend-platform/testing';
|
||||
|
||||
import { initializeStore } from '../../../store';
|
||||
import { executeThunk } from '../../../test-utils';
|
||||
import executeThunk from '../../../test-utils';
|
||||
import { getCourseMetadataApiUrl } from './api';
|
||||
import { fetchTab } from './thunks';
|
||||
import fetchTab from './thunks';
|
||||
|
||||
import './__factories__';
|
||||
|
||||
@@ -34,7 +34,7 @@ describe('Navigation bar api tests', () => {
|
||||
});
|
||||
|
||||
it('Successfully get navigation tabs', async () => {
|
||||
axiosMock.onGet(`${getCourseMetadataApiUrl(courseId)}`).reply(200, (Factory.build('navigationBar', 1)));
|
||||
axiosMock.onGet(`${getCourseMetadataApiUrl(courseId)}`).reply(200, (Factory.build('navigationBar', 1, { isEnrolled: true })));
|
||||
await executeThunk(fetchTab(courseId, 'outline'), store.dispatch, store.getState);
|
||||
|
||||
expect(store.getState().courseTabs.tabs).toHaveLength(4);
|
||||
@@ -58,7 +58,7 @@ describe('Navigation bar api tests', () => {
|
||||
it('Denied to get navigation bar when user has no access on course', async () => {
|
||||
axiosMock.onGet(`${getCourseMetadataApiUrl(courseId)}`).reply(
|
||||
200,
|
||||
(Factory.build('navigationBar', 1, { hasCourseAccess: false })),
|
||||
(Factory.build('navigationBar', 1, { hasCourseAccess: false, isEnrolled: true })),
|
||||
);
|
||||
await executeThunk(fetchTab(courseId, 'outline'), store.dispatch, store.getState);
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable no-param-reassign */
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
export const LOADING = 'loading';
|
||||
@@ -14,30 +13,44 @@ const slice = createSlice({
|
||||
tabs: [],
|
||||
courseTitle: null,
|
||||
courseNumber: null,
|
||||
isEnrolled: false,
|
||||
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;
|
||||
},
|
||||
fetchTabDenied: (state, { payload }) => (
|
||||
{
|
||||
...state,
|
||||
courseId: payload.courseId,
|
||||
courseStatus: DENIED,
|
||||
}
|
||||
),
|
||||
fetchTabFailure: (state, { payload }) => (
|
||||
{
|
||||
...state,
|
||||
courseId: payload.courseId,
|
||||
courseStatus: FAILED,
|
||||
}
|
||||
),
|
||||
fetchTabRequest: (state, { payload }) => (
|
||||
{
|
||||
...state,
|
||||
courseId: payload.courseId,
|
||||
courseStatus: LOADING,
|
||||
}
|
||||
),
|
||||
fetchTabSuccess: (state, { payload }) => (
|
||||
{
|
||||
...state,
|
||||
courseId: payload.courseId,
|
||||
targetUserId: payload.targetUserId,
|
||||
tabs: payload.tabs,
|
||||
courseStatus: LOADED,
|
||||
courseTitle: payload.courseTitle,
|
||||
courseNumber: payload.courseNumber,
|
||||
org: payload.org,
|
||||
isEnrolled: payload.isEnrolled,
|
||||
}
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable import/prefer-default-export, no-unused-expressions */
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
|
||||
import { getHttpErrorStatus } from '../../../discussions/utils';
|
||||
@@ -10,11 +9,11 @@ import {
|
||||
fetchTabSuccess,
|
||||
} from './slice';
|
||||
|
||||
export function fetchTab(courseId, rootSlug) {
|
||||
export default function fetchTab(courseId) {
|
||||
return async (dispatch) => {
|
||||
dispatch(fetchTabRequest({ courseId }));
|
||||
try {
|
||||
const courseHomeCourseMetadata = await getCourseHomeCourseMetadata(courseId, rootSlug);
|
||||
const courseHomeCourseMetadata = await getCourseHomeCourseMetadata(courseId);
|
||||
if (!courseHomeCourseMetadata.courseAccess.hasAccess) {
|
||||
dispatch(fetchTabDenied({ courseId }));
|
||||
} else {
|
||||
@@ -24,6 +23,7 @@ export function fetchTab(courseId, rootSlug) {
|
||||
org: courseHomeCourseMetadata.org,
|
||||
courseNumber: courseHomeCourseMetadata.number,
|
||||
courseTitle: courseHomeCourseMetadata.title,
|
||||
isEnrolled: courseHomeCourseMetadata.isEnrolled,
|
||||
}));
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
@import "~@edx/brand/paragon/fonts.scss";
|
||||
@import "~@edx/brand/paragon/variables.scss";
|
||||
@import "~@edx/paragon/scss/core/core.scss";
|
||||
@import "~@openedx/paragon/scss/core/core.scss";
|
||||
@import "~@edx/brand/paragon/overrides.scss";
|
||||
|
||||
$fa-font-path: "~font-awesome/fonts";
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Dropdown } from '@openedx/paragon';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import { Dropdown } from '@edx/paragon';
|
||||
|
||||
import useIndexOfLastVisibleChild from './useIndexOfLastVisibleChild';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useLayoutEffect, useRef, useState } from 'react';
|
||||
|
||||
import { useWindowSize } from '@edx/paragon';
|
||||
import { useWindowSize } from '@openedx/paragon';
|
||||
|
||||
const invisibleStyle = {
|
||||
position: 'absolute',
|
||||
|
||||
74
src/components/PostHelpPanel.jsx
Normal file
74
src/components/PostHelpPanel.jsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import {
|
||||
Hyperlink, Icon, IconButton, IconButtonWithTooltip,
|
||||
} from '@openedx/paragon';
|
||||
import { Close, HelpOutline } from '@openedx/paragon/icons';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import messages from '../discussions/posts/post-editor/messages';
|
||||
|
||||
const PostHelpPanel = () => {
|
||||
const intl = useIntl();
|
||||
const [showHelpPane, setShowHelpPane] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="d-flex justify-content-end">
|
||||
<IconButtonWithTooltip
|
||||
onClick={() => setShowHelpPane(true)}
|
||||
alt={intl.formatMessage(messages.showHelpIcon)}
|
||||
tooltipContent={<div>{intl.formatMessage(messages.discussionHelpTooltip)}</div>}
|
||||
src={HelpOutline}
|
||||
iconAs={Icon}
|
||||
size="inline"
|
||||
className="float-right p-3 help-icon"
|
||||
iconClassNames="help-icon-size"
|
||||
data-testid="help-button"
|
||||
invertColors
|
||||
isActive
|
||||
/>
|
||||
</div>
|
||||
{showHelpPane && (
|
||||
<div
|
||||
className="w-100 p-2 bg-light-200 rounded box-shadow-down-1 post-preview overflow-auto my-3"
|
||||
style={{ minHeight: '200px', wordBreak: 'break-word' }}
|
||||
>
|
||||
<IconButton
|
||||
onClick={() => setShowHelpPane(false)}
|
||||
alt={intl.formatMessage(messages.actionsAlt)}
|
||||
src={Close}
|
||||
iconAs={Icon}
|
||||
size="inline"
|
||||
className="float-right p-3"
|
||||
iconClassNames="icon-size"
|
||||
data-testid="hide-help-button"
|
||||
/>
|
||||
<div className="pt-2 px-3">
|
||||
<h4 className="font-weight-bold">{intl.formatMessage(messages.discussionHelpHeader)}</h4>
|
||||
<p className="pt-2">{intl.formatMessage(messages.discussionHelpDescription)}</p>
|
||||
<Hyperlink
|
||||
target="_blank"
|
||||
className="w-100"
|
||||
destination="https://support.edx.org/hc/en-us/sections/115004169687-Participating-in-Course-Discussions"
|
||||
showLaunchIcon={false}
|
||||
>
|
||||
{intl.formatMessage(messages.discussionHelpCourseParticipation)}
|
||||
</Hyperlink>
|
||||
<Hyperlink
|
||||
target="_blank"
|
||||
className="w-100"
|
||||
destination="https://support.edx.org/hc/en-us/articles/360000035267-Entering-math-expressions-in-course-discussions"
|
||||
showLaunchIcon={false}
|
||||
>
|
||||
{intl.formatMessage(messages.discussionHelpMathExpressions)}
|
||||
</Hyperlink>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(PostHelpPanel);
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Button, Icon, IconButton } from '@openedx/paragon';
|
||||
import { Close } from '@openedx/paragon/icons';
|
||||
|
||||
import { useIntl } 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';
|
||||
@@ -18,7 +19,7 @@ const PostPreviewPanel = ({
|
||||
<>
|
||||
{showPreviewPane && (
|
||||
<div
|
||||
className={`w-100 p-2 bg-light-200 rounded box-shadow-down-1 post-preview ${isPost ? 'mt-2 mb-5' : 'my-3'}`}
|
||||
className={`w-100 p-2 bg-light-200 rounded box-shadow-down-1 post-preview overflow-auto ${isPost ? 'mt-2 mb-5' : 'my-3'}`}
|
||||
style={{ minHeight: '200px', wordBreak: 'break-word' }}
|
||||
>
|
||||
<IconButton
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import React, {
|
||||
useCallback, useEffect, useMemo, useRef, useState,
|
||||
useCallback, useContext, useEffect, useRef, useState,
|
||||
} from 'react';
|
||||
|
||||
import { Icon, SearchField } from '@openedx/paragon';
|
||||
import { Search as SearchIcon } from '@openedx/paragon/icons';
|
||||
import camelCase from 'lodash/camelCase';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Icon, SearchField } from '@edx/paragon';
|
||||
import { Search as SearchIcon } from '@edx/paragon/icons';
|
||||
|
||||
import { useCurrentPage } from '../discussions/data/hooks';
|
||||
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';
|
||||
@@ -18,7 +18,7 @@ import { setFilter as setTopicFilter } from '../discussions/topics/data/slices';
|
||||
const Search = () => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
const page = useCurrentPage();
|
||||
const { page } = useContext(DiscussionContext);
|
||||
const postSearch = useSelector(({ threads }) => threads.filters.search);
|
||||
const topicSearch = useSelector(({ topics }) => topics.filter);
|
||||
const learnerSearch = useSelector(({ learners }) => learners.usernameSearch);
|
||||
@@ -26,15 +26,15 @@ const Search = () => {
|
||||
const isTopicSearch = 'topics'.includes(page);
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const previousSearchValueRef = useRef('');
|
||||
let currentValue = '';
|
||||
|
||||
const currentValue = useMemo(() => {
|
||||
if (isPostSearch) {
|
||||
return postSearch;
|
||||
} if (isTopicSearch) {
|
||||
return topicSearch;
|
||||
}
|
||||
return learnerSearch;
|
||||
}, [postSearch, topicSearch, learnerSearch]);
|
||||
if (isPostSearch) {
|
||||
currentValue = postSearch;
|
||||
} else if (isTopicSearch) {
|
||||
currentValue = topicSearch;
|
||||
} else {
|
||||
currentValue = learnerSearch;
|
||||
}
|
||||
|
||||
const onClear = useCallback(() => {
|
||||
dispatch(setSearchQuery(''));
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Button, Icon } from '@openedx/paragon';
|
||||
import { Search } from '@openedx/paragon/icons';
|
||||
|
||||
import { useIntl } 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';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Spinner as ParagonSpinner } from '@edx/paragon';
|
||||
import { Spinner as ParagonSpinner } from '@openedx/paragon';
|
||||
|
||||
const Spinner = () => (
|
||||
<div className="spinner-container" data-testid="spinner">
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { ActionRow, AlertModal, Button } from '@openedx/paragon';
|
||||
import { Editor } from '@tinymce/tinymce-react';
|
||||
import { useLocation, useParams } from 'react-router';
|
||||
import { useLocation, useParams } from 'react-router-dom';
|
||||
// TinyMCE so the global var exists
|
||||
// eslint-disable-next-line no-unused-vars,import/no-extraneous-dependencies
|
||||
import tinymce from 'tinymce/tinymce';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { ActionRow, AlertModal, Button } from '@edx/paragon';
|
||||
|
||||
import { MAX_UPLOAD_FILE_SIZE } from '../data/constants';
|
||||
import messages from '../discussions/messages';
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Icon, OverlayTrigger, Tooltip } from '@openedx/paragon';
|
||||
import { HelpOutline, PostOutline, Report } from '@openedx/paragon/icons';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Icon, OverlayTrigger, Tooltip } from '@edx/paragon';
|
||||
import { HelpOutline, PostOutline, Report } from '@edx/paragon/icons';
|
||||
|
||||
import {
|
||||
selectUserHasModerationPrivileges,
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
// eslint-disable-next-line react/function-component-definition
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
// eslint-disable-next-line react/function-component-definition
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
// eslint-disable-next-line react/function-component-definition
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
// eslint-disable-next-line react/function-component-definition
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
// eslint-disable-next-line react/function-component-definition
|
||||
export default function Question() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="28"
|
||||
height="28"
|
||||
fill="none"
|
||||
viewBox="0 0 28 28"
|
||||
>
|
||||
<path
|
||||
fill="#fff"
|
||||
d="M0 14.001c0-7.732 6.268-14 14-14s14 6.268 14 14-6.268 14-14 14-14-6.268-14-14z"
|
||||
/>
|
||||
<path
|
||||
fill="#2D494E"
|
||||
d="M14 2.334c-6.44 0-11.667 5.227-11.667 11.667 0 6.44 5.227 11.667 11.667 11.667 6.44 0 11.666-5.227 11.666-11.667 0-6.44-5.226-11.667-11.666-11.667z"
|
||||
/>
|
||||
<path
|
||||
fill="#fff"
|
||||
d="M12.833 22.168h2.333v-2.334h-2.333v2.334zM16.531 14.2l1.05-1.074a3.712 3.712 0 001.085-2.625A4.665 4.665 0 0014 5.834a4.665 4.665 0 00-4.667 4.667h2.333A2.34 2.34 0 0114 8.168a2.34 2.34 0 012.333 2.333c0 .642-.257 1.225-.688 1.645l-1.447 1.47a4.696 4.696 0 00-1.365 3.302v.583h2.333c0-1.75.525-2.45 1.365-3.302z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
// eslint-disable-next-line react/function-component-definition
|
||||
export default function QuestionAnswer() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="21"
|
||||
height="20"
|
||||
fill="none"
|
||||
viewBox="0 0 21 20"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M18.737 5h-2.5v7.5H5.404V15h10l3.333 3.333V5zm-4.166 5.833V1.667H2.07v12.5l3.333-3.334h9.166z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
// eslint-disable-next-line react/function-component-definition
|
||||
export default function QuestionAnswerOutline() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
fill="none"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12.6 3.267v6.067H4.08l-.512.512-.502.502v-7.08H12.6zm.867-1.733H2.198a.87.87 0 00-.867.867v12.134L4.8 11.068h8.668a.87.87 0 00.866-.867v-7.8a.87.87 0 00-.867-.867zM17.8 5h-1.733v7.8H4.799v1.734c0 .476.39.867.867.867H15.2l3.467 3.466v-13A.87.87 0 0017.8 5z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
// eslint-disable-next-line react/function-component-definition
|
||||
export default function StarFilled() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="21"
|
||||
height="20"
|
||||
fill="none"
|
||||
viewBox="0 0 21 20"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M10.404 14.392l5.15 3.108-1.367-5.858 4.55-3.942-5.991-.508-2.342-5.525-2.342 5.525L2.07 7.7l4.55 3.942L5.254 17.5l5.15-3.108z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
// eslint-disable-next-line react/function-component-definition
|
||||
export default function StarOutline() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
fill="none"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M18.737 7.7l-5.991-.517-2.342-5.516-2.342 5.525L2.07 7.7l4.55 3.942L5.254 17.5l5.15-3.108 5.15 3.108-1.359-5.858L18.737 7.7zm-8.333 5.133L7.27 14.725l.834-3.567-2.767-2.4 3.65-.316 1.417-3.359 1.425 3.367 3.65.317-2.767 2.4.834 3.566-3.142-1.9z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
// eslint-disable-next-line react/function-component-definition
|
||||
export default function ThumbUpFilled() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="21"
|
||||
height="20"
|
||||
fill="none"
|
||||
viewBox="0 0 21 20"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12.212.833L6.237 6.817V17.5h10.258l3.075-7.167V6.667h-6.925l.934-4.484-1.367-1.35zM1.237 7.5H4.57v10H1.237v-10z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
// eslint-disable-next-line react/function-component-definition
|
||||
export default function ThumbUpOutline() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
fill="none"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
d="M19.57 6.667v3.666L16.495 17.5H6.238V6.817L12.212.833l1.367 1.35-.934 4.484h6.925zm-11.666.841v8.325h7.492l2.508-5.841V8.333h-7.309l.925-4.45-3.616 3.625z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
<path fill="currentColor" d="M4.57 17.5H1.237v-10H4.57v10z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
export { default as InsertLink } from './InsertLink';
|
||||
export { default as Issue } from './Issue';
|
||||
export { default as People } from './People';
|
||||
export { default as PushPin } from './PushPin';
|
||||
export { default as Question } from './Question';
|
||||
export { default as QuestionAnswer } from './QuestionAnswer';
|
||||
export { default as QuestionAnswerOutline } from './QuestionAnswerOutline';
|
||||
export { default as StarFilled } from './StarFilled';
|
||||
export { default as StarOutline } from './StarOutline';
|
||||
export { default as ThumbUpFilled } from './ThumbUpFilled';
|
||||
export { default as ThumbUpOutline } from './ThumbUpOutline';
|
||||
@@ -1,7 +1,5 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
|
||||
// Course Blocks API response for the demo course.
|
||||
export const getBlocksAPIResponse = (newProvider = false) => {
|
||||
const getBlocksAPIResponse = (newProvider = false) => {
|
||||
const response = {
|
||||
root: 'block-v1:edX+DemoX+Demo_Course+type@course+block@course',
|
||||
blocks: {
|
||||
@@ -936,3 +934,5 @@ export const getBlocksAPIResponse = (newProvider = false) => {
|
||||
}
|
||||
return response;
|
||||
};
|
||||
|
||||
export default getBlocksAPIResponse;
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from './blocks';
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
import { getApiBaseUrl } from './constants';
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
export const getApiBaseUrl = () => getConfig().LMS_BASE_URL;
|
||||
export const getFullUrl = (path) => (
|
||||
new URL(`${getConfig().PUBLIC_PATH.replace(/\/$/, '')}/${path}`, window.location.origin).href
|
||||
);
|
||||
|
||||
/**
|
||||
* Enum for thread types.
|
||||
@@ -77,6 +80,7 @@ export const RequestStatus = {
|
||||
*/
|
||||
export const AvatarOutlineAndLabelColors = {
|
||||
Staff: 'staff-color',
|
||||
Moderator: 'TA-color',
|
||||
'Community TA': 'TA-color',
|
||||
};
|
||||
|
||||
@@ -137,25 +141,24 @@ export const DiscussionProvider = {
|
||||
OPEN_EDX: 'openedx',
|
||||
};
|
||||
|
||||
export const BASE_PATH = `${getConfig().PUBLIC_PATH}:courseId`;
|
||||
const BASE_PATH = '/:courseId';
|
||||
|
||||
export const Routes = {
|
||||
DISCUSSIONS: {
|
||||
PATH: BASE_PATH,
|
||||
},
|
||||
LEARNERS: {
|
||||
PATH: `${BASE_PATH}/learners`,
|
||||
POSTS: `${BASE_PATH}/learners/:learnerUsername/posts(/:postId)?`,
|
||||
PATH: `${BASE_PATH}/learners/:learnerUsername?`,
|
||||
POSTS: `${BASE_PATH}/learners/:learnerUsername/posts/:postId?`,
|
||||
POSTS_EDIT: `${BASE_PATH}/learners/:learnerUsername/posts/:postId/edit`,
|
||||
},
|
||||
POSTS: {
|
||||
PATH: `${BASE_PATH}/topics/:topicId`,
|
||||
MY_POSTS: `${BASE_PATH}/my-posts(/:postId)?`,
|
||||
ALL_POSTS: `${BASE_PATH}/posts(/:postId)?`,
|
||||
NEW_POST: [
|
||||
`${BASE_PATH}/topics/:topicId/posts/:postId`,
|
||||
`${BASE_PATH}/topics/:topicId`,
|
||||
`${BASE_PATH}`,
|
||||
],
|
||||
MY_POSTS: `${BASE_PATH}/my-posts/:postId?`,
|
||||
ALL_POSTS: `${BASE_PATH}/posts/:postId?`,
|
||||
EDIT_MY_POSTS: `${BASE_PATH}/my-posts/:postId/edit`,
|
||||
EDIT_ALL_POSTS: `${BASE_PATH}/posts/:postId/edit`,
|
||||
NEW_POST: `${BASE_PATH}/*`,
|
||||
EDIT_POST: [
|
||||
`${BASE_PATH}/category/:category/posts/:postId/edit`,
|
||||
`${BASE_PATH}/topics/:topicId/posts/:postId/edit`,
|
||||
@@ -166,19 +169,19 @@ export const Routes = {
|
||||
},
|
||||
COMMENTS: {
|
||||
PATH: [
|
||||
`${BASE_PATH}/category/:category/posts/:postId`,
|
||||
`${BASE_PATH}/topics/:topicId/posts/:postId`,
|
||||
`${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`,
|
||||
`${BASE_PATH}/learners/:learnerUsername/posts/:postId?`,
|
||||
],
|
||||
PAGE: `${BASE_PATH}/:page`,
|
||||
PAGE: `${BASE_PATH}/:page/*`,
|
||||
PAGES: {
|
||||
category: `${BASE_PATH}/category/:category/posts/:postId`,
|
||||
topics: `${BASE_PATH}/topics/:topicId/posts/:postId`,
|
||||
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`,
|
||||
learners: `${BASE_PATH}/learners/:learnerUsername/posts/:postId?`,
|
||||
},
|
||||
},
|
||||
TOPICS: {
|
||||
@@ -189,9 +192,10 @@ export const Routes = {
|
||||
],
|
||||
ALL: `${BASE_PATH}/topics`,
|
||||
CATEGORY: `${BASE_PATH}/category/:category`,
|
||||
CATEGORY_POST: `${BASE_PATH}/category/:category/posts/:postId`,
|
||||
CATEGORY_POST: `${BASE_PATH}/category/:category/posts/:postId?`,
|
||||
CATEGORY_POST_EDIT: `${BASE_PATH}/category/:category/posts/:postId/edit`,
|
||||
TOPIC: `${BASE_PATH}/topics/:topicId`,
|
||||
TOPIC_POST: `${BASE_PATH}/topics/:topicId/posts/:postId`,
|
||||
TOPIC_POST: `${BASE_PATH}/topics/:topicId/posts/:postId?`,
|
||||
TOPIC_POST_EDIT: `${BASE_PATH}/topics/:topicId/posts/:postId/edit`,
|
||||
},
|
||||
};
|
||||
@@ -205,11 +209,12 @@ export const PostsPages = {
|
||||
};
|
||||
|
||||
export const ALL_ROUTES = []
|
||||
.concat([Routes.TOPICS.CATEGORY_POST, Routes.TOPICS.CATEGORY])
|
||||
.concat([Routes.TOPICS.CATEGORY_POST, `${Routes.TOPICS.CATEGORY}?`])
|
||||
.concat(Routes.COMMENTS.PATH)
|
||||
.concat(Routes.TOPICS.PATH)
|
||||
.concat(Routes.POSTS.EDIT_POST)
|
||||
.concat([Routes.POSTS.ALL_POSTS, Routes.POSTS.MY_POSTS])
|
||||
.concat([Routes.LEARNERS.POSTS, Routes.LEARNERS.PATH])
|
||||
.concat([Routes.DISCUSSIONS.PATH]);
|
||||
.concat([`${Routes.DISCUSSIONS.PATH}/*`]);
|
||||
|
||||
export const MAX_UPLOAD_FILE_SIZE = 1024;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useDispatch } from 'react-redux';
|
||||
@@ -15,7 +14,7 @@ import { useDispatch } from 'react-redux';
|
||||
*
|
||||
* @return {(boolean|(function(*=): Promise<void>)|*)[]}
|
||||
*/
|
||||
export function useDispatchWithState() {
|
||||
export default function useDispatchWithState() {
|
||||
const dispatch = useDispatch();
|
||||
const [isDispatching, setDispatching] = useState(false);
|
||||
|
||||
|
||||
@@ -5,11 +5,11 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { initializeMockApp } from '@edx/frontend-platform/testing';
|
||||
|
||||
import { initializeStore } from '../store';
|
||||
import { executeThunk } from '../test-utils';
|
||||
import { getBlocksAPIResponse } from './__factories__';
|
||||
import executeThunk from '../test-utils';
|
||||
import getBlocksAPIResponse from './__factories__/blocks';
|
||||
import { getBlocksAPIURL } from './api';
|
||||
import { RequestStatus } from './constants';
|
||||
import { fetchCourseBlocks } from './thunks';
|
||||
import fetchCourseBlocks from './thunks';
|
||||
|
||||
const blocksAPIURL = getBlocksAPIURL();
|
||||
const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
|
||||
import { selectDiscussionProvider, selectGroupAtSubsection } from '../discussions/data/selectors';
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable no-param-reassign,import/prefer-default-export */
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
import { RequestStatus } from './constants';
|
||||
@@ -16,19 +15,33 @@ const blocksSlice = createSlice({
|
||||
blocks: {},
|
||||
},
|
||||
reducers: {
|
||||
fetchCourseBlocksRequest: (state) => {
|
||||
state.status = RequestStatus.IN_PROGRESS;
|
||||
},
|
||||
fetchCourseBlocksSuccess: (state, { payload }) => {
|
||||
state.status = RequestStatus.SUCCESSFUL;
|
||||
Object.assign(state, payload);
|
||||
},
|
||||
fetchCourseBlocksFailed: (state) => {
|
||||
state.status = RequestStatus.FAILED;
|
||||
},
|
||||
fetchCourseBlocksDenied: (state) => {
|
||||
state.status = RequestStatus.DENIED;
|
||||
},
|
||||
fetchCourseBlocksRequest: (state) => (
|
||||
{
|
||||
...state,
|
||||
status: RequestStatus.IN_PROGRESS,
|
||||
}
|
||||
),
|
||||
fetchCourseBlocksSuccess: (state, { payload }) => (
|
||||
{
|
||||
...state,
|
||||
status: RequestStatus.SUCCESSFUL,
|
||||
topics: payload.topics,
|
||||
chapters: payload.chapters,
|
||||
blocks: payload.blocks,
|
||||
}
|
||||
),
|
||||
fetchCourseBlocksFailed: (state) => (
|
||||
{
|
||||
...state,
|
||||
status: RequestStatus.FAILED,
|
||||
}
|
||||
),
|
||||
fetchCourseBlocksDenied: (state) => (
|
||||
{
|
||||
...state,
|
||||
status: RequestStatus.DENIED,
|
||||
}
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable import/prefer-default-export, no-unused-expressions */
|
||||
import { camelCaseObject } from '@edx/frontend-platform';
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
|
||||
@@ -88,7 +87,7 @@ function normaliseCourseBlocks({
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchCourseBlocks(courseId, username) {
|
||||
export default function fetchCourseBlocks(courseId, username) {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
dispatch(fetchCourseBlocksRequest({ courseId }));
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import { ensureConfig, getConfig, snakeCaseObject } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
|
||||
@@ -5,9 +5,9 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { initializeMockApp } from '@edx/frontend-platform/testing';
|
||||
|
||||
import { initializeStore } from '../../../store';
|
||||
import { executeThunk } from '../../../test-utils';
|
||||
import executeThunk from '../../../test-utils';
|
||||
import { getCohortsApiUrl } from './api';
|
||||
import { fetchCourseCohorts } from './thunks';
|
||||
import fetchCourseCohorts from './thunks';
|
||||
|
||||
import './__factories__';
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
const selectCourseCohorts = state => state.cohorts.cohorts;
|
||||
|
||||
export const selectCourseCohorts = state => state.cohorts.cohorts;
|
||||
export default selectCourseCohorts;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable no-param-reassign,import/prefer-default-export */
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
import { RequestStatus } from '../../../data/constants';
|
||||
@@ -10,17 +9,26 @@ const cohortsSlice = createSlice({
|
||||
cohorts: [],
|
||||
},
|
||||
reducers: {
|
||||
fetchCohortsRequest: (state) => {
|
||||
state.status = RequestStatus.IN_PROGRESS;
|
||||
state.cohorts = [];
|
||||
},
|
||||
fetchCohortsSuccess: (state, { payload }) => {
|
||||
state.status = RequestStatus.SUCCESSFUL;
|
||||
state.cohorts = payload;
|
||||
},
|
||||
fetchCohortsFailed: (state) => {
|
||||
state.status = RequestStatus.FAILED;
|
||||
},
|
||||
fetchCohortsRequest: (state) => (
|
||||
{
|
||||
...state,
|
||||
status: RequestStatus.IN_PROGRESS,
|
||||
cohorts: [],
|
||||
}
|
||||
),
|
||||
fetchCohortsSuccess: (state, { payload }) => (
|
||||
{
|
||||
...state,
|
||||
status: RequestStatus.SUCCESSFUL,
|
||||
cohorts: payload,
|
||||
}
|
||||
),
|
||||
fetchCohortsFailed: (state) => (
|
||||
{
|
||||
...state,
|
||||
status: RequestStatus.FAILED,
|
||||
}
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import { camelCaseObject } from '@edx/frontend-platform';
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
|
||||
@@ -11,7 +10,7 @@ import {
|
||||
fetchCohortsSuccess,
|
||||
} from './slices';
|
||||
|
||||
export function fetchCourseCohorts(courseId) {
|
||||
export default function fetchCourseCohorts(courseId) {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
dispatch(fetchCohortsRequest());
|
||||
|
||||
@@ -3,14 +3,14 @@ import React, {
|
||||
} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import {
|
||||
Button, Dropdown, Icon, IconButton, ModalPopup, useToggle,
|
||||
} from '@openedx/paragon';
|
||||
import { MoreHoriz } from '@openedx/paragon/icons';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
import {
|
||||
Button, Dropdown, Icon, IconButton, ModalPopup, useToggle,
|
||||
} from '@edx/paragon';
|
||||
import { MoreHoriz } from '@edx/paragon/icons';
|
||||
|
||||
import { ContentActions } from '../../data/constants';
|
||||
import { selectIsPostingEnabled } from '../data/selectors';
|
||||
@@ -48,7 +48,8 @@ const ActionsDropdown = ({
|
||||
}
|
||||
}, [actions, isPostingEnabled]);
|
||||
|
||||
const onClickButton = useCallback(() => {
|
||||
const onClickButton = useCallback((event) => {
|
||||
event.preventDefault();
|
||||
setTarget(buttonRef.current);
|
||||
open();
|
||||
}, [open]);
|
||||
@@ -78,7 +79,7 @@ const ActionsDropdown = ({
|
||||
placement="bottom-end"
|
||||
>
|
||||
<div
|
||||
className="bg-white shadow d-flex flex-column"
|
||||
className="bg-white shadow d-flex flex-column mt-1"
|
||||
data-testid="actions-dropdown-modal-popup"
|
||||
>
|
||||
{actions.map(action => (
|
||||
@@ -93,12 +94,13 @@ const ActionsDropdown = ({
|
||||
handleActions(action.action);
|
||||
}}
|
||||
className="d-flex justify-content-start actions-dropdown-item"
|
||||
data-testId={action.id}
|
||||
>
|
||||
<Icon
|
||||
src={action.icon}
|
||||
className="icon-size-24"
|
||||
/>
|
||||
<span className="font-weight-normal font-xl ml-2">
|
||||
<span className="font-weight-normal ml-2">
|
||||
{intl.formatMessage(action.label)}
|
||||
</span>
|
||||
</Dropdown.Item>
|
||||
|
||||
@@ -12,13 +12,13 @@ import { AppProvider } from '@edx/frontend-platform/react';
|
||||
|
||||
import { ContentActions } from '../../data/constants';
|
||||
import { initializeStore } from '../../store';
|
||||
import { executeThunk } from '../../test-utils';
|
||||
import executeThunk from '../../test-utils';
|
||||
import { getCourseConfigApiUrl } from '../data/api';
|
||||
import { fetchCourseConfig } from '../data/thunks';
|
||||
import fetchCourseConfig from '../data/thunks';
|
||||
import messages from '../messages';
|
||||
import { getCommentsApiUrl } from '../post-comments/data/api';
|
||||
import { addComment, fetchThreadComments } from '../post-comments/data/thunks';
|
||||
import { PostCommentsContext } from '../post-comments/postCommentsContext';
|
||||
import PostCommentsContext from '../post-comments/postCommentsContext';
|
||||
import { getThreadsApiUrl } from '../posts/data/api';
|
||||
import { fetchThread } from '../posts/data/thunks';
|
||||
import { ACTIONS_LIST } from '../utils';
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Alert } from '@openedx/paragon';
|
||||
import { Report } from '@openedx/paragon/icons';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Alert } from '@edx/paragon';
|
||||
import { Report } from '@edx/paragon/icons';
|
||||
|
||||
import { AvatarOutlineAndLabelColors } from '../../data/constants';
|
||||
import {
|
||||
selectModerationSettings, selectUserHasModerationPrivileges, selectUserIsGroupTa, selectUserIsStaff,
|
||||
selectUserHasModerationPrivileges, selectUserIsGroupTa, selectUserIsStaff,
|
||||
} from '../data/selectors';
|
||||
import messages from '../post-comments/messages';
|
||||
import AlertBar from './AlertBar';
|
||||
@@ -29,7 +29,6 @@ const AlertBanner = ({
|
||||
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
|
||||
const userIsGroupTa = useSelector(selectUserIsGroupTa);
|
||||
const userIsGlobalStaff = useSelector(selectUserIsStaff);
|
||||
const { reasonCodesEnabled } = useSelector(selectModerationSettings);
|
||||
const userIsContentAuthor = getAuthenticatedUser().username === author;
|
||||
const canSeeReportedBanner = abuseFlagged;
|
||||
const canSeeLastEditOrClosedAlert = (userHasModerationPrivileges || userIsGroupTa
|
||||
@@ -45,7 +44,7 @@ const AlertBanner = ({
|
||||
{intl.formatMessage(messages.abuseFlaggedMessage)}
|
||||
</Alert>
|
||||
)}
|
||||
{reasonCodesEnabled && canSeeLastEditOrClosedAlert && (
|
||||
{ canSeeLastEditOrClosedAlert && (
|
||||
<>
|
||||
{lastEdit?.reason && (
|
||||
<AlertBar
|
||||
|
||||
@@ -9,7 +9,7 @@ import { ThreadType } from '../../data/constants';
|
||||
import { initializeStore } from '../../store';
|
||||
import messages from '../post-comments/messages';
|
||||
import AlertBanner from './AlertBanner';
|
||||
import { DiscussionContext } from './context';
|
||||
import DiscussionContext from './context';
|
||||
|
||||
import '../post-comments/data/__factories__';
|
||||
import '../posts/data/__factories__';
|
||||
@@ -90,7 +90,6 @@ describe.each([
|
||||
store = initializeStore({
|
||||
config: {
|
||||
hasModerationPrivileges: true,
|
||||
reasonCodesEnabled: true,
|
||||
},
|
||||
});
|
||||
const content = buildTestContent(type, props);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Alert } from '@openedx/paragon';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Alert } from '@edx/paragon';
|
||||
|
||||
import messages from '../post-comments/messages';
|
||||
import AuthorLabel from './AuthorLabel';
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
import React, { useContext, useMemo } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Icon, OverlayTrigger, Tooltip } from '@openedx/paragon';
|
||||
import classNames from 'classnames';
|
||||
import { generatePath } from 'react-router';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { generatePath, Link } from 'react-router-dom';
|
||||
import * as timeago from 'timeago.js';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Icon, OverlayTrigger, Tooltip } 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 { DiscussionContext } from './context';
|
||||
import { getAuthorLabel } from '../utils';
|
||||
import DiscussionContext from './context';
|
||||
import timeLocale from './time-locale';
|
||||
|
||||
const AuthorLabel = ({
|
||||
@@ -28,30 +26,19 @@ const AuthorLabel = ({
|
||||
}) => {
|
||||
timeago.register('time-locale', timeLocale);
|
||||
const intl = useIntl();
|
||||
const { courseId } = useContext(DiscussionContext);
|
||||
let icon = null;
|
||||
let authorLabelMessage = null;
|
||||
|
||||
if (authorLabel === 'Staff') {
|
||||
icon = Institution;
|
||||
authorLabelMessage = intl.formatMessage(messages.authorLabelStaff);
|
||||
}
|
||||
|
||||
if (authorLabel === 'Community TA') {
|
||||
icon = School;
|
||||
authorLabelMessage = intl.formatMessage(messages.authorLabelTA);
|
||||
}
|
||||
const { courseId, enableInContextSidebar } = useContext(DiscussionContext);
|
||||
const { icon, authorLabelMessage } = useMemo(() => getAuthorLabel(intl, authorLabel), [authorLabel]);
|
||||
|
||||
const isRetiredUser = author ? author.startsWith('retired__user') : false;
|
||||
const showTextPrimary = !authorLabelMessage && !isRetiredUser && !alert;
|
||||
const className = classNames('d-flex align-items-center', { 'mb-0.5': !postOrComment }, labelColor);
|
||||
|
||||
const showUserNameAsLink = useShowLearnersTab()
|
||||
&& linkToProfile && author && author !== intl.formatMessage(messages.anonymous);
|
||||
const showUserNameAsLink = linkToProfile && author && author !== intl.formatMessage(messages.anonymous)
|
||||
&& !enableInContextSidebar;
|
||||
|
||||
const authorName = useMemo(() => (
|
||||
<span
|
||||
className={classNames('mr-1.5 font-size-14 font-style font-weight-500', {
|
||||
className={classNames('mr-1.5 font-style font-weight-500 author-name', {
|
||||
'text-gray-700': isRetiredUser,
|
||||
'text-primary-500': !authorLabelMessage && !isRetiredUser,
|
||||
})}
|
||||
@@ -65,17 +52,15 @@ const AuthorLabel = ({
|
||||
const labelContents = useMemo(() => (
|
||||
<>
|
||||
<OverlayTrigger
|
||||
placement={authorToolTip ? 'top' : 'right'}
|
||||
overlay={(
|
||||
<Tooltip id={`endorsed-by-${author}-tooltip`}>
|
||||
{author}
|
||||
<Tooltip id={authorToolTip ? `endorsed-by-${author}-tooltip` : `${authorLabel}-label-tooltip`}>
|
||||
{authorToolTip ? author : authorLabel}
|
||||
</Tooltip>
|
||||
)}
|
||||
trigger={['hover', 'focus']}
|
||||
>
|
||||
<div className={classNames('d-flex flex-row align-items-center', {
|
||||
'disable-div': !authorToolTip,
|
||||
})}
|
||||
>
|
||||
<div className={classNames('d-flex flex-row align-items-center')}>
|
||||
<Icon
|
||||
style={{
|
||||
width: '1rem',
|
||||
@@ -86,7 +71,7 @@ const AuthorLabel = ({
|
||||
/>
|
||||
{authorLabelMessage && (
|
||||
<span
|
||||
className={classNames('mr-1.5 font-size-14 font-style font-weight-500', {
|
||||
className={classNames('mr-1.5 font-style font-weight-500', {
|
||||
'text-primary-500': showTextPrimary,
|
||||
'text-gray-700': isRetiredUser,
|
||||
})}
|
||||
@@ -100,7 +85,7 @@ const AuthorLabel = ({
|
||||
{postCreatedAt && (
|
||||
<span
|
||||
title={postCreatedAt}
|
||||
className={classNames('align-content-center', {
|
||||
className={classNames('align-content-center post-summary-timestamp', {
|
||||
'text-white': alert,
|
||||
'text-gray-500': !alert,
|
||||
})}
|
||||
@@ -114,12 +99,12 @@ const AuthorLabel = ({
|
||||
|
||||
return showUserNameAsLink
|
||||
? (
|
||||
<div className={className}>
|
||||
<div className={`${className} flex-wrap`}>
|
||||
<Link
|
||||
data-testid="learner-posts-link"
|
||||
id="learner-posts-link"
|
||||
to={generatePath(Routes.LEARNERS.POSTS, { learnerUsername: author, courseId })}
|
||||
className="text-decoration-none"
|
||||
className="text-decoration-none text-reset"
|
||||
style={{ width: 'fit-content' }}
|
||||
>
|
||||
{!alert && authorName}
|
||||
@@ -127,7 +112,7 @@ const AuthorLabel = ({
|
||||
{labelContents}
|
||||
</div>
|
||||
)
|
||||
: <div className={className}>{authorName}{labelContents}</div>;
|
||||
: <div className={`${className} flex-wrap`}>{authorName}{labelContents}</div>;
|
||||
};
|
||||
|
||||
AuthorLabel.propTypes = {
|
||||
|
||||
@@ -9,11 +9,11 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
|
||||
import { initializeStore } from '../../store';
|
||||
import { executeThunk } from '../../test-utils';
|
||||
import executeThunk from '../../test-utils';
|
||||
import { getCourseConfigApiUrl } from '../data/api';
|
||||
import { fetchCourseConfig } from '../data/thunks';
|
||||
import fetchCourseConfig from '../data/thunks';
|
||||
import AuthorLabel from './AuthorLabel';
|
||||
import { DiscussionContext } from './context';
|
||||
import DiscussionContext from './context';
|
||||
|
||||
const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||
const courseConfigApiUrl = getCourseConfigApiUrl();
|
||||
@@ -21,11 +21,11 @@ let store;
|
||||
let axiosMock;
|
||||
let container;
|
||||
|
||||
function renderComponent(author, authorLabel, linkToProfile, labelColor) {
|
||||
function renderComponent(author, authorLabel, linkToProfile, labelColor, enableInContextSidebar) {
|
||||
const wrapper = render(
|
||||
<IntlProvider locale="en">
|
||||
<AppProvider store={store}>
|
||||
<DiscussionContext.Provider value={{ courseId }}>
|
||||
<DiscussionContext.Provider value={{ courseId, enableInContextSidebar }}>
|
||||
<AuthorLabel
|
||||
author={author}
|
||||
authorLabel={authorLabel}
|
||||
@@ -53,7 +53,6 @@ describe('Author label', () => {
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`).reply(200, {
|
||||
learners_tab_enabled: true,
|
||||
has_moderation_privileges: true,
|
||||
});
|
||||
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/settings`).reply(200, {});
|
||||
@@ -63,6 +62,7 @@ describe('Author label', () => {
|
||||
describe.each([
|
||||
['anonymous', null, false, ''],
|
||||
['ta_user', 'Community TA', true, 'text-TA-color'],
|
||||
['moderator_user', 'Moderator', true, 'text-TA-color'],
|
||||
['retired__user', null, false, ''],
|
||||
['staff_user', 'Staff', true, 'text-staff-color'],
|
||||
['learner_user', null, false, ''],
|
||||
@@ -79,9 +79,9 @@ describe('Author label', () => {
|
||||
);
|
||||
|
||||
it(
|
||||
`it is "${!linkToProfile && 'not'}" clickable when linkToProfile is ${!!linkToProfile}`,
|
||||
`it is "${(!linkToProfile) && 'not'}" clickable when linkToProfile is ${!!linkToProfile} and enableInContextSidebar is false`,
|
||||
async () => {
|
||||
renderComponent(author, authorLabel, linkToProfile, labelColor);
|
||||
renderComponent(author, authorLabel, linkToProfile, labelColor, false);
|
||||
|
||||
if (linkToProfile) {
|
||||
expect(screen.queryByTestId('learner-posts-link')).toBeInTheDocument();
|
||||
@@ -91,6 +91,15 @@ describe('Author label', () => {
|
||||
},
|
||||
);
|
||||
|
||||
it(
|
||||
'it is not clickable when enableInContextSidebar is true',
|
||||
async () => {
|
||||
renderComponent(author, authorLabel, linkToProfile, labelColor, true);
|
||||
|
||||
expect(screen.queryByTestId('learner-posts-link')).not.toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
|
||||
it(
|
||||
`it has "${!linkToProfile && 'not'}" label text and label color when linkToProfile is ${!!linkToProfile}`,
|
||||
async () => {
|
||||
@@ -98,7 +107,7 @@ describe('Author label', () => {
|
||||
const authorElement = container.querySelector('[role=heading]');
|
||||
const labelParentNode = authorElement.parentNode.parentNode;
|
||||
const labelElement = labelParentNode.lastChild.lastChild;
|
||||
const label = ['TA', 'Staff'].includes(labelElement.textContent) && labelElement.textContent;
|
||||
const label = ['CTA', 'TA', 'Staff'].includes(labelElement.textContent) && labelElement.textContent;
|
||||
|
||||
if (linkToProfile) {
|
||||
expect(labelParentNode).toHaveClass(labelColor);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { ActionRow, Button, ModalDialog } from '@openedx/paragon';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { ActionRow, Button, ModalDialog } from '@edx/paragon';
|
||||
|
||||
import messages from '../messages';
|
||||
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import React, { useContext } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Alert, Icon } from '@openedx/paragon';
|
||||
import { CheckCircle, Verified } from '@openedx/paragon/icons';
|
||||
import * as timeago from 'timeago.js';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Alert, Icon } from '@edx/paragon';
|
||||
import { CheckCircle, Verified } from '@edx/paragon/icons';
|
||||
|
||||
import { ThreadType } from '../../data/constants';
|
||||
import messages from '../post-comments/messages';
|
||||
import { PostCommentsContext } from '../post-comments/postCommentsContext';
|
||||
import PostCommentsContext from '../post-comments/postCommentsContext';
|
||||
import AuthorLabel from './AuthorLabel';
|
||||
import timeLocale from './time-locale';
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@ import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { ThreadType } from '../../data/constants';
|
||||
import { initializeStore } from '../../store';
|
||||
import messages from '../post-comments/messages';
|
||||
import { PostCommentsContext } from '../post-comments/postCommentsContext';
|
||||
import { DiscussionContext } from './context';
|
||||
import PostCommentsContext from '../post-comments/postCommentsContext';
|
||||
import DiscussionContext from './context';
|
||||
import EndorsedAlertBanner from './EndorsedAlertBanner';
|
||||
|
||||
import '../post-comments/data/__factories__';
|
||||
@@ -84,7 +84,6 @@ describe.each([
|
||||
store = initializeStore({
|
||||
config: {
|
||||
hasModerationPrivileges: true,
|
||||
reasonCodesEnabled: true,
|
||||
},
|
||||
});
|
||||
const content = buildTestContent(type, props);
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import React, { useContext } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import {
|
||||
Button, Icon, IconButton, OverlayTrigger, Tooltip,
|
||||
} from '@openedx/paragon';
|
||||
import {
|
||||
StarFilled, StarOutline, ThumbUpFilled, ThumbUpOutline,
|
||||
} from '@openedx/paragon/icons';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Button, Icon, IconButton, OverlayTrigger, Tooltip,
|
||||
} from '@edx/paragon';
|
||||
|
||||
import {
|
||||
StarFilled, StarOutline, ThumbUpFilled, ThumbUpOutline,
|
||||
} from '../../components/icons';
|
||||
import { useUserPostingEnabled } from '../data/hooks';
|
||||
import { PostCommentsContext } from '../post-comments/postCommentsContext';
|
||||
import { ThreadType } from '../../data/constants';
|
||||
import { useHasLikePermission, useUserPostingEnabled } from '../data/hooks';
|
||||
import PostCommentsContext from '../post-comments/postCommentsContext';
|
||||
import ActionsDropdown from './ActionsDropdown';
|
||||
import { DiscussionContext } from './context';
|
||||
import DiscussionContext from './context';
|
||||
|
||||
const HoverCard = ({
|
||||
id,
|
||||
@@ -32,10 +33,11 @@ const HoverCard = ({
|
||||
const { enableInContextSidebar } = useContext(DiscussionContext);
|
||||
const { isClosed } = useContext(PostCommentsContext);
|
||||
const isUserPrivilegedInPostingRestriction = useUserPostingEnabled();
|
||||
const userHasLikePermission = useHasLikePermission(contentType, id);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex-fill justify-content-end align-items-center hover-card mr-n4 position-absolute"
|
||||
className="flex-fill justify-content-end align-items-center hover-card bg-white mr-n4 position-absolute"
|
||||
data-testid={`hover-card-${id}`}
|
||||
id={`hover-card-${id}`}
|
||||
>
|
||||
@@ -44,7 +46,7 @@ const HoverCard = ({
|
||||
<Button
|
||||
variant="tertiary"
|
||||
className={classNames(
|
||||
'px-2.5 py-2 border-0 font-style text-gray-700 font-size-12',
|
||||
'px-2.5 py-2 border-0 font-style text-gray-700',
|
||||
{ 'w-100': enableInContextSidebar },
|
||||
)}
|
||||
onClick={() => handleResponseCommentButton()}
|
||||
@@ -85,6 +87,7 @@ const HoverCard = ({
|
||||
iconAs={Icon}
|
||||
size="sm"
|
||||
alt="Like"
|
||||
disabled={!userHasLikePermission}
|
||||
iconClassNames="like-icon-dimensions"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -127,8 +130,22 @@ HoverCard.propTypes = {
|
||||
addResponseCommentButtonMessage: PropTypes.string.isRequired,
|
||||
onLike: PropTypes.func.isRequired,
|
||||
voted: PropTypes.bool.isRequired,
|
||||
// eslint-disable-next-line react/forbid-prop-types
|
||||
endorseIcons: PropTypes.objectOf(PropTypes.any),
|
||||
endorseIcons: PropTypes.objectOf(PropTypes.shape(
|
||||
{
|
||||
id: PropTypes.string,
|
||||
action: PropTypes.string,
|
||||
icon: PropTypes.element,
|
||||
label: {
|
||||
id: PropTypes.string,
|
||||
defaultMessage: PropTypes.string,
|
||||
description: PropTypes.string,
|
||||
},
|
||||
conditions: {
|
||||
endorsed: PropTypes.bool,
|
||||
postType: ThreadType,
|
||||
},
|
||||
},
|
||||
)),
|
||||
onFollow: PropTypes.func,
|
||||
following: PropTypes.bool,
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
} from '@testing-library/react';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { IntlProvider } from 'react-intl';
|
||||
import { MemoryRouter, Route } from 'react-router';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { Factory } from 'rosie';
|
||||
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
@@ -11,15 +11,15 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
|
||||
import { initializeStore } from '../../store';
|
||||
import { executeThunk } from '../../test-utils';
|
||||
import executeThunk from '../../test-utils';
|
||||
import { getCourseConfigApiUrl } from '../data/api';
|
||||
import { fetchCourseConfig } from '../data/thunks';
|
||||
import fetchCourseConfig from '../data/thunks';
|
||||
import DiscussionContent from '../discussions-home/DiscussionContent';
|
||||
import { getCommentsApiUrl } from '../post-comments/data/api';
|
||||
import { fetchCommentResponses } from '../post-comments/data/thunks';
|
||||
import { getThreadsApiUrl } from '../posts/data/api';
|
||||
import { fetchThreads } from '../posts/data/thunks';
|
||||
import { DiscussionContext } from './context';
|
||||
import DiscussionContext from './context';
|
||||
|
||||
import '../posts/data/__factories__';
|
||||
import '../post-comments/data/__factories__';
|
||||
@@ -60,15 +60,12 @@ async function mockAxiosReturnPagedCommentsResponses() {
|
||||
function renderComponent(postId) {
|
||||
const wrapper = render(
|
||||
<IntlProvider locale="en">
|
||||
<AppProvider store={store}>
|
||||
<AppProvider store={store} wrapWithRouter={false}>
|
||||
<DiscussionContext.Provider
|
||||
value={{ courseId, postId }}
|
||||
value={{ courseId, postId, page: 'posts' }}
|
||||
>
|
||||
<MemoryRouter initialEntries={[`/${courseId}/posts/${postId}`]}>
|
||||
<DiscussionContent />
|
||||
<Route
|
||||
path="*"
|
||||
/>
|
||||
</MemoryRouter>
|
||||
</DiscussionContext.Provider>
|
||||
</AppProvider>
|
||||
@@ -109,8 +106,8 @@ describe('HoverCard', () => {
|
||||
});
|
||||
|
||||
test('it should have hover card on post', async () => {
|
||||
await waitFor(() => renderComponent(discussionPostId));
|
||||
const post = screen.getByTestId('post-thread-1');
|
||||
renderComponent(discussionPostId);
|
||||
const post = await screen.findByTestId('post-thread-1');
|
||||
expect(within(post).getByTestId('hover-card-thread-1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import React from 'react';
|
||||
|
||||
export const DiscussionContext = React.createContext({
|
||||
const DiscussionContext = React.createContext({
|
||||
page: null,
|
||||
courseId: null,
|
||||
postId: null,
|
||||
@@ -10,3 +9,5 @@ export const DiscussionContext = React.createContext({
|
||||
category: null,
|
||||
learnerUsername: null,
|
||||
});
|
||||
|
||||
export default DiscussionContext;
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { useEnableInContextSidebar } from '../data/hooks';
|
||||
|
||||
const withConditionalInContextRendering = (WrappedComponent, condition) => (
|
||||
function SidebarConditionalRenderer(props) {
|
||||
const enableInContextSidebar = useEnableInContextSidebar();
|
||||
|
||||
return enableInContextSidebar === condition && <WrappedComponent {...props} />;
|
||||
}
|
||||
);
|
||||
|
||||
export default withConditionalInContextRendering;
|
||||
54
src/discussions/content-unavailable/ContentUnavailable.jsx
Normal file
54
src/discussions/content-unavailable/ContentUnavailable.jsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import propTypes from 'prop-types';
|
||||
|
||||
import { Button } from '@openedx/paragon';
|
||||
import classNames from 'classnames';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import ContentUnavailableIcon from '../../assets/ContentUnavailable';
|
||||
import selectCourseTabs from '../../components/NavigationBar/data/selectors';
|
||||
import { useIsOnTablet, useIsOnXLDesktop } from '../data/hooks';
|
||||
import messages from '../messages';
|
||||
|
||||
const ContentUnavailable = ({ subTitleMessage }) => {
|
||||
const intl = useIntl();
|
||||
const isOnTabletorDesktop = useIsOnTablet();
|
||||
const isOnXLDesktop = useIsOnXLDesktop();
|
||||
const { courseId } = useSelector(selectCourseTabs);
|
||||
|
||||
const redirectToDashboard = useCallback(() => {
|
||||
window.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/about`);
|
||||
}, [courseId]);
|
||||
|
||||
return (
|
||||
<div className="min-content-height justify-content-center align-items-center d-flex w-100 flex-column bg-white">
|
||||
<div className={classNames('d-flex flex-column align-items-center', {
|
||||
'content-unavailable-desktop': isOnTabletorDesktop || isOnXLDesktop,
|
||||
'py-0 px-3': !isOnTabletorDesktop && !isOnXLDesktop,
|
||||
})}
|
||||
>
|
||||
<ContentUnavailableIcon />
|
||||
<h3 className="pt-3 font-weight-bold text-primary-500 text-center">
|
||||
{intl.formatMessage(messages.contentUnavailableTitle)}
|
||||
</h3>
|
||||
<p className="pb-2 text-gray-500 text-center">{intl.formatMessage(subTitleMessage)}</p>
|
||||
<Button onClick={redirectToDashboard} variant="outline-dark" className="py-2 px-2.5">
|
||||
{intl.formatMessage(messages.contentUnavailableAction)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ContentUnavailable.propTypes = {
|
||||
subTitleMessage: propTypes.shape({
|
||||
id: propTypes.string,
|
||||
defaultMessage: propTypes.string,
|
||||
description: propTypes.string,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default React.memo(ContentUnavailable);
|
||||
@@ -1,5 +1,3 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
|
||||
import { ensureConfig, getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
@@ -7,8 +5,10 @@ ensureConfig([
|
||||
'LMS_BASE_URL',
|
||||
], 'Posts API service');
|
||||
|
||||
export const getCourseConfigApiUrl = () => `${getConfig().LMS_BASE_URL}/api/discussion/v1/courses/`;
|
||||
export const getCourseConfigApiUrl = () => `${getConfig().LMS_BASE_URL}/api/discussion/v2/courses/`;
|
||||
export const getCourseSettingsApiUrl = () => `${getConfig().LMS_BASE_URL}/api/discussion/v1/courses/`;
|
||||
export const getDiscussionsConfigUrl = (courseId) => `${getCourseConfigApiUrl()}${courseId}/`;
|
||||
export const getDiscussionsSettingsUrl = (courseId) => `${getCourseSettingsApiUrl()}${courseId}/settings`;
|
||||
/**
|
||||
* Get discussions course config
|
||||
* @param {string} courseId
|
||||
@@ -23,7 +23,7 @@ export async function getDiscussionsConfig(courseId) {
|
||||
* @param {string} courseId
|
||||
*/
|
||||
export async function getDiscussionsSettings(courseId) {
|
||||
const url = `${getDiscussionsConfigUrl(courseId)}settings`;
|
||||
const url = `${getDiscussionsSettingsUrl(courseId)}`;
|
||||
const { data } = await getAuthenticatedHttpClient().get(url);
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -3,42 +3,43 @@ import {
|
||||
useContext, useEffect, useMemo, useRef, useState,
|
||||
} from 'react';
|
||||
|
||||
import { breakpoints, useWindowSize } from '@openedx/paragon';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useHistory, useLocation, useRouteMatch } from 'react-router';
|
||||
import {
|
||||
matchPath, useLocation, useMatch, useNavigate,
|
||||
} from 'react-router-dom';
|
||||
|
||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { AppContext } from '@edx/frontend-platform/react';
|
||||
import { breakpoints, useWindowSize } from '@edx/paragon';
|
||||
|
||||
import {
|
||||
ALL_ROUTES, BASE_PATH, RequestStatus, Routes,
|
||||
} from '../../data/constants';
|
||||
import selectCourseTabs from '../../components/NavigationBar/data/selectors';
|
||||
import { LOADED } from '../../components/NavigationBar/data/slice';
|
||||
import fetchTab from '../../components/NavigationBar/data/thunks';
|
||||
import { ContentActions, RequestStatus, Routes } from '../../data/constants';
|
||||
import { selectTopicsUnderCategory } from '../../data/selectors';
|
||||
import { fetchCourseBlocks } from '../../data/thunks';
|
||||
import { DiscussionContext } from '../common/context';
|
||||
import fetchCourseBlocks from '../../data/thunks';
|
||||
import DiscussionContext from '../common/context';
|
||||
import PostCommentsContext from '../post-comments/postCommentsContext';
|
||||
import { clearRedirect } from '../posts/data';
|
||||
import { threadsLoadingStatus } from '../posts/data/selectors';
|
||||
import { selectTopics } from '../topics/data/selectors';
|
||||
import tourCheckpoints from '../tours/constants';
|
||||
import { selectTours } from '../tours/data/selectors';
|
||||
import { fetchDiscussionTours, updateTourShowStatus } from '../tours/data/thunks';
|
||||
import selectTours from '../tours/data/selectors';
|
||||
import { updateTourShowStatus } from '../tours/data/thunks';
|
||||
import messages from '../tours/messages';
|
||||
import { discussionsPath } from '../utils';
|
||||
import { checkPermissions, discussionsPath } from '../utils';
|
||||
import { ContentSelectors } from './constants';
|
||||
import {
|
||||
selectAreThreadsFiltered,
|
||||
selectEnableInContext,
|
||||
selectIsCourseAdmin,
|
||||
selectIsCourseStaff,
|
||||
selectIsPostingEnabled,
|
||||
selectLearnersTabEnabled,
|
||||
selectModerationSettings,
|
||||
selectIsUserLearner,
|
||||
selectPostThreadCount,
|
||||
selectUserHasModerationPrivileges,
|
||||
selectUserIsGroupTa,
|
||||
selectUserIsStaff,
|
||||
} from './selectors';
|
||||
import { fetchCourseConfig } from './thunks';
|
||||
import fetchCourseConfig from './thunks';
|
||||
|
||||
export function useTotalTopicThreadCount() {
|
||||
const topics = useSelector(selectTopics);
|
||||
@@ -55,30 +56,91 @@ export function useTotalTopicThreadCount() {
|
||||
}
|
||||
|
||||
export const useSidebarVisible = () => {
|
||||
const location = useLocation();
|
||||
const enableInContext = useSelector(selectEnableInContext);
|
||||
const isViewingTopics = useRouteMatch(Routes.TOPICS.ALL);
|
||||
const isViewingLearners = useRouteMatch(Routes.LEARNERS.PATH);
|
||||
const isViewingTopics = useMatch(Routes.TOPICS.ALL);
|
||||
const isViewingLearners = useMatch(`${Routes.LEARNERS.PATH}/*`);
|
||||
const isFiltered = useSelector(selectAreThreadsFiltered);
|
||||
const totalThreads = useSelector(selectPostThreadCount);
|
||||
const isThreadsEmpty = Boolean(useSelector(threadsLoadingStatus()) === RequestStatus.SUCCESSFUL && !totalThreads);
|
||||
const isIncontextTopicsView = Boolean(useRouteMatch(Routes.TOPICS.PATH) && enableInContext);
|
||||
const hideSidebar = Boolean(isThreadsEmpty && !isFiltered && !(isViewingTopics?.isExact || isViewingLearners));
|
||||
const matchInContextTopicView = Routes.TOPICS.PATH.find((route) => matchPath({ path: `${route}/*` }, location.pathname));
|
||||
const isInContextTopicsView = Boolean(matchInContextTopicView && enableInContext);
|
||||
const hideSidebar = Boolean(isThreadsEmpty && !isFiltered && !(isViewingTopics || isViewingLearners));
|
||||
|
||||
if (isIncontextTopicsView) {
|
||||
if (isInContextTopicsView) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !hideSidebar;
|
||||
};
|
||||
|
||||
export function useCourseDiscussionData(courseId) {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchBaseData() {
|
||||
await dispatch(fetchCourseConfig(courseId));
|
||||
await dispatch(fetchTab(courseId));
|
||||
}
|
||||
|
||||
fetchBaseData();
|
||||
}, [courseId]);
|
||||
}
|
||||
|
||||
export function useCourseBlockData(courseId) {
|
||||
const dispatch = useDispatch();
|
||||
const { authenticatedUser } = useContext(AppContext);
|
||||
const { isEnrolled, courseStatus } = useSelector(selectCourseTabs);
|
||||
const isUserLearner = useSelector(selectIsUserLearner);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchBaseData() {
|
||||
if (courseStatus === LOADED && (!isUserLearner || isEnrolled)) {
|
||||
await dispatch(fetchCourseBlocks(courseId, authenticatedUser.username));
|
||||
}
|
||||
}
|
||||
|
||||
fetchBaseData();
|
||||
}, [courseId, isEnrolled, courseStatus, isUserLearner]);
|
||||
}
|
||||
|
||||
export function useRedirectToThread(courseId, enableInContextSidebar) {
|
||||
const dispatch = useDispatch();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const redirectToThread = useSelector(
|
||||
(state) => state.threads.redirectToThread,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// After posting a new thread we'd like to redirect users to it, the topic and post id are temporarily
|
||||
// stored in redirectToThread
|
||||
if (redirectToThread) {
|
||||
dispatch(clearRedirect());
|
||||
const newLocation = discussionsPath(Routes.COMMENTS.PAGES[enableInContextSidebar ? 'topics' : 'my-posts'], {
|
||||
courseId,
|
||||
postId: redirectToThread.threadId,
|
||||
topicId: redirectToThread.topicId,
|
||||
})(location);
|
||||
navigate({ ...newLocation });
|
||||
}
|
||||
}, [redirectToThread]);
|
||||
}
|
||||
|
||||
export function useIsOnDesktop() {
|
||||
const windowSize = useWindowSize();
|
||||
return windowSize.width >= breakpoints.medium.minWidth;
|
||||
return windowSize.width >= breakpoints.medium.maxWidth;
|
||||
}
|
||||
|
||||
export function useIsOnTablet() {
|
||||
const windowSize = useWindowSize();
|
||||
return windowSize.width >= breakpoints.small.maxWidth;
|
||||
}
|
||||
|
||||
export function useIsOnXLDesktop() {
|
||||
const windowSize = useWindowSize();
|
||||
return windowSize.width >= breakpoints.extraLarge.minWidth;
|
||||
return windowSize.width >= breakpoints.extraLarge.maxWidth;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -123,18 +185,15 @@ export const useAlertBannerVisible = (
|
||||
) => {
|
||||
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
|
||||
const userIsGroupTa = useSelector(selectUserIsGroupTa);
|
||||
const { reasonCodesEnabled } = useSelector(selectModerationSettings);
|
||||
const userIsContentAuthor = getAuthenticatedUser().username === author;
|
||||
const canSeeLastEditOrClosedAlert = (userHasModerationPrivileges || userIsContentAuthor || userIsGroupTa);
|
||||
const canSeeReportedBanner = abuseFlagged;
|
||||
|
||||
return (
|
||||
(reasonCodesEnabled && canSeeLastEditOrClosedAlert && (lastEdit?.reason || closed)) || (canSeeReportedBanner)
|
||||
(canSeeLastEditOrClosedAlert && (lastEdit?.reason || closed)) || (canSeeReportedBanner)
|
||||
);
|
||||
};
|
||||
|
||||
export const useShowLearnersTab = () => useSelector(selectLearnersTabEnabled);
|
||||
|
||||
/**
|
||||
* React hook that gets the current topic ID from the current topic or category.
|
||||
* The topicId in the DiscussionContext only return the direct topicId from the URL.
|
||||
@@ -158,12 +217,9 @@ export const useCurrentDiscussionTopic = () => {
|
||||
|
||||
export const useUserPostingEnabled = () => {
|
||||
const isPostingEnabled = useSelector(selectIsPostingEnabled);
|
||||
const isUserAdmin = useSelector(selectUserIsStaff);
|
||||
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
|
||||
const isUserGroupTA = useSelector(selectUserIsGroupTa);
|
||||
const isCourseAdmin = useSelector(selectIsCourseAdmin);
|
||||
const isCourseStaff = useSelector(selectIsCourseStaff);
|
||||
const isPrivileged = isUserAdmin || userHasModerationPrivileges || isUserGroupTA || isCourseAdmin || isCourseStaff;
|
||||
const isPrivileged = userHasModerationPrivileges || isUserGroupTA;
|
||||
|
||||
return (isPostingEnabled || isPrivileged);
|
||||
};
|
||||
@@ -225,88 +281,9 @@ export const useDebounce = (value, delay) => {
|
||||
return debouncedValue;
|
||||
};
|
||||
|
||||
export const useEnableInContextSidebar = () => {
|
||||
const location = useLocation();
|
||||
export const useHasLikePermission = (contentType, id) => {
|
||||
const { postType } = useContext(PostCommentsContext);
|
||||
const content = { ...useSelector(ContentSelectors[contentType](id)), postType };
|
||||
|
||||
return Boolean(new URLSearchParams(location.search).get('inContextSidebar') !== null);
|
||||
return checkPermissions(content, ContentActions.VOTE);
|
||||
};
|
||||
|
||||
export const useCourseId = () => {
|
||||
const { params: { courseId } } = useRouteMatch(BASE_PATH);
|
||||
|
||||
return courseId;
|
||||
};
|
||||
|
||||
export const useCurrentPage = () => {
|
||||
const { params: { page } } = useRouteMatch(`${Routes.COMMENTS.PAGE}?`);
|
||||
|
||||
return page;
|
||||
};
|
||||
|
||||
export const usePostId = () => {
|
||||
const { params: { postId } } = useRouteMatch(ALL_ROUTES);
|
||||
|
||||
return postId;
|
||||
};
|
||||
|
||||
export const useLearnerUsername = () => {
|
||||
const { params: { learnerUsername } } = useRouteMatch(ALL_ROUTES);
|
||||
|
||||
return learnerUsername;
|
||||
};
|
||||
|
||||
export const useTopicId = () => {
|
||||
const { params: { topicId } } = useRouteMatch(ALL_ROUTES);
|
||||
|
||||
return topicId;
|
||||
};
|
||||
|
||||
export const useCategory = () => {
|
||||
const { params: { category } } = useRouteMatch(ALL_ROUTES);
|
||||
|
||||
return category;
|
||||
};
|
||||
|
||||
export function useRedirectToThread() {
|
||||
const dispatch = useDispatch();
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
const courseId = useCourseId();
|
||||
const enableInContextSidebar = useEnableInContextSidebar();
|
||||
|
||||
const redirectToThread = useSelector(
|
||||
(state) => state.threads.redirectToThread,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// After posting a new thread we'd like to redirect users to it, the topic and post id are temporarily
|
||||
// stored in redirectToThread
|
||||
if (redirectToThread) {
|
||||
dispatch(clearRedirect());
|
||||
const newLocation = discussionsPath(Routes.COMMENTS.PAGES[enableInContextSidebar ? 'topics' : 'my-posts'], {
|
||||
courseId,
|
||||
postId: redirectToThread.threadId,
|
||||
topicId: redirectToThread.topicId,
|
||||
})(location);
|
||||
history.push(newLocation);
|
||||
}
|
||||
}, [redirectToThread]);
|
||||
}
|
||||
|
||||
export function useCourseDiscussionData() {
|
||||
const dispatch = useDispatch();
|
||||
const courseId = useCourseId();
|
||||
const { authenticatedUser } = useContext(AppContext);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchBaseData() {
|
||||
await Promise.all([
|
||||
dispatch(fetchCourseConfig(courseId)),
|
||||
dispatch(fetchCourseBlocks(courseId, authenticatedUser.username)),
|
||||
dispatch(fetchDiscussionTours()),
|
||||
]);
|
||||
}
|
||||
|
||||
fetchBaseData();
|
||||
}, [courseId]);
|
||||
}
|
||||
|
||||
@@ -8,22 +8,22 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
|
||||
import { initializeStore } from '../../store';
|
||||
import { executeThunk } from '../../test-utils';
|
||||
import { DiscussionContext } from '../common/context';
|
||||
import executeThunk from '../../test-utils';
|
||||
import DiscussionContext from '../common/context';
|
||||
import { getCourseConfigApiUrl } from './api';
|
||||
import { useCurrentDiscussionTopic, useUserPostingEnabled } from './hooks';
|
||||
import { fetchCourseConfig } from './thunks';
|
||||
import fetchCourseConfig from './thunks';
|
||||
|
||||
const courseId = 'course-v1:edX+TestX+Test_Course';
|
||||
const courseConfigApiUrl = getCourseConfigApiUrl();
|
||||
let store;
|
||||
let axiosMock;
|
||||
|
||||
const generateApiResponse = (isPostingEnabled, isCourseAdmin = false) => ({
|
||||
const generateApiResponse = (isPostingEnabled, hasModerationPrivileges = false) => ({
|
||||
isPostingEnabled,
|
||||
hasModerationPrivileges: false,
|
||||
hasModerationPrivileges,
|
||||
isGroupTa: false,
|
||||
isCourseAdmin,
|
||||
isCourseAdmin: false,
|
||||
isCourseStaff: false,
|
||||
isUserAdmin: false,
|
||||
});
|
||||
@@ -160,7 +160,7 @@ describe('Hooks', () => {
|
||||
expect(queryByText('false')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('when posting is not disabled and Role is not Learner return true', async () => {
|
||||
test('when posting is disabled and Role is not Learner return true', async () => {
|
||||
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`)
|
||||
.reply(200, generateApiResponse(false, true));
|
||||
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
|
||||
import selectCourseTabs from '../../components/NavigationBar/data/selectors';
|
||||
import { PostsStatusFilter, ThreadType } from '../../data/constants';
|
||||
import { isCourseStatusValid } from '../utils';
|
||||
|
||||
export const selectAnonymousPostingConfig = state => ({
|
||||
allowAnonymous: state.config.allowAnonymous,
|
||||
@@ -14,8 +17,6 @@ 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;
|
||||
@@ -33,7 +34,6 @@ export const selectIsPostingEnabled = state => state.config.isPostingEnabled;
|
||||
export const selectModerationSettings = state => ({
|
||||
postCloseReasons: state.config.postCloseReasons,
|
||||
editReasons: state.config.editReasons,
|
||||
reasonCodesEnabled: state.config.reasonCodesEnabled,
|
||||
});
|
||||
|
||||
export const selectDiscussionProvider = state => state.config.provider;
|
||||
@@ -64,3 +64,29 @@ export function selectTopicThreadCount(topicId) {
|
||||
export function selectPostThreadCount(state) {
|
||||
return state.threads.totalThreads;
|
||||
}
|
||||
|
||||
export const selectIsUserLearner = createSelector(
|
||||
selectUserHasModerationPrivileges,
|
||||
selectUserIsGroupTa,
|
||||
selectUserIsStaff,
|
||||
selectIsCourseAdmin,
|
||||
selectIsCourseStaff,
|
||||
selectCourseTabs,
|
||||
(
|
||||
userHasModerationPrivileges,
|
||||
userIsGroupTa,
|
||||
userIsStaff,
|
||||
userIsCourseAdmin,
|
||||
userIsCourseStaff,
|
||||
{ courseStatus },
|
||||
) => (
|
||||
(
|
||||
!userHasModerationPrivileges
|
||||
&& !userIsGroupTa
|
||||
&& !userIsStaff
|
||||
&& !userIsCourseAdmin
|
||||
&& !userIsCourseStaff
|
||||
&& isCourseStatusValid(courseStatus)
|
||||
) || false
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable no-param-reassign,import/prefer-default-export */
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
@@ -16,7 +15,6 @@ const configSlice = createSlice({
|
||||
isCourseAdmin: false,
|
||||
isCourseStaff: false,
|
||||
isUserAdmin: false,
|
||||
learnersTabEnabled: false,
|
||||
isPostingEnabled: false,
|
||||
settings: {
|
||||
divisionScheme: 'none',
|
||||
@@ -24,25 +22,34 @@ const configSlice = createSlice({
|
||||
dividedInlineDiscussions: [],
|
||||
dividedCourseWideDiscussions: [],
|
||||
},
|
||||
reasonCodesEnabled: false,
|
||||
editReasons: [],
|
||||
postCloseReasons: [],
|
||||
enableInContext: false,
|
||||
},
|
||||
reducers: {
|
||||
fetchConfigRequest: (state) => {
|
||||
state.status = RequestStatus.IN_PROGRESS;
|
||||
},
|
||||
fetchConfigRequest: (state) => (
|
||||
{
|
||||
...state,
|
||||
status: RequestStatus.IN_PROGRESS,
|
||||
}
|
||||
),
|
||||
fetchConfigSuccess: (state, { payload }) => {
|
||||
state.status = RequestStatus.SUCCESSFUL;
|
||||
Object.assign(state, payload);
|
||||
},
|
||||
fetchConfigFailed: (state) => {
|
||||
state.status = RequestStatus.FAILED;
|
||||
},
|
||||
fetchConfigDenied: (state) => {
|
||||
state.status = RequestStatus.DENIED;
|
||||
const newState = Object.assign(state, payload);
|
||||
newState.status = RequestStatus.SUCCESSFUL;
|
||||
return newState;
|
||||
},
|
||||
fetchConfigFailed: (state) => (
|
||||
{
|
||||
...state,
|
||||
status: RequestStatus.FAILED,
|
||||
}
|
||||
),
|
||||
fetchConfigDenied: (state) => (
|
||||
{
|
||||
...state,
|
||||
status: RequestStatus.DENIED,
|
||||
}
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import { camelCaseObject } from '@edx/frontend-platform';
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
|
||||
@@ -19,7 +18,7 @@ import {
|
||||
* @param {string} courseId The course ID for the course to fetch config for.
|
||||
* @returns {(function(*): Promise<void>)|*}
|
||||
*/
|
||||
export function fetchCourseConfig(courseId) {
|
||||
export default function fetchCourseConfig(courseId) {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
let learnerSort = LearnersOrdering.BY_LAST_ACTIVITY;
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import React, { memo } from 'react';
|
||||
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { LearningHeader } from '@edx/frontend-component-header';
|
||||
|
||||
import selectCourseTabs from '../../components/NavigationBar/data/selectors';
|
||||
import withConditionalInContextRendering from '../common/withConditionalInContextRendering';
|
||||
|
||||
const CourseHeader = () => {
|
||||
const { courseNumber, courseTitle, org } = useSelector(selectCourseTabs);
|
||||
|
||||
console.log('CourseHeader', courseNumber, courseTitle, org);
|
||||
|
||||
return (courseNumber || courseTitle || org) && (
|
||||
<LearningHeader courseOrg={org} courseNumber={courseNumber} courseTitle={courseTitle} />
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(withConditionalInContextRendering(CourseHeader, false));
|
||||
@@ -1,24 +0,0 @@
|
||||
import React, { memo } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { useEnableInContextSidebar } from '../data/hooks';
|
||||
import NavigationBar from '../navigation/navigation-bar/NavigationBar';
|
||||
import PostActionsBar from '../posts/post-actions-bar/PostActionsBar';
|
||||
|
||||
const DiscussionActionBar = () => {
|
||||
const enableInContextSidebar = useEnableInContextSidebar();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames('d-flex flex-row justify-content-between navbar fixed-top', {
|
||||
'pl-4 pr-3 py-0': enableInContextSidebar,
|
||||
})}
|
||||
>
|
||||
<NavigationBar />
|
||||
<PostActionsBar />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(DiscussionActionBar);
|
||||
@@ -1,10 +1,10 @@
|
||||
import React, { lazy, Suspense } from 'react';
|
||||
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Route, Switch } from 'react-router';
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
|
||||
import Spinner from '../../components/Spinner';
|
||||
import { Routes } from '../../data/constants';
|
||||
import { Routes as ROUTES } from '../../data/constants';
|
||||
|
||||
const PostEditor = lazy(() => import('../posts/post-editor/PostEditor'));
|
||||
const PostCommentsView = lazy(() => import('../post-comments/PostCommentsView'));
|
||||
@@ -13,23 +13,23 @@ const DiscussionContent = () => {
|
||||
const postEditorVisible = useSelector((state) => state.threads.postEditorVisible);
|
||||
|
||||
return (
|
||||
<div className="d-flex bg-light-400 flex-column w-75 w-xs-100 w-xl-75 align-items-center">
|
||||
<div className="d-flex bg-light-400 flex-column w-75 w-xs-100 w-xl-75 align-items-center overflow-auto">
|
||||
<div className="d-flex flex-column w-100">
|
||||
<Suspense fallback={(<Spinner />)}>
|
||||
{postEditorVisible ? (
|
||||
<Route path={Routes.POSTS.NEW_POST}>
|
||||
<PostEditor />
|
||||
</Route>
|
||||
) : (
|
||||
<Switch>
|
||||
<Route path={Routes.POSTS.EDIT_POST}>
|
||||
<PostEditor editExisting />
|
||||
</Route>
|
||||
<Route path={Routes.COMMENTS.PATH}>
|
||||
<PostCommentsView />
|
||||
</Route>
|
||||
</Switch>
|
||||
)}
|
||||
<Routes>
|
||||
{postEditorVisible ? (
|
||||
<Route path={ROUTES.POSTS.NEW_POST} element={<PostEditor />} />
|
||||
) : (
|
||||
<>
|
||||
{ROUTES.POSTS.EDIT_POST.map(route => (
|
||||
<Route key={route} path={route} element={<PostEditor editExisting />} />
|
||||
))}
|
||||
{ROUTES.COMMENTS.PATH.map(route => (
|
||||
<Route key={route} path={route} element={<PostCommentsView />} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import React, { memo } from 'react';
|
||||
|
||||
import Footer from '@edx/frontend-component-footer';
|
||||
|
||||
import withConditionalInContextRendering from '../common/withConditionalInContextRendering';
|
||||
|
||||
const DiscussionFooter = () => <Footer />;
|
||||
|
||||
export default memo(withConditionalInContextRendering(DiscussionFooter, false));
|
||||
@@ -1,17 +0,0 @@
|
||||
import React, { memo } from 'react';
|
||||
|
||||
import CourseTabsNavigation from '../../components/NavigationBar/CourseTabsNavigation';
|
||||
import CourseHeader from './CourseHeader';
|
||||
|
||||
const DiscussionHeader = () => {
|
||||
console.log('DiscussionHeader');
|
||||
|
||||
return (
|
||||
<>
|
||||
<CourseHeader />
|
||||
<CourseTabsNavigation />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(DiscussionHeader);
|
||||
@@ -1,53 +0,0 @@
|
||||
import React, { lazy, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { useEnableInContextSidebar } from '../data/hooks';
|
||||
import DiscussionsProductTour from '../tours/DiscussionsProductTour';
|
||||
import DiscussionActionBar from './DiscussionActionBar';
|
||||
import DiscussionFooter from './DiscussionFooter';
|
||||
import DiscussionHeader from './DiscussionHeader';
|
||||
import DiscussionSidebar from './DiscussionSidebar';
|
||||
import InfoPage from './InfoPage';
|
||||
import LayoutSwitcher from './LayoutSwitcher';
|
||||
import LegacyBreadcrumb from './LegacyBreadcrumb';
|
||||
|
||||
const DiscussionsRestrictionBanner = lazy(() => import('./DiscussionsRestrictionBanner'));
|
||||
|
||||
const DiscussionsLayout = ({ children }) => {
|
||||
const postActionBarRef = useRef(null);
|
||||
const enableInContextSidebar = useEnableInContextSidebar();
|
||||
|
||||
return (
|
||||
<>
|
||||
<DiscussionHeader />
|
||||
<main className="container-fluid d-flex flex-column p-0 w-100" id="main" tabIndex="-1">
|
||||
<div
|
||||
ref={postActionBarRef}
|
||||
className={classNames('header-action-bar', {
|
||||
'shadow-none border-light-300 border-bottom': enableInContextSidebar,
|
||||
})}
|
||||
>
|
||||
<DiscussionActionBar />
|
||||
<DiscussionsRestrictionBanner />
|
||||
</div>
|
||||
<LegacyBreadcrumb />
|
||||
<LayoutSwitcher
|
||||
sidebar={<DiscussionSidebar postActionBarRef={postActionBarRef} />}
|
||||
infoPage={<InfoPage />}
|
||||
>
|
||||
{children}
|
||||
</LayoutSwitcher>
|
||||
<DiscussionsProductTour />
|
||||
</main>
|
||||
<DiscussionFooter />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
DiscussionsLayout.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
};
|
||||
|
||||
export default React.memo(DiscussionsLayout);
|
||||
@@ -1,16 +1,22 @@
|
||||
import React, { lazy, Suspense } from 'react';
|
||||
import React, {
|
||||
lazy, Suspense, useContext, useEffect, useRef,
|
||||
} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { useWindowSize } from '@openedx/paragon';
|
||||
import classNames from 'classnames';
|
||||
import { useSelector } from 'react-redux';
|
||||
import {
|
||||
Redirect, Route, Switch, useLocation,
|
||||
} from 'react-router';
|
||||
Navigate, Route, Routes,
|
||||
} from 'react-router-dom';
|
||||
|
||||
import Spinner from '../../components/Spinner';
|
||||
import { RequestStatus, Routes } from '../../data/constants';
|
||||
import { useEnableInContextSidebar, useShowLearnersTab } from '../data/hooks';
|
||||
import { RequestStatus, Routes as ROUTES } from '../../data/constants';
|
||||
import DiscussionContext from '../common/context';
|
||||
import {
|
||||
useContainerSize, useIsOnDesktop, useIsOnTablet, useIsOnXLDesktop,
|
||||
} from '../data/hooks';
|
||||
import { selectConfigLoadingStatus, selectEnableInContext } from '../data/selectors';
|
||||
import ResizableSidebar from './ResizableSidebar';
|
||||
|
||||
const TopicPostsView = lazy(() => import('../in-context-topics/TopicPostsView'));
|
||||
const InContextTopicsView = lazy(() => import('../in-context-topics/TopicsView'));
|
||||
@@ -19,71 +25,110 @@ const LearnersView = lazy(() => import('../learners/LearnersView'));
|
||||
const PostsView = lazy(() => import('../posts/PostsView'));
|
||||
const LegacyTopicsView = lazy(() => import('../topics/TopicsView'));
|
||||
|
||||
const DiscussionSidebar = ({ postActionBarRef }) => {
|
||||
const location = useLocation();
|
||||
const enableInContextSidebar = useEnableInContextSidebar();
|
||||
const DiscussionSidebar = ({ displaySidebar, postActionBarRef }) => {
|
||||
const isOnDesktop = useIsOnDesktop();
|
||||
const isOnXLDesktop = useIsOnXLDesktop();
|
||||
const isOnTablet = useIsOnTablet();
|
||||
const { enableInContextSidebar } = useContext(DiscussionContext);
|
||||
const enableInContext = useSelector(selectEnableInContext);
|
||||
const configStatus = useSelector(selectConfigLoadingStatus);
|
||||
const redirectToLearnersTab = useShowLearnersTab();
|
||||
const sidebarRef = useRef(null);
|
||||
const postActionBarHeight = useContainerSize(postActionBarRef);
|
||||
const { height: windowHeight } = useWindowSize();
|
||||
|
||||
const memoizedRedirection = React.useMemo(() => (
|
||||
<Suspense fallback={(<Spinner />)}>
|
||||
<Switch>
|
||||
{enableInContext && !enableInContextSidebar && (
|
||||
<Route
|
||||
path={Routes.TOPICS.ALL}
|
||||
component={InContextTopicsView}
|
||||
exact
|
||||
/>
|
||||
)}
|
||||
{enableInContext && !enableInContextSidebar && (
|
||||
<Route
|
||||
path={[
|
||||
Routes.TOPICS.TOPIC,
|
||||
Routes.TOPICS.CATEGORY,
|
||||
Routes.TOPICS.TOPIC_POST,
|
||||
Routes.TOPICS.TOPIC_POST_EDIT,
|
||||
]}
|
||||
component={TopicPostsView}
|
||||
exact
|
||||
/>
|
||||
)}
|
||||
<Route
|
||||
path={[Routes.POSTS.ALL_POSTS, Routes.POSTS.MY_POSTS, Routes.POSTS.PATH, Routes.TOPICS.CATEGORY]}
|
||||
component={PostsView}
|
||||
/>
|
||||
<Route path={Routes.TOPICS.PATH} component={LegacyTopicsView} />
|
||||
{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={{
|
||||
...location,
|
||||
pathname: Routes.POSTS.ALL_POSTS,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Switch>
|
||||
</Suspense>
|
||||
), [enableInContext, enableInContextSidebar, configStatus, location, redirectToLearnersTab]);
|
||||
useEffect(() => {
|
||||
if (sidebarRef && postActionBarHeight && !enableInContextSidebar) {
|
||||
if (isOnDesktop) {
|
||||
sidebarRef.current.style.maxHeight = `${windowHeight - postActionBarHeight}px`;
|
||||
}
|
||||
sidebarRef.current.style.minHeight = `${windowHeight - postActionBarHeight}px`;
|
||||
sidebarRef.current.style.top = `${postActionBarHeight}px`;
|
||||
}
|
||||
}, [sidebarRef, postActionBarHeight, enableInContextSidebar]);
|
||||
|
||||
return (
|
||||
<ResizableSidebar postActionBarRef={postActionBarRef}>
|
||||
{memoizedRedirection}
|
||||
</ResizableSidebar>
|
||||
<div
|
||||
ref={sidebarRef}
|
||||
className={classNames('flex-column position-sticky', {
|
||||
'd-none': !displaySidebar,
|
||||
'd-flex overflow-auto box-shadow-centered-1': displaySidebar,
|
||||
'w-100': !isOnDesktop,
|
||||
'sidebar-desktop-width': isOnDesktop && !isOnXLDesktop,
|
||||
'sidebar-tablet-width': isOnTablet && !isOnDesktop,
|
||||
'w-25 sidebar-XL-width': isOnXLDesktop,
|
||||
'min-content-height': !enableInContextSidebar,
|
||||
})}
|
||||
data-testid="sidebar"
|
||||
>
|
||||
<Suspense fallback={(<Spinner />)}>
|
||||
<Routes>
|
||||
{enableInContext && !enableInContextSidebar && (
|
||||
<Route
|
||||
path={ROUTES.TOPICS.ALL}
|
||||
element={<InContextTopicsView />}
|
||||
/>
|
||||
)}
|
||||
{enableInContext && !enableInContextSidebar && (
|
||||
[
|
||||
ROUTES.TOPICS.TOPIC,
|
||||
ROUTES.TOPICS.CATEGORY,
|
||||
ROUTES.TOPICS.TOPIC_POST,
|
||||
ROUTES.TOPICS.TOPIC_POST_EDIT,
|
||||
].map((route) => (
|
||||
<Route
|
||||
key={route}
|
||||
path={route}
|
||||
element={<TopicPostsView />}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
{[
|
||||
ROUTES.POSTS.ALL_POSTS,
|
||||
ROUTES.POSTS.EDIT_ALL_POSTS,
|
||||
ROUTES.POSTS.MY_POSTS,
|
||||
ROUTES.POSTS.EDIT_MY_POSTS,
|
||||
ROUTES.TOPICS.CATEGORY,
|
||||
ROUTES.TOPICS.CATEGORY_POST,
|
||||
ROUTES.TOPICS.CATEGORY_POST_EDIT,
|
||||
ROUTES.TOPICS.TOPIC,
|
||||
ROUTES.TOPICS.TOPIC_POST,
|
||||
ROUTES.TOPICS.TOPIC_POST_EDIT,
|
||||
].map((route) => (
|
||||
<Route
|
||||
key={route}
|
||||
path={route}
|
||||
element={<PostsView />}
|
||||
/>
|
||||
))}
|
||||
{ROUTES.TOPICS.PATH.map(path => (
|
||||
<Route key={path} path={path} element={<LegacyTopicsView />} />
|
||||
))}
|
||||
{
|
||||
[ROUTES.LEARNERS.POSTS, ROUTES.LEARNERS.POSTS_EDIT].map((route) => (
|
||||
<Route key={route} path={route} element={<LearnerPostsView />} />
|
||||
))
|
||||
}
|
||||
<Route path={ROUTES.LEARNERS.PATH} element={<LearnersView />} />
|
||||
{configStatus === RequestStatus.SUCCESSFUL && (
|
||||
<Route path={`${ROUTES.DISCUSSIONS.PATH}/*`} element={<Navigate to="posts" />} />
|
||||
)}
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
DiscussionSidebar.propTypes = {
|
||||
displaySidebar: PropTypes.bool,
|
||||
postActionBarRef: PropTypes.oneOfType([
|
||||
PropTypes.func,
|
||||
PropTypes.shape({ current: PropTypes.instanceOf(Element) }),
|
||||
]).isRequired,
|
||||
]),
|
||||
};
|
||||
|
||||
DiscussionSidebar.defaultProps = {
|
||||
displaySidebar: false,
|
||||
postActionBarRef: null,
|
||||
};
|
||||
|
||||
export default React.memo(DiscussionSidebar);
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { 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 { Context as ResponsiveContext } from 'react-responsive';
|
||||
import { MemoryRouter } from 'react-router';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { Factory } from 'rosie';
|
||||
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
@@ -11,7 +10,7 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
|
||||
import { initializeStore } from '../../store';
|
||||
import { DiscussionContext } from '../common/context';
|
||||
import DiscussionContext from '../common/context';
|
||||
import { fetchConfigSuccess } from '../data/slices';
|
||||
import { getThreadsApiUrl } from '../posts/data/api';
|
||||
import DiscussionSidebar from './DiscussionSidebar';
|
||||
@@ -28,8 +27,8 @@ function renderComponent(displaySidebar = true, location = `/${courseId}/`) {
|
||||
const wrapper = render(
|
||||
<IntlProvider locale="en">
|
||||
<ResponsiveContext.Provider value={{ width: 1280 }}>
|
||||
<AppProvider store={store}>
|
||||
<DiscussionContext.Provider value={{ courseId }}>
|
||||
<AppProvider store={store} wrapWithRouter={false}>
|
||||
<DiscussionContext.Provider value={{ courseId, page: 'posts' }}>
|
||||
<MemoryRouter initialEntries={[location]}>
|
||||
<DiscussionSidebar displaySidebar={displaySidebar} postActionBarRef={null} />
|
||||
</MemoryRouter>
|
||||
@@ -85,7 +84,7 @@ describe('DiscussionSidebar', () => {
|
||||
},
|
||||
})]);
|
||||
renderComponent();
|
||||
await act(async () => expect(await screen.findAllByText('Thread by other users')).toBeTruthy());
|
||||
await screen.findAllByText('Thread by other users');
|
||||
expect(screen.queryByText('Thread by abc123')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -100,7 +99,7 @@ describe('DiscussionSidebar', () => {
|
||||
},
|
||||
})]);
|
||||
renderComponent();
|
||||
await act(async () => expect(await screen.findAllByText('Thread by other users')).toBeTruthy());
|
||||
await screen.findAllByText('Thread by other users');
|
||||
expect(screen.queryByText('Thread by abc123')).not.toBeInTheDocument();
|
||||
expect(container.querySelectorAll('.discussion-post')).toHaveLength(postCount);
|
||||
});
|
||||
|
||||
@@ -1,31 +1,74 @@
|
||||
import React, { lazy, Suspense, useMemo } from 'react';
|
||||
import React, {
|
||||
lazy, Suspense, useMemo, useRef,
|
||||
} from 'react';
|
||||
|
||||
import { useRouteMatch } from 'react-router';
|
||||
import classNames from 'classnames';
|
||||
import { useSelector } from 'react-redux';
|
||||
import {
|
||||
matchPath, Route, Routes, useLocation, useMatch,
|
||||
} from 'react-router-dom';
|
||||
|
||||
import { LearningHeader as Header } from '@edx/frontend-component-header';
|
||||
|
||||
import { Spinner } from '../../components';
|
||||
import { ALL_ROUTES } from '../../data/constants';
|
||||
import { DiscussionContext } from '../common/context';
|
||||
import selectCourseTabs from '../../components/NavigationBar/data/selectors';
|
||||
import { ALL_ROUTES, DiscussionProvider, Routes as ROUTES } from '../../data/constants';
|
||||
import DiscussionContext from '../common/context';
|
||||
import ContentUnavailable from '../content-unavailable/ContentUnavailable';
|
||||
import {
|
||||
useCourseDiscussionData, useCurrentPage, useEnableInContextSidebar, useRedirectToThread,
|
||||
useCourseBlockData, useCourseDiscussionData, useIsOnTablet, useRedirectToThread, useSidebarVisible,
|
||||
} from '../data/hooks';
|
||||
import DiscussionLayout from './DiscussionLayout';
|
||||
import { selectDiscussionProvider, selectEnableInContext, selectIsUserLearner } from '../data/selectors';
|
||||
import { EmptyLearners, EmptyTopics } from '../empty-posts';
|
||||
import EmptyPosts from '../empty-posts/EmptyPosts';
|
||||
import { EmptyTopic as InContextEmptyTopics } from '../in-context-topics/components';
|
||||
import messages from '../messages';
|
||||
import { selectPostEditorVisible } from '../posts/data/selectors';
|
||||
import { isCourseStatusValid } from '../utils';
|
||||
import useFeedbackWrapper from './FeedbackWrapper';
|
||||
|
||||
const FooterSlot = lazy(() => import('@edx/frontend-component-footer').then(module => ({ default: module.FooterSlot })));
|
||||
const PostActionsBar = lazy(() => import('../posts/post-actions-bar/PostActionsBar'));
|
||||
const CourseTabsNavigation = lazy(() => import('../../components/NavigationBar/CourseTabsNavigation'));
|
||||
const LegacyBreadcrumbMenu = lazy(() => import('../navigation/breadcrumb-menu/LegacyBreadcrumbMenu'));
|
||||
const NavigationBar = lazy(() => import('../navigation/navigation-bar/NavigationBar'));
|
||||
const DiscussionsProductTour = lazy(() => import('../tours/DiscussionsProductTour'));
|
||||
const DiscussionsRestrictionBanner = lazy(() => import('./DiscussionsRestrictionBanner'));
|
||||
const DiscussionContent = lazy(() => import('./DiscussionContent'));
|
||||
const DiscussionSidebar = lazy(() => import('./DiscussionSidebar'));
|
||||
|
||||
const DiscussionsHome = () => {
|
||||
useCourseDiscussionData();
|
||||
useRedirectToThread();
|
||||
useFeedbackWrapper();
|
||||
const page = useCurrentPage();
|
||||
const enableInContextSidebar = useEnableInContextSidebar();
|
||||
const location = useLocation();
|
||||
const postActionBarRef = useRef(null);
|
||||
const postEditorVisible = useSelector(selectPostEditorVisible);
|
||||
const provider = useSelector(selectDiscussionProvider);
|
||||
const enableInContext = useSelector(selectEnableInContext);
|
||||
const {
|
||||
params: {
|
||||
courseId, postId, topicId, category, learnerUsername,
|
||||
},
|
||||
} = useRouteMatch(ALL_ROUTES);
|
||||
courseNumber, courseTitle, org, courseStatus, isEnrolled,
|
||||
} = useSelector(selectCourseTabs);
|
||||
const isUserLearner = useSelector(selectIsUserLearner);
|
||||
const pageParams = useMatch(ROUTES.COMMENTS.PAGE)?.params;
|
||||
const page = pageParams?.page || null;
|
||||
const matchPattern = ALL_ROUTES.find((route) => matchPath({ path: route }, location.pathname));
|
||||
const { params } = useMatch(matchPattern);
|
||||
const isOnTabletorDesktop = useIsOnTablet();
|
||||
let displaySidebar = useSidebarVisible();
|
||||
const enableInContextSidebar = Boolean(new URLSearchParams(location.search).get('inContextSidebar') !== null);
|
||||
const {
|
||||
courseId, postId, topicId, category, learnerUsername,
|
||||
} = params;
|
||||
|
||||
const contextValues = useMemo(() => ({
|
||||
useCourseDiscussionData(courseId);
|
||||
useRedirectToThread(courseId, enableInContextSidebar);
|
||||
useCourseBlockData(courseId);
|
||||
useFeedbackWrapper();
|
||||
/* Display the content area if we are currently viewing/editing a post or creating one.
|
||||
If the window is larger than a particular size, show the sidebar for navigating between posts/topics.
|
||||
However, for smaller screens or embeds, only show the sidebar if the content area isn't displayed. */
|
||||
const displayContentArea = (postId || postEditorVisible || (learnerUsername && postId));
|
||||
if (displayContentArea) { displaySidebar = isOnTabletorDesktop; }
|
||||
|
||||
const discussionContextValue = useMemo(() => ({
|
||||
page,
|
||||
courseId,
|
||||
postId,
|
||||
@@ -33,15 +76,107 @@ const DiscussionsHome = () => {
|
||||
enableInContextSidebar,
|
||||
category,
|
||||
learnerUsername,
|
||||
}), [page, courseId, postId, topicId, enableInContextSidebar, category, learnerUsername]);
|
||||
}));
|
||||
|
||||
return (
|
||||
<Suspense fallback={(<Spinner />)}>
|
||||
<DiscussionLayout>
|
||||
<DiscussionContext.Provider value={contextValues}>
|
||||
<DiscussionContent />
|
||||
</DiscussionContext.Provider>
|
||||
</DiscussionLayout>
|
||||
<DiscussionContext.Provider value={discussionContextValue}>
|
||||
{!enableInContextSidebar && (<Header courseOrg={org} courseNumber={courseNumber} courseTitle={courseTitle} />)}
|
||||
<main className="container-fluid d-flex flex-column p-0 w-100 font-size" id="main" tabIndex="-1">
|
||||
{!enableInContextSidebar && <CourseTabsNavigation />}
|
||||
{(isEnrolled || !isUserLearner) && (
|
||||
<div
|
||||
className={classNames('header-action-bar bg-white position-sticky', {
|
||||
'shadow-none border-light-300 border-bottom': enableInContextSidebar,
|
||||
})}
|
||||
ref={postActionBarRef}
|
||||
>
|
||||
<div className={classNames('d-flex flex-row justify-content-between navbar fixed-top', {
|
||||
'pl-4 pr-2 py-0': enableInContextSidebar,
|
||||
})}
|
||||
>
|
||||
{!enableInContextSidebar && (<NavigationBar />)}
|
||||
<PostActionsBar />
|
||||
</div>
|
||||
<DiscussionsRestrictionBanner />
|
||||
</div>
|
||||
)}
|
||||
{provider === DiscussionProvider.LEGACY && (
|
||||
<Suspense fallback={(<Spinner />)}>
|
||||
<Routes>
|
||||
{[
|
||||
ROUTES.TOPICS.CATEGORY,
|
||||
ROUTES.TOPICS.CATEGORY_POST,
|
||||
ROUTES.TOPICS.CATEGORY_POST_EDIT,
|
||||
ROUTES.TOPICS.TOPIC,
|
||||
ROUTES.TOPICS.TOPIC_POST,
|
||||
ROUTES.TOPICS.TOPIC_POST_EDIT,
|
||||
].map((route) => (
|
||||
<Route
|
||||
key={route}
|
||||
path={route}
|
||||
element={<LegacyBreadcrumbMenu />}
|
||||
/>
|
||||
))}
|
||||
</Routes>
|
||||
</Suspense>
|
||||
)}
|
||||
{isCourseStatusValid(courseStatus) && (
|
||||
!isEnrolled && isUserLearner ? (
|
||||
<Suspense fallback={(<Spinner />)}>
|
||||
<Routes>
|
||||
{ALL_ROUTES.map((route) => (
|
||||
<Route
|
||||
key={route}
|
||||
path={route}
|
||||
element={(<ContentUnavailable subTitleMessage={messages.contentUnavailableSubTitle} />)}
|
||||
/>
|
||||
))}
|
||||
</Routes>
|
||||
</Suspense>
|
||||
) : (
|
||||
<div className="d-flex flex-row position-relative">
|
||||
<Suspense fallback={(<Spinner />)}>
|
||||
<DiscussionSidebar displaySidebar={displaySidebar} postActionBarRef={postActionBarRef} />
|
||||
</Suspense>
|
||||
{displayContentArea && (
|
||||
<Suspense fallback={(<Spinner />)}>
|
||||
<DiscussionContent />
|
||||
</Suspense>
|
||||
)}
|
||||
{!displayContentArea && (
|
||||
<Routes>
|
||||
<>
|
||||
{ROUTES.TOPICS.PATH.map(route => (
|
||||
<Route
|
||||
key={route}
|
||||
path={`${route}/*`}
|
||||
element={(enableInContext || enableInContextSidebar)
|
||||
? <InContextEmptyTopics /> : <EmptyTopics />}
|
||||
/>
|
||||
))}
|
||||
<Route
|
||||
path={ROUTES.POSTS.MY_POSTS}
|
||||
element={<EmptyPosts subTitleMessage={messages.emptyMyPosts} />}
|
||||
/>
|
||||
{[`${ROUTES.POSTS.PATH}/*`, ROUTES.POSTS.ALL_POSTS, ROUTES.LEARNERS.POSTS].map((route) => (
|
||||
<Route
|
||||
key={route}
|
||||
path={route}
|
||||
element={<EmptyPosts subTitleMessage={messages.emptyAllPosts} />}
|
||||
/>
|
||||
))}
|
||||
<Route path={ROUTES.LEARNERS.PATH} element={<EmptyLearners />} />
|
||||
</>
|
||||
</Routes>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{!enableInContextSidebar && isEnrolled && (<DiscussionsProductTour />)}
|
||||
</main>
|
||||
{!enableInContextSidebar && <FooterSlot />}
|
||||
</DiscussionContext.Provider>
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@ import MockAdapter from 'axios-mock-adapter';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { IntlProvider } from 'react-intl';
|
||||
import { Context as ResponsiveContext } from 'react-responsive';
|
||||
import { MemoryRouter } from 'react-router';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { Factory } from 'rosie';
|
||||
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
@@ -13,18 +13,18 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
|
||||
import { getCourseMetadataApiUrl } from '../../components/NavigationBar/data/api';
|
||||
import { fetchTab } from '../../components/NavigationBar/data/thunks';
|
||||
import fetchTab from '../../components/NavigationBar/data/thunks';
|
||||
import { getApiBaseUrl } from '../../data/constants';
|
||||
import { initializeStore } from '../../store';
|
||||
import { executeThunk } from '../../test-utils';
|
||||
import executeThunk from '../../test-utils';
|
||||
import { getCourseConfigApiUrl, getDiscussionsConfigUrl } from '../data/api';
|
||||
import { fetchCourseConfig } from '../data/thunks';
|
||||
import fetchCourseConfig from '../data/thunks';
|
||||
import { getCourseTopicsApiUrl } from '../in-context-topics/data/api';
|
||||
import { fetchCourseTopicsV3 } from '../in-context-topics/data/thunks';
|
||||
import fetchCourseTopicsV3 from '../in-context-topics/data/thunks';
|
||||
import navigationBarMessages from '../navigation/navigation-bar/messages';
|
||||
import { getThreadsApiUrl } from '../posts/data/api';
|
||||
import { fetchThreads } from '../posts/data/thunks';
|
||||
import { fetchCourseTopics } from '../topics/data/thunks';
|
||||
import fetchCourseTopics from '../topics/data/thunks';
|
||||
import DiscussionsHome from './DiscussionsHome';
|
||||
|
||||
import '../posts/data/__factories__/threads.factory';
|
||||
@@ -42,7 +42,7 @@ function renderComponent(location = `/${courseId}/`) {
|
||||
const wrapper = render(
|
||||
<IntlProvider locale="en">
|
||||
<ResponsiveContext.Provider value={{ width: 1280 }}>
|
||||
<AppProvider store={store}>
|
||||
<AppProvider store={store} wrapWithRouter={false}>
|
||||
<MemoryRouter initialEntries={[location]}>
|
||||
<DiscussionsHome />
|
||||
</MemoryRouter>
|
||||
@@ -65,6 +65,8 @@ describe('DiscussionsHome', () => {
|
||||
});
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
store = initializeStore();
|
||||
axiosMock.onGet(`${getCourseMetadataApiUrl(courseId)}`).reply(200, (Factory.build('navigationBar', 1, { isEnrolled: true })));
|
||||
await executeThunk(fetchTab(courseId, 'outline'), store.dispatch, store.getState);
|
||||
});
|
||||
|
||||
async function setUpV1TopicsMockResponse() {
|
||||
@@ -88,8 +90,7 @@ describe('DiscussionsHome', () => {
|
||||
|
||||
test('full view should hide close button', async () => {
|
||||
renderComponent(`/${courseId}/topics`);
|
||||
expect(screen.queryByText(navigationBarMessages.allTopics.defaultMessage))
|
||||
.toBeInTheDocument();
|
||||
await screen.findByText(navigationBarMessages.allTopics.defaultMessage);
|
||||
expect(screen.queryByRole('button', { name: 'Close' }))
|
||||
.not
|
||||
.toBeInTheDocument();
|
||||
@@ -142,7 +143,7 @@ describe('DiscussionsHome', () => {
|
||||
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
|
||||
await renderComponent(`/${courseId}/${searchByEndPoint}`);
|
||||
|
||||
expect(screen.queryByText('Add a post')).toBeInTheDocument();
|
||||
await screen.findByText('Add a post');
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -166,7 +167,7 @@ describe('DiscussionsHome', () => {
|
||||
await executeThunk(fetchThreads(courseId), store.dispatch, store.getState);
|
||||
await renderComponent(`/${courseId}/${searchByEndPoint}`);
|
||||
|
||||
expect(screen.queryByText(result)).toBeInTheDocument();
|
||||
await screen.findByText(result);
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -193,25 +194,26 @@ describe('DiscussionsHome', () => {
|
||||
await executeThunk(fetchCourseTopicsV3(courseId), store.dispatch, store.getState);
|
||||
await renderComponent(`/${courseId}/${searchByEndPoint}`);
|
||||
|
||||
expect(screen.queryByText('No topic selected')).toBeInTheDocument();
|
||||
await screen.findByText('No topic selected');
|
||||
},
|
||||
);
|
||||
|
||||
it('should display empty page message for empty learners list', async () => {
|
||||
axiosMock.onGet(getDiscussionsConfigUrl(courseId)).reply(200, {
|
||||
learners_tab_enabled: true,
|
||||
});
|
||||
axiosMock.onGet(getDiscussionsConfigUrl(courseId)).reply(200, {});
|
||||
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
|
||||
await renderComponent(`/${courseId}/learners`);
|
||||
|
||||
expect(screen.queryByText('Nothing here yet')).toBeInTheDocument();
|
||||
await screen.findByText('Nothing here yet');
|
||||
});
|
||||
|
||||
it('should display post editor form when click on add a post button for posts', async () => {
|
||||
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
|
||||
await renderComponent(`/${courseId}/my-posts`);
|
||||
|
||||
const addPost = await screen.findByText('Add a post');
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.queryByText('Add a post'));
|
||||
fireEvent.click(addPost);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(container.querySelector('.post-form')).toBeInTheDocument());
|
||||
@@ -219,15 +221,15 @@ describe('DiscussionsHome', () => {
|
||||
|
||||
it('should display post editor form when click on add a post button in legacy topics view', async () => {
|
||||
axiosMock.onGet(getDiscussionsConfigUrl(courseId)).reply(200, {
|
||||
enable_in_context: false,
|
||||
enable_in_context: false, hasModerationPrivileges: true,
|
||||
});
|
||||
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
|
||||
await renderComponent(`/${courseId}/topics`);
|
||||
|
||||
expect(screen.queryByText('Nothing here yet')).toBeInTheDocument();
|
||||
await screen.findByText('Nothing here yet');
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.queryByText('Add a post'));
|
||||
fireEvent.click((await screen.findAllByText('Add a post'))[0]);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(container.querySelector('.post-form')).toBeInTheDocument());
|
||||
@@ -236,28 +238,56 @@ describe('DiscussionsHome', () => {
|
||||
it('should display Add a post button for legacy topics view', async () => {
|
||||
await renderComponent(`/${courseId}/topics/topic-1`);
|
||||
|
||||
expect(screen.queryByText('Add a post')).toBeInTheDocument();
|
||||
await screen.findByText('Add a post');
|
||||
});
|
||||
|
||||
it('should display No post selected for legacy topics view', async () => {
|
||||
await setUpV1TopicsMockResponse();
|
||||
await renderComponent(`/${courseId}/topics/category-1-topic-1`);
|
||||
|
||||
expect(screen.queryByText('No post selected')).toBeInTheDocument();
|
||||
await screen.findByText('No post selected');
|
||||
});
|
||||
|
||||
it('should display No topic selected for legacy topics view', async () => {
|
||||
await setUpV1TopicsMockResponse();
|
||||
await renderComponent(`/${courseId}/topics`);
|
||||
|
||||
expect(screen.queryByText('No topic selected')).toBeInTheDocument();
|
||||
await screen.findByText('No topic selected');
|
||||
});
|
||||
|
||||
it('should display navigation tabs', async () => {
|
||||
axiosMock.onGet(`${getCourseMetadataApiUrl(courseId)}`).reply(200, (Factory.build('navigationBar', 1)));
|
||||
await executeThunk(fetchTab(courseId, 'outline'), store.dispatch, store.getState);
|
||||
renderComponent(`/${courseId}/topics`);
|
||||
|
||||
expect(screen.queryByText('Discussion')).toBeInTheDocument();
|
||||
await screen.findByText('Discussion');
|
||||
});
|
||||
|
||||
it('should display content unavailable message when the user is not enrolled in the course.', async () => {
|
||||
axiosMock.onGet(`${getCourseMetadataApiUrl(courseId)}`).reply(200, (Factory.build('navigationBar', 1, { isEnrolled: false })));
|
||||
await executeThunk(fetchTab(courseId, 'outline'), store.dispatch, store.getState);
|
||||
|
||||
renderComponent();
|
||||
|
||||
await screen.findByText('Content unavailable');
|
||||
});
|
||||
|
||||
it('should redirect to dashboard when the user clicks on the Enroll button.', async () => {
|
||||
const replaceMock = jest.fn();
|
||||
delete window.location;
|
||||
window.location = { replace: replaceMock };
|
||||
|
||||
axiosMock.onGet(`${getCourseMetadataApiUrl(courseId)}`).reply(200, (Factory.build('navigationBar', 1, { isEnrolled: false })));
|
||||
await executeThunk(fetchTab(courseId, 'outline'), store.dispatch, store.getState);
|
||||
|
||||
renderComponent();
|
||||
|
||||
const enrollButton = await screen.findByText('Enroll');
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(enrollButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.location.replace).toHaveBeenCalledWith(expect.stringContaining('about'));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
|
||||
import { PageBanner } from '@openedx/paragon';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { PageBanner } from '@edx/paragon';
|
||||
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import { selectConfigLoadingStatus, selectIsPostingEnabled } from '../data/selectors';
|
||||
|
||||
@@ -5,7 +5,7 @@ import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
|
||||
import { initializeStore } from '../../store';
|
||||
import { DiscussionContext } from '../common/context';
|
||||
import DiscussionContext from '../common/context';
|
||||
import { fetchConfigSuccess } from '../data/slices';
|
||||
import messages from '../messages';
|
||||
import DiscussionsRestrictionBanner from './DiscussionsRestrictionBanner';
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Route, Switch } from 'react-router';
|
||||
|
||||
import { Routes } from '../../data/constants';
|
||||
import { useEnableInContextSidebar, useShowLearnersTab } from '../data/hooks';
|
||||
import { selectEnableInContext } from '../data/selectors';
|
||||
import { EmptyLearners, EmptyPosts, EmptyTopics } from '../empty-posts';
|
||||
import { EmptyTopic as InContextEmptyTopics } from '../in-context-topics/components';
|
||||
import messages from '../messages';
|
||||
|
||||
const InfoPage = () => {
|
||||
const enableInContext = useSelector(selectEnableInContext);
|
||||
const isRedirectToLearners = useShowLearnersTab();
|
||||
const enableInContextSidebar = useEnableInContextSidebar();
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Route
|
||||
path={Routes.TOPICS.PATH}
|
||||
component={(enableInContext || enableInContextSidebar) ? InContextEmptyTopics : EmptyTopics}
|
||||
/>
|
||||
<Route
|
||||
path={Routes.POSTS.MY_POSTS}
|
||||
render={routeProps => <EmptyPosts {...routeProps} subTitleMessage={messages.emptyMyPosts} />}
|
||||
/>
|
||||
<Route
|
||||
path={[Routes.POSTS.PATH, Routes.POSTS.ALL_POSTS, Routes.LEARNERS.POSTS]}
|
||||
render={routeProps => <EmptyPosts {...routeProps} subTitleMessage={messages.emptyAllPosts} />}
|
||||
/>
|
||||
{isRedirectToLearners && <Route path={Routes.LEARNERS.PATH} component={EmptyLearners} />}
|
||||
</Switch>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(InfoPage);
|
||||
@@ -1,40 +0,0 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import {
|
||||
useIsOnDesktop, useLearnerUsername, usePostId, useSidebarVisible,
|
||||
} from '../data/hooks';
|
||||
import { selectPostEditorVisible } from '../posts/data/selectors';
|
||||
|
||||
const LayoutSwitcher = ({ children, sidebar, infoPage }) => {
|
||||
const postId = usePostId();
|
||||
const learnerUsername = useLearnerUsername();
|
||||
const isOnDesktop = useIsOnDesktop();
|
||||
let displaySidebar = useSidebarVisible();
|
||||
const postEditorVisible = useSelector(selectPostEditorVisible);
|
||||
|
||||
const displayContentArea = useMemo(() => {
|
||||
const isContentVisible = postId || postEditorVisible || (learnerUsername && postId);
|
||||
if (isContentVisible) {
|
||||
displaySidebar = isOnDesktop;
|
||||
}
|
||||
return isContentVisible;
|
||||
}, [postId, postEditorVisible, learnerUsername, isOnDesktop]);
|
||||
|
||||
return (
|
||||
<div className="d-flex flex-row position-relative">
|
||||
{displaySidebar && sidebar }
|
||||
{displayContentArea ? children : infoPage }
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
LayoutSwitcher.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
sidebar: PropTypes.node.isRequired,
|
||||
infoPage: PropTypes.node.isRequired,
|
||||
};
|
||||
|
||||
export default React.memo(LayoutSwitcher);
|
||||
@@ -1,23 +0,0 @@
|
||||
import React, { memo } from 'react';
|
||||
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Route } from 'react-router-dom';
|
||||
|
||||
import { DiscussionProvider, Routes } from '../../data/constants';
|
||||
import { selectDiscussionProvider } from '../data/selectors';
|
||||
import LegacyBreadcrumbMenu from '../navigation/breadcrumb-menu/LegacyBreadcrumbMenu';
|
||||
|
||||
const LegacyBreadcrumb = () => {
|
||||
const provider = useSelector(selectDiscussionProvider);
|
||||
|
||||
return (
|
||||
provider === DiscussionProvider.LEGACY && (
|
||||
<Route
|
||||
path={[Routes.POSTS.PATH, Routes.TOPICS.CATEGORY]}
|
||||
component={LegacyBreadcrumbMenu}
|
||||
/>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(LegacyBreadcrumb);
|
||||
@@ -1,54 +0,0 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { useWindowSize } from '@edx/paragon';
|
||||
|
||||
import {
|
||||
useContainerSize, useEnableInContextSidebar, useIsOnDesktop, useIsOnXLDesktop,
|
||||
} from '../data/hooks';
|
||||
|
||||
const ResizableSidebar = ({ children, postActionBarRef }) => {
|
||||
const sidebarRef = useRef(null);
|
||||
const isOnDesktop = useIsOnDesktop();
|
||||
const isOnXLDesktop = useIsOnXLDesktop();
|
||||
const enableInContextSidebar = useEnableInContextSidebar();
|
||||
const postActionBarHeight = useContainerSize(postActionBarRef);
|
||||
const { height: windowHeight } = useWindowSize();
|
||||
|
||||
useEffect(() => {
|
||||
if (sidebarRef && postActionBarHeight && !enableInContextSidebar) {
|
||||
if (isOnDesktop) {
|
||||
sidebarRef.current.style.maxHeight = `${windowHeight - postActionBarHeight}px`;
|
||||
}
|
||||
sidebarRef.current.style.minHeight = `${windowHeight - postActionBarHeight}px`;
|
||||
sidebarRef.current.style.top = `${postActionBarHeight}px`;
|
||||
}
|
||||
}, [sidebarRef, postActionBarHeight, enableInContextSidebar]);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="sidebar"
|
||||
ref={sidebarRef}
|
||||
className={classNames('flex-column position-sticky d-flex overflow-auto box-shadow-centered-1', {
|
||||
'w-100': !isOnDesktop,
|
||||
'sidebar-desktop-width': isOnDesktop && !isOnXLDesktop,
|
||||
'w-25 sidebar-XL-width': isOnXLDesktop,
|
||||
'min-content-height': !enableInContextSidebar,
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ResizableSidebar.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
postActionBarRef: PropTypes.oneOfType([
|
||||
PropTypes.func,
|
||||
PropTypes.shape({ current: PropTypes.instanceOf(Element) }),
|
||||
]).isRequired,
|
||||
};
|
||||
|
||||
export default React.memo(ResizableSidebar);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user