Compare commits

...

73 Commits

Author SHA1 Message Date
ayeshoali
ed3addc021 chore: removed enable_learners_tab_in_discussions_mfe flag dependency 2024-01-05 16:24:06 +05:00
sundasnoreen12
b467298d9a fix: fixed UI issues of discussion for incontext sidebar (#633)
* fix: fixed UI issues of discussion for incontext sidebar

* refactor: added paragon class

* refactor: improved actionBar UI

---------

Co-authored-by: Awais Ansari <awais.ansari63@gmail.com>
2024-01-02 13:35:47 +05:00
renovate[bot]
b5d036a54d fix(deps): update dependency regenerator-runtime to v0.14.1 2023-12-18 15:05:01 +00:00
renovate[bot]
bc997108ef chore(deps): update dependency @edx/frontend-build to v13.0.14 (#628)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-12-18 18:33:51 +05:00
vladislavkeblysh
6ae5130c14 feat: fixed page styles (#577) 2023-12-18 16:26:38 +05:00
Jenkins
67d79cb3aa chore(i18n): update translations 2023-12-17 15:22:26 -05:00
renovate[bot]
f31a0e71f3 fix(deps): update dependency formik to v2.4.5 (#619)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-12-14 15:15:42 +05:00
renovate[bot]
9761787c89 fix(deps): update dependency @reduxjs/toolkit to v1.9.7 (#616)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-12-14 12:30:12 +05:00
renovate[bot]
e5a21f4a75 chore(deps): update dependency @edx/frontend-build to v13.0.12 (#624)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-12-12 14:16:54 +05:00
renovate[bot]
1d89e9556a fix(deps): update dependency @edx/frontend-component-footer to v12.6.1 (#625)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-12-12 14:11:01 +05:00
Syed Ali Abbas Zaidi
b35632df64 feat: upgrade react router to v6 (#542)
* feat: upgrade react router to v6

* fix: routing issues

* fix: category route should redirect to all posts

* fix: path error on routes
2023-12-07 18:10:48 +05:00
Kshitij Sobti
b36c0266fd fix: null error at useRouteMatch when running on tutor (#613)
tutor sets the PUBLIC_PATH to '/discussions' which causes frontend-platform to
treat all URLs for matching etc to be relative to this path. Since many places
include '/discussions' in the match it causes those matches to break.

This change makes the default PUBLIC_PATH in .env.development to match the one
set by tutor and removes it from the base path of the router letting frontend
platform handle the prefix.

This also allows for deployments to customise this path to be something other
than 'discussions'.
2023-12-06 17:20:28 +05:00
renovate[bot]
0d5df18ab2 fix(deps): update dependency redux to v4.2.1 (#621)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-12-05 12:21:46 +05:00
renovate[bot]
c61435546d fix(deps): update dependency regenerator-runtime to v0.14.0 (#622)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-12-05 12:03:29 +05:00
renovate[bot]
df4a3c2a73 chore(deps): update dependency rosie to v2.1.1 (#605)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-12-04 16:15:31 +05:00
renovate[bot]
ac635edcb8 chore(deps): update dependency @edx/frontend-build to v13.0.8 (#608)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-12-04 13:07:44 +05:00
renovate[bot]
c4f7115732 fix(deps): update dependency @edx/frontend-component-footer to v12.6.0 (#609)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-12-04 13:01:08 +05:00
renovate[bot]
5cc8ba43fe fix(deps): update dependency @edx/frontend-component-header to v4.10.1 (#610)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-12-04 12:51:30 +05:00
renovate[bot]
68505821bb fix(deps): update dependency @edx/paragon to v20.46.3 (#611)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-12-04 11:51:58 +05:00
Ahtisham Shahid
c6d953fe7b feat: removed enable_moderation_reason_codes flag (#615)
* chore: removed deprecated flag

fix: resolved linter error

fix: changed workflow

fix: changed workflow

fix: changed workflow

fix: changed workflow

fix: changed workflow

fix: changed workflow

fix: changed workflow

fix: changed workflow

fix: changed workflow

fix: changed workflow

fix: changed workflow

fix: changed workflow

* test: fixed postEditor test case

---------

Co-authored-by: Awais Ansari <awais.ansari63@gmail.com>
2023-12-03 22:49:08 +05:00
Jenkins
a479f5ae5b chore(i18n): update translations 2023-11-19 15:22:18 -05:00
Ihor Romaniuk
48b2b9de64 fix: container indents and style imports (#600) 2023-11-14 12:07:47 +05:00
renovate[bot]
352fa0eacf chore(deps): update dependency @edx/frontend-build to v13.0.5 (#604)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-11-13 14:45:32 +05:00
Mashal Malik
6930e90c78 refactor: update lock file version (#559) 2023-11-08 18:36:07 +05:00
Ihor Romaniuk
4994de9615 fix: unify font-family with paragon component styles (#598) 2023-11-08 16:43:18 +05:00
renovate[bot]
6d90da7aa2 chore(deps): update dependency @testing-library/jest-dom to v5.17.0 (#588)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-11-08 00:04:27 +05:00
Awais Ansari
bcb43dfdff fix: resolved load more posts delay issue (#602) 2023-11-07 11:48:19 +05:00
renovate[bot]
7d7221b144 fix(deps): update dependency @edx/frontend-component-footer to v12.5.1 (#590)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-11-06 18:30:33 +05:00
renovate[bot]
7cd93bd8d2 fix(deps): update dependency @edx/frontend-component-header to v4.9.1 (#594)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-11-06 18:15:15 +05:00
Jenkins
6afa9840aa chore(i18n): update translations 2023-11-05 15:23:37 -05:00
renovate[bot]
46ddd6d885 chore(deps): update dependency @edx/reactifex to v1.1.0 (#580)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-11-03 14:41:08 +05:00
renovate[bot]
5bd15655f6 chore(deps): update dependency axios-mock-adapter to v1.22.0 (#589)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-11-03 14:27:33 +05:00
Muhammad Abdullah Waheed
27f73e3c23 feat: babel-plugin-react-intl to babel-plugin-formatjs migration (#572) 2023-11-01 12:02:57 +05:00
Mashal Malik
67700e8974 refactor: updated README file to reflect template changes (#592) 2023-10-31 17:30:05 +05:00
Sarina Canelake
1cb8ad3018 Merge pull request #591 from openedx/feanil/update_security_emails
docs: Update the security e-mail address.
2023-10-30 18:04:44 -04:00
Feanil Patel
4068b9e46a docs: Update the security e-mail address.
This repository is now managed by the Axim Collaborative and security issues
with it should be reported to security@openedx.org instead of security@edx.org

This work is being done as a part of https://github.com/openedx/wg-security/issues/16
2023-10-30 17:33:10 -04:00
Emad Rad
5db3a18cb7 fix: Persian language (#555)
* fix: corrected typos

comfirm -> confirm
varaint -> variant
Privilaged -> Privileged
courseare, coursweare  -> courseware
Discssion -> Discussion
dimentions -> dimensions

* refactor: clean up language codes
2023-10-24 14:29:09 +05:00
Feanil Patel
3a6a783f21 chore: Update to the new version of brand-openedx in the new scope. (#585)
Part of https://github.com/openedx/axim-engineering/issues/23

This updates the `@edx/brand` alias to point to the `brand-openedx` package at
the `openedx` scope. This does not impact imports because this package is used
via an alias.
2023-10-20 15:59:01 -04:00
renovate[bot]
69e0689ab9 fix(deps): update dependency @edx/frontend-platform to v4.6.3 (#566)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-10-12 16:18:23 +05:00
renovate[bot]
f795d9f836 fix(deps): update dependency tinymce to v5.10.7 (#570)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-10-12 14:57:00 +05:00
renovate[bot]
68c18526fb fix(deps): update dependency classnames to v2.3.2 (#568)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-10-11 18:10:40 +05:00
renovate[bot]
f49f272afa chore(deps): update dependency @edx/browserslist-config to v1.2.0 (#571)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-10-11 14:06:11 +05:00
Jenkins
67212254f7 chore(i18n): update translations 2023-09-24 16:27:33 -04:00
sundasnoreen12
af5b10a575 fix: now discussion sidebar modal appears above the fold (#563)
Co-authored-by: SundasNoreen <sundas.noreen@arbisoft.com>
2023-09-15 17:25:04 +05:00
Jenkins
fb2be35d00 chore(i18n): update translations 2023-09-03 16:32:24 -04:00
Jenkins
10adf1171b chore(i18n): update translations 2023-08-27 16:27:23 -04:00
Awais Ansari
2609380bd8 fix: moved feedback widget behind the env variable (#557)
* feat: remove InformationBanner from Discussion MFE

* fix: moved feedback widget behind the env variable
2023-08-21 14:49:35 +05:00
Jenkins
61d0f9a7ea chore(i18n): update translations 2023-08-13 16:27:20 -04:00
sundasnoreen12
ea235cf6ca fix: fixed leak issue when checkpoint will be undefined (#553)
Co-authored-by: SundasNoreen <sundas.noreen@arbisoft.com>
Co-authored-by: Awais Ansari <79941147+awais-ansari@users.noreply.github.com>
2023-08-08 14:32:34 +05:00
Jenkins
b8f11c3046 chore(i18n): update translations 2023-08-06 16:32:19 -04:00
Jenkins
1769692d22 chore(i18n): update translations 2023-07-30 16:27:15 -04:00
Awais Ansari
3d6b71c247 fix: learning header constant height (#551) 2023-07-26 14:42:07 +05:00
Awais Ansari
bd42521f6b style: add important in post type card border (#550) 2023-07-18 14:23:27 +05:00
edX requirements bot
445caca4e4 Merge pull request #540 from DmytroAlipov/fix-discussion-search
Fix bug with a repeated search query
2023-07-13 06:04:53 -04:00
alipov_d
4a2b32494d fix: issue with a repeated search query 2023-07-12 18:19:56 +02:00
Mashal Malik
a16bd783a0 build: update react-redux (#549) 2023-07-12 19:37:03 +05:00
Mashal Malik
df1a16ee85 feat: update react & react-dom to v17 (#537)
* feat: update react & react-dom to v17

* build: update pkgs

* fix: fix test

* build: remove ^ from pkgs

---------

Co-authored-by: Bilal Qamar <59555732+BilalQamar95@users.noreply.github.com>
2023-07-12 15:04:13 +05:00
Jenkins
656935336e chore(i18n): update translations 2023-07-09 16:27:12 -04:00
sundasnoreen12
2498f74556 chore: added renovate file structure based on provided template (#546)
Co-authored-by: SundasNoreen <sundas.noreen@arbisoft.com>
2023-07-06 15:09:03 +05:00
ayesha waris
99ad3aff53 test: fixed postcommentsview test cases (#543)
* test: fixed postcommentsview test cases

* test: fixed hovercard failed tests
2023-06-21 18:21:11 +05:00
Awais Ansari
e2bb68a1cd chore: update codecov ref from edx to openedx (#544)
* chore: update codecov ref from edx to openedx

* refactor: fix typo
2023-06-20 17:50:42 +05:00
Jenkins
f694b480b5 chore(i18n): update translations 2023-06-18 16:27:07 -04:00
Dmytro
228a771a39 fix: error 400 editing comment (#533) 2023-06-12 17:03:45 +05:00
edX requirements bot
c821033a64 Merge pull request #505 from igobranco/igobranco/new-translation-languages
chore(i18n): add languages
2023-06-12 06:04:52 -04:00
Ivo Branco
7ce4566df3 chore(i18n): add languages
Add new languages: pt-PT, uk, ru,hi, cs, es-AR, es-ES, fa-IR
2023-06-12 10:57:05 +01:00
Jenkins
a02771f96f chore(i18n): update translations 2023-06-11 16:27:04 -04:00
ayesha waris
8c53a7a19e feat: integrated backend discussions restriction with MFE (#529)
* feat: integrated backend discussions restriction with MFE

* test: fixes failed test cases

* refactor: fixed lint issues

---------

Co-authored-by: ayesha waris <73840786+ayeshoali@users.noreply.github.com>
Co-authored-by: SundasNoreen <sundas.noreen@arbisoft.com>
2023-06-06 14:18:53 +05:00
Eugene Dyudyunov
c8500a0c1e fix: post sharing URL (#445) 2023-05-31 14:01:12 +05:00
Jenkins
f7ad94997d chore(i18n): update translations 2023-05-28 16:26:59 -04:00
Omar Al-Ithawi
733a74d9e4 feat: use atlas in make pull_translations (#502)
Changes
-------
 - Move all i18n imports into `src/i18n/index.js` so `intl-imports.js` can
   override it with latest translations
 - Add `atlas` into `make pull_translations` when `OPENEDX_ATLAS_PULL`
   environment variable is set.

Refs: [FC-0012 project](https://openedx.atlassian.net/l/cp/XGS0iCcQ) implementing Translation Infrastructure OEP-58.
2023-05-25 18:38:29 +05:00
Bilal Qamar
b2b33b76f7 feat: upgraded to node v18, added .nvmrc and updated workflows (#471)
* feat: upgraded to node v18, added .nvmrc and updated workflows

* refactor: updated packages

* refactor: resolved eslint issues
2023-05-25 13:16:53 +05:00
Bilal Qamar
70f6541585 build: edx namespace packages upgrade & resolved respective eslint issue (#508)
* refactor: updated frontend-build, frontend-platform, header & footer packages

* fix: resolved eslint issues post frontend-build upgrade

* refactor: resolved eslint issues

* refactor: pinned frontend-build & changed suggested function definitions
2023-05-24 11:55:28 +05:00
Muhammad Adeel Tajamul
822854953f fix: switch to use PUBLIC_PATH for routes (#525) 2023-05-23 12:21:25 +05:00
141 changed files with 10101 additions and 30940 deletions

5
.env
View File

@@ -20,6 +20,5 @@ SEGMENT_KEY=''
SITE_NAME=''
USER_INFO_COOKIE_NAME=''
SUPPORT_URL=''
TA_FEEDBACK_FORM= ''
STAFF_FEEDBACK_FORM= ''
DISPLAY_FEEDBACK_BANNER='false'
LEARNER_FEEDBACK_URL=''
STAFF_FEEDBACK_URL=''

View File

@@ -21,6 +21,5 @@ SEGMENT_KEY=''
SITE_NAME=localhost
USER_INFO_COOKIE_NAME='edx-user-info'
SUPPORT_URL='https://support.edx.org'
TA_FEEDBACK_FORM='https://learner-form.test'
STAFF_FEEDBACK_FORM='https://staff-form.test'
DISPLAY_FEEDBACK_BANNER='false'
LEARNER_FEEDBACK_URL=''
STAFF_FEEDBACK_URL=''

View File

@@ -19,6 +19,5 @@ SEGMENT_KEY=''
SITE_NAME='localhost'
USER_INFO_COOKIE_NAME='edx-user-info'
SUPPORT_URL='https://support.edx.org'
TA_FEEDBACK_FORM='https://learner-form.test'
STAFF_FEEDBACK_FORM='https://staff-form.test'
DISPLAY_FEEDBACK_BANNER='false'
LEARNER_FEEDBACK_URL=''
STAFF_FEEDBACK_URL=''

View File

@@ -1,9 +1,10 @@
const { createConfig } = require('@edx/frontend-build');
module.exports = createConfig('eslint',
{
"plugins": ["simple-import-sort"],
"rules": {
module.exports = createConfig(
'eslint',
{
plugins: ['simple-import-sort'],
rules: {
'import/no-extraneous-dependencies': 'off',
'react-hooks/exhaustive-deps': 'off',
'jsx-a11y/no-noninteractive-element-interactions': 'off',
@@ -25,7 +26,6 @@ module.exports = createConfig('eslint',
},
],
'simple-import-sort/exports': 'error',
}
}
},
},
);

View File

@@ -9,18 +9,17 @@ on:
jobs:
tests:
runs-on: ubuntu-latest
strategy:
matrix:
node: [16]
steps:
- name: Checkout
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Setup Nodejs Env
run: echo "NODE_VER=`cat .nvmrc`" >> $GITHUB_ENV
- name: Setup Nodejs
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node }}
node-version: ${{ env.NODE_VER }}
- name: Install dependencies
run: npm ci
- name: Validate package-lock.json changes

View File

@@ -10,4 +10,4 @@ on:
jobs:
version-check:
uses: openedx/.github/.github/workflows/lockfileversion-check.yml@master
uses: openedx/.github/.github/workflows/lockfile-check.yml@master

View File

@@ -1,2 +0,0 @@
process.env.TA_FEEDBACK_FORM= 'https://learner-form.test';
process.env.STAFF_FEEDBACK_FORM= 'https://staff-form.test';

2
.nvmrc
View File

@@ -1 +1 @@
16
18

View File

@@ -1,13 +1,14 @@
export TRANSIFEX_RESOURCE = frontend-app-discussions
transifex_resource = frontend-app-discussions
transifex_langs = "ar,fr,es_419,zh_CN,tr_TR,pl,fr_CA,fr_FR,de_DE,it_IT"
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
transifex_input = $(i18n)/transifex_input.json
# This directory must match .babelrc .
transifex_temp = ./temp/babel-plugin-react-intl
transifex_temp = ./temp/babel-plugin-formatjs
NPM_TESTS=build i18n_extract lint test
@@ -55,9 +56,24 @@ 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) \
translations/frontend-component-header/src/i18n/messages:frontend-component-header \
translations/frontend-component-footer/src/i18n/messages:frontend-component-footer \
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
# This target is used by Travis.
validate-no-uncommitted-package-lock-changes:

View File

@@ -1,15 +1,42 @@
########################
frontend-app-discussions
========================
########################
|Build Status| |Codecov| |license|
|Codecov| |license|
.. |Codecov| image:: https://codecov.io/gh/openedx/frontend-app-discussions/branch/master/graph/badge.svg?token=3z7XvuzTq3
:target: https://codecov.io/gh/openedx/frontend-app-discussions
.. |license| image:: https://img.shields.io/badge/license-AGPL-informational
:target: https://github.com/openedx/frontend-app-discussions/blob/master/LICENSE
********
Purpose
-------
********
This repository is a React-based micro frontend for the Open edX discussion forums.
***************
Getting Started
---------------
***************
Prerequisites
=============
The `devstack`_ is currently recommended as a development environment for your
new MFE. If you start it with ``make dev.up.lms`` that should give you
everything you need as a companion to this frontend.
Note that it is also possible to use `Tutor`_ to develop an MFE. You can refer
to the `relevant tutor-mfe documentation`_ to get started using it.
.. _Devstack: https://github.com/openedx/devstack
.. _Tutor: https://github.com/overhangio/tutor
.. _relevant tutor-mfe documentation: https://github.com/overhangio/tutor-mfe#mfe-development
Cloning and Startup
===================
1. Clone your new repo:
@@ -26,7 +53,7 @@ Getting Started
The dev server is running at `http://localhost:2002 <http://localhost:2002>`_.
Getting Help
------------
============
Please tag **@openedx/edx-infinity ** on any PRs or issues. Thanks.
If you're having trouble, we have discussion forums at https://discuss.openedx.org where you can connect with others in the community.
@@ -39,7 +66,8 @@ For more information about these options, see the `Getting Help`_ page.
.. _Getting Help: https://openedx.org/getting-help
How to Contribute
-----------------
=================
Details about how to become a contributor to the Open edX project may be found in the wiki at `How to contribute`_
.. _How to contribute: https://edx.readthedocs.io/projects/edx-developer-guide/en/latest/process/index.html
@@ -50,39 +78,45 @@ can find it it at `PULL_REQUEST_TEMPLATE.md <https://github.com/openedx/frontend
This project is currently accepting all types of contributions, bug fixes and security fixes
The Open edX Code of Conduct
----------------------------
============================
All community members should familiarize themselves with the `Open edX Code of Conduct`_.
.. _Open edX Code of Conduct: https://openedx.org/code-of-conduct/
People
------
======
The assigned maintainers for this component and other project details may be found in Backstage or from inspecting catalog-info.yaml.
Reporting Security Issues
-------------------------
Please do not report security issues in public. Please email security@edx.org.
Please do not report security issues in public. Please email security@openedx.org.
Project Structure
-----------------
=================
The source for this project is organized into nested submodules according to the ADR `Feature-based Application Organization <https://github.com/openedx/frontend-app-discussions/blob/master/docs/decisions/0002-feature-based-application-organization.rst>`_.
Build Process Notes
-------------------
===================
**Production Build**
The production build is created with ``npm run build``.
License
=======
The code in this repository is licensed under the AGPLv3 unless otherwise
noted.
Please see `LICENSE <LICENSE>`_ for details.
Internationalization
--------------------
====================
Please see `edx/frontend-platform's i18n module <https://edx.github.io/frontend-platform/module-Internationalization.html>`_ for documentation on internationalization. The documentation explains how to use it, and the `How To <https://github.com/openedx/frontend-i18n/blob/master/docs/how_tos/i18n.rst>`_ has more detail.
.. |Build Status| image:: https://api.travis-ci.org/edx/frontend-app-discussions.svg?branch=master
:target: https://travis-ci.org/edx/frontend-app-discussions
.. |Codecov| image:: https://codecov.io/gh/edx/frontend-app-discussions/branch/master/graph/badge.svg
:target: https://codecov.io/gh/edx/frontend-app-discussions
.. |license| image:: https://img.shields.io/npm/l/@edx/frontend-app-discussions.svg
:target: @edx/frontend-app-discussions
Reporting Security Issues
=========================
Please do not report security issues in public. Please email security@openedx.org.

View File

@@ -1,9 +1,9 @@
const { createConfig } = require('@edx/frontend-build');
module.exports = createConfig('jest', {
// setupFilesAfterEnv is used after the jest environment has been loaded. In general this is what you want.
// 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>/.jest/setEnvVars.js'],
setupFiles: ['<rootDir>/.env.test'],
setupFilesAfterEnv: [
'<rootDir>/src/setupTest.js',
],

34525
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,7 +11,7 @@
],
"scripts": {
"build": "fedx-scripts webpack",
"i18n_extract": "BABEL_ENV=i18n fedx-scripts babel src --quiet > /dev/null",
"i18n_extract": "fedx-scripts formatjs extract",
"lint": "fedx-scripts eslint --ext .js --ext .jsx .",
"lint:fix": "fedx-scripts eslint --ext .js --ext .jsx . --fix",
"snapshot": "fedx-scripts jest --updateSnapshot",
@@ -33,45 +33,45 @@
"url": "https://github.com/openedx/frontend-app-discussions/issues"
},
"dependencies": {
"@edx/brand": "npm:@edx/brand-openedx@1.1.0",
"@edx/frontend-component-footer": "11.2.0",
"@edx/frontend-component-header": "3.2.0",
"@edx/frontend-platform": "2.6.1",
"@edx/paragon": "20.15.0",
"@reduxjs/toolkit": "1.8.0",
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
"@edx/frontend-component-footer": "12.6.1",
"@edx/frontend-component-header": "4.10.1",
"@edx/frontend-platform": "5.6.1",
"@edx/paragon": "20.46.3",
"@reduxjs/toolkit": "1.9.7",
"@tinymce/tinymce-react": "3.13.1",
"babel-polyfill": "6.26.0",
"classnames": "2.3.1",
"classnames": "2.3.2",
"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": "16.14.0",
"react-dom": "16.14.0",
"react-redux": "7.2.6",
"react-router": "5.2.1",
"react-router-dom": "5.3.0",
"redux": "4.1.2",
"regenerator-runtime": "0.13.9",
"react": "17.0.2",
"react-dom": "17.0.2",
"react-redux": "7.2.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.2",
"tinymce": "5.10.7",
"yup": "0.31.1"
},
"devDependencies": {
"@edx/browserslist-config": "1.1.0",
"@edx/frontend-build": "11.0.1",
"@edx/reactifex": "1.0.3",
"@testing-library/jest-dom": "5.16.2",
"@testing-library/react": "12.1.4",
"@edx/browserslist-config": "1.2.0",
"@edx/frontend-build": "13.0.14",
"@edx/reactifex": "1.1.0",
"@testing-library/jest-dom": "5.17.0",
"@testing-library/react": "12.1.5",
"@testing-library/user-event": "13.5.0",
"axios-mock-adapter": "1.20.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"
"rosie": "2.1.1"
}
}

View File

@@ -1,9 +1,33 @@
{
"extends": [
"config:base"
"config:base",
"schedule:weekly",
":automergeLinters",
":automergeMinor",
":automergeTesters",
":enableVulnerabilityAlerts",
":rebaseStalePrs",
":semanticCommits",
":updateNotScheduled"
],
"patch": {
"automerge": true
},
"rebaseStalePrs": true
"packageRules": [
{
"matchDepTypes": [
"devDependencies"
],
"matchUpdateTypes": [
"lockFileMaintenance",
"minor",
"patch",
"pin"
],
"automerge": true
},
{
"matchPackagePatterns": ["@edx", "@openedx"],
"matchUpdateTypes": ["minor", "patch"],
"automerge": true
}
],
"timezone": "America/New_York"
}

View File

@@ -19,19 +19,21 @@ import { selectCourseCohorts } from '../discussions/cohorts/data/selectors';
import messages from '../discussions/posts/post-filter-bar/messages';
import { ActionItem } from '../discussions/posts/post-filter-bar/PostFilterBar';
function FilterBar({
const FilterBar = ({
intl,
filters,
selectedFilters,
onFilterChange,
showCohortsFilter,
}) {
}) => {
const [isOpen, setOpen] = useState(false);
const cohorts = useSelector(selectCourseCohorts);
const { status } = useSelector(state => state.cohorts);
const selectedCohort = useMemo(() => cohorts.find(cohort => (
toString(cohort.id) === selectedFilters.cohort)),
[selectedFilters.cohort]);
const selectedCohort = useMemo(
() => cohorts.find(cohort => (
toString(cohort.id) === selectedFilters.cohort)),
[selectedFilters.cohort],
);
const allFilters = [
{
@@ -183,7 +185,7 @@ function FilterBar({
</Collapsible.Body>
</Collapsible.Advanced>
);
}
};
FilterBar.propTypes = {
intl: intlShape.isRequired,

View File

@@ -24,25 +24,23 @@ const CourseTabsNavigation = ({
}, [courseId]);
return (
<div id="courseTabsNavigation" className={classNames('course-tabs-navigation', className)}>
<div className="container-xl">
{!!tabs.length && (
<Tabs
className="nav-underline-tabs"
aria-label={intl.formatMessage(messages.courseMaterial)}
>
{tabs.map(({ url, title, slug }) => (
<a
key={slug}
className={classNames('nav-item flex-shrink-0 nav-link', { active: slug === activeTab })}
href={url}
>
{title}
</a>
))}
</Tabs>
)}
</div>
<div id="courseTabsNavigation" className={classNames('course-tabs-navigation px-4', className)}>
{!!tabs.length && (
<Tabs
className="nav-underline-tabs"
aria-label={intl.formatMessage(messages.courseMaterial)}
>
{tabs.map(({ url, title, slug }) => (
<a
key={slug}
className={classNames('nav-item flex-shrink-0 nav-link', { active: slug === activeTab })}
href={url}
>
{title}
</a>
))}
</Tabs>
)}
</div>
);
};

View File

@@ -56,8 +56,10 @@ 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 })));
axiosMock.onGet(`${getCourseMetadataApiUrl(courseId)}`).reply(
200,
(Factory.build('navigationBar', 1, { hasCourseAccess: false })),
);
await executeThunk(fetchTab(courseId, 'outline'), store.dispatch, store.getState);
expect(store.getState().courseTabs.courseStatus).toEqual('denied');

View File

@@ -1,10 +1,11 @@
@import "@edx/brand/paragon/fonts.scss";
@import "@edx/brand/paragon/variables.scss";
@import "@edx/paragon/scss/core/core.scss";
@import "@edx/brand/paragon/overrides.scss";
@import "~@edx/brand/paragon/fonts.scss";
@import "~@edx/brand/paragon/variables.scss";
@import "~@edx/paragon/scss/core/core.scss";
@import "~@edx/brand/paragon/overrides.scss";
$fa-font-path: "~font-awesome/fonts";
@import "~font-awesome/scss/font-awesome";
.course-tabs-navigation {
border-bottom: solid 1px #eaeaea;

View File

@@ -8,7 +8,7 @@ import { Dropdown } from '@edx/paragon';
import useIndexOfLastVisibleChild from './useIndexOfLastVisibleChild';
export default function Tabs({ children, className, ...attrs }) {
const Tabs = ({ children, className, ...attrs }) => {
const [
indexOfLastVisibleChild,
containerElementRef,
@@ -31,25 +31,28 @@ export default function Tabs({ children, className, ...attrs }) {
// Insert the overflow menu at the cut off index (even if it will be hidden
// it so it can be part of measurements)
wrappedChildren.splice(indexOfOverflowStart, 0, (
<div
className="nav-item flex-shrink-0"
style={indexOfOverflowStart >= React.Children.count(children) ? invisibleStyle : null}
ref={overflowElementRef}
key="overflow"
>
<Dropdown className="h-100">
<Dropdown.Toggle variant="link" className="nav-link h-100" id="learn.course.tabs.navigation.overflow.menu">
<FormattedMessage
id="learn.course.tabs.navigation.overflow.menu"
description="The title of the overflow menu for course tabs"
defaultMessage="More..."
/>
</Dropdown.Toggle>
<Dropdown.Menu className="dropdown-menu-right">{overflowChildren}</Dropdown.Menu>
</Dropdown>
</div>
));
wrappedChildren.splice(
indexOfOverflowStart,
0, (
<div
className="nav-item flex-shrink-0"
style={indexOfOverflowStart >= React.Children.count(children) ? invisibleStyle : null}
ref={overflowElementRef}
key="overflow"
>
<Dropdown className="h-100">
<Dropdown.Toggle variant="link" className="nav-link h-100" id="learn.course.tabs.navigation.overflow.menu">
<FormattedMessage
id="learn.course.tabs.navigation.overflow.menu"
description="The title of the overflow menu for course tabs"
defaultMessage="More..."
/>
</Dropdown.Toggle>
<Dropdown.Menu className="dropdown-menu-right">{overflowChildren}</Dropdown.Menu>
</Dropdown>
</div>
),
);
return wrappedChildren;
}, [children, indexOfLastVisibleChild]);
@@ -62,7 +65,7 @@ export default function Tabs({ children, className, ...attrs }) {
{tabChildren}
</nav>
);
}
};
Tabs.propTypes = {
children: PropTypes.node,
@@ -73,3 +76,5 @@ Tabs.defaultProps = {
children: null,
className: undefined,
};
export default Tabs;

View File

@@ -1,5 +1,5 @@
import React, {
useCallback, useContext, useEffect, useState,
useCallback, useContext, useEffect, useRef, useState,
} from 'react';
import camelCase from 'lodash/camelCase';
@@ -25,6 +25,7 @@ const Search = () => {
const isPostSearch = ['posts', 'my-posts'].includes(page);
const isTopicSearch = 'topics'.includes(page);
const [searchValue, setSearchValue] = useState('');
const previousSearchValueRef = useRef('');
let currentValue = '';
if (isPostSearch) {
@@ -39,14 +40,15 @@ const Search = () => {
dispatch(setSearchQuery(''));
dispatch(setTopicFilter(''));
dispatch(setUsernameSearch(''));
}, []);
previousSearchValueRef.current = '';
}, [previousSearchValueRef]);
const onChange = useCallback((query) => {
setSearchValue(query);
}, []);
const onSubmit = useCallback((query) => {
if (query === '') {
if (query === '' || query === previousSearchValueRef.current) {
return;
}
@@ -57,7 +59,8 @@ const Search = () => {
} else if (page === 'learners') {
dispatch(setUsernameSearch(query));
}
}, [page, searchValue]);
previousSearchValueRef.current = query;
}, [page, searchValue, previousSearchValueRef]);
const handleIconClick = useCallback((e) => {
e.preventDefault();

View File

@@ -1,7 +1,7 @@
import React, { useCallback, useState } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import { Editor } from '@tinymce/tinymce-react';
import { 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';
@@ -42,13 +42,14 @@ import contentCss from '!!raw-loader!tinymce/skins/content/default/content.min.c
import contentUiCss from '!!raw-loader!tinymce/skins/ui/oxide/content.min.css';
/* istanbul ignore next */
function TinyMCEEditor(props) {
const TinyMCEEditor = (props) => {
// note that skin and content_css is disabled to avoid the normal
// loading process and is instead loaded as a string via content_style
const locationObj = useLocation();
const { courseId, postId } = useParams();
const [showImageWarning, setShowImageWarning] = useState(false);
const intl = useIntl();
const enableInContextSidebar = Boolean(new URLSearchParams(locationObj.search).get('inContextSidebar') !== null);
/* istanbul ignore next */
const setup = useCallback((editor) => {
@@ -99,6 +100,29 @@ function TinyMCEEditor(props) {
contentStyle = '';
}
// eslint-disable-next-line consistent-return
useEffect(() => {
if (enableInContextSidebar) {
const checkToxDialogVisibility = () => {
const toxDialog = document.querySelector('.tox-dialog');
if (toxDialog) {
toxDialog.style.alignSelf = 'start';
toxDialog.style.marginTop = '50px';
}
};
const observer = new MutationObserver(checkToxDialogVisibility);
// Observe changes to the entire document
observer.observe(document, { childList: true, subtree: true });
// Clean up the observer when the component unmounts
return () => {
observer.disconnect();
};
}
}, [enableInContextSidebar]);
return (
<>
<Editor
@@ -152,6 +176,6 @@ function TinyMCEEditor(props) {
</AlertModal>
</>
);
}
};
export default React.memo(TinyMCEEditor);

View File

@@ -1,5 +1,6 @@
import React from 'react';
// eslint-disable-next-line react/function-component-definition
export default function InsertLink() {
return (
<svg

View File

@@ -1,5 +1,6 @@
import React from 'react';
// eslint-disable-next-line react/function-component-definition
export default function Issue() {
return (
<svg

View File

@@ -1,5 +1,6 @@
import React from 'react';
// eslint-disable-next-line react/function-component-definition
export default function People() {
return (
<svg

View File

@@ -1,5 +1,6 @@
import React from 'react';
// eslint-disable-next-line react/function-component-definition
export default function PushPin() {
return (
<svg

View File

@@ -1,5 +1,6 @@
import React from 'react';
// eslint-disable-next-line react/function-component-definition
export default function Question() {
return (
<svg

View File

@@ -1,5 +1,6 @@
import React from 'react';
// eslint-disable-next-line react/function-component-definition
export default function QuestionAnswer() {
return (
<svg

View File

@@ -1,5 +1,6 @@
import React from 'react';
// eslint-disable-next-line react/function-component-definition
export default function QuestionAnswerOutline() {
return (
<svg

View File

@@ -1,5 +1,6 @@
import React from 'react';
// eslint-disable-next-line react/function-component-definition
export default function StarFilled() {
return (
<svg

View File

@@ -1,5 +1,6 @@
import React from 'react';
// eslint-disable-next-line react/function-component-definition
export default function StarOutline() {
return (
<svg

View File

@@ -1,5 +1,6 @@
import React from 'react';
// eslint-disable-next-line react/function-component-definition
export default function ThumbUpFilled() {
return (
<svg

View File

@@ -1,5 +1,6 @@
import React from 'react';
// eslint-disable-next-line react/function-component-definition
export default function ThumbUpOutline() {
return (
<svg

View File

@@ -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.
@@ -144,18 +147,17 @@ export const Routes = {
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 +168,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 +191,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 +208,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;

View File

@@ -13,23 +13,23 @@ import {
import { MoreHoriz } from '@edx/paragon/icons';
import { ContentActions } from '../../data/constants';
import { selectBlackoutDate } from '../data/selectors';
import { selectIsPostingEnabled } from '../data/selectors';
import messages from '../messages';
import { inBlackoutDateRange, useActions } from '../utils';
import { useActions } from '../utils';
function ActionsDropdown({
const ActionsDropdown = ({
actionHandlers,
contentType,
disabled,
dropDownIconSize,
iconSize,
id,
}) {
}) => {
const buttonRef = useRef();
const intl = useIntl();
const [isOpen, open, close] = useToggle(false);
const [target, setTarget] = useState(null);
const blackoutDateRange = useSelector(selectBlackoutDate);
const isPostingEnabled = useSelector(selectIsPostingEnabled);
const actions = useActions(contentType, id);
const handleActions = useCallback((action) => {
@@ -41,12 +41,12 @@ function ActionsDropdown({
}
}, [actionHandlers]);
// Find and remove edit action if in blackout date range.
// Find and remove edit action if in Posting is disabled.
useMemo(() => {
if (inBlackoutDateRange(blackoutDateRange)) {
if (!isPostingEnabled) {
actions.splice(actions.findIndex(action => action.id === 'edit'), 1);
}
}, [actions, blackoutDateRange]);
}, [actions, isPostingEnabled]);
const onClickButton = useCallback(() => {
setTarget(buttonRef.current);
@@ -68,7 +68,7 @@ function ActionsDropdown({
disabled={disabled}
size={iconSize}
ref={buttonRef}
iconClassNames={dropDownIconSize ? 'dropdown-icon-dimentions' : ''}
iconClassNames={dropDownIconSize ? 'dropdown-icon-dimensions' : ''}
/>
<div className="actions-dropdown">
<ModalPopup
@@ -109,7 +109,7 @@ function ActionsDropdown({
</div>
</>
);
}
};
ActionsDropdown.propTypes = {
id: PropTypes.string.isRequired,

View File

@@ -13,6 +13,8 @@ import { AppProvider } from '@edx/frontend-platform/react';
import { ContentActions } from '../../data/constants';
import { initializeStore } from '../../store';
import { executeThunk } from '../../test-utils';
import { getCourseConfigApiUrl } from '../data/api';
import { fetchCourseConfig } from '../data/thunks';
import messages from '../messages';
import { getCommentsApiUrl } from '../post-comments/data/api';
import { addComment, fetchThreadComments } from '../post-comments/data/thunks';
@@ -29,6 +31,7 @@ let store;
let axiosMock;
const commentsApiUrl = getCommentsApiUrl();
const threadsApiUrl = getThreadsApiUrl();
const courseId = 'course-v1:edX+TestX+Test_Course';
const discussionThreadId = 'thread-1';
const questionThreadId = 'thread-2';
const commentContent = 'This is a comment for thread-1';
@@ -170,7 +173,7 @@ const findOpenActionsDropdownButton = async () => (
);
describe('ActionsDropdown', () => {
beforeEach(() => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
@@ -182,6 +185,11 @@ describe('ActionsDropdown', () => {
store = initializeStore();
Factory.resetAll();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock.onGet(`${getCourseConfigApiUrl()}${courseId}/`)
.reply(200, { isPostingEnabled: true });
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
});
it.each(Object.values(buildTestContent()))('can open drop down if enabled', async (commentOrPost) => {

View File

@@ -10,7 +10,7 @@ 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

View File

@@ -90,7 +90,6 @@ describe.each([
store = initializeStore({
config: {
hasModerationPrivileges: true,
reasonCodesEnabled: true,
},
});
const content = buildTestContent(type, props);

View File

@@ -2,8 +2,7 @@ import React, { useContext, useMemo } from 'react';
import PropTypes from 'prop-types';
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';
@@ -11,7 +10,6 @@ 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 timeLocale from './time-locale';
@@ -46,12 +44,11 @@ const AuthorLabel = ({
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);
const authorName = useMemo(() => (
<span
className={classNames('mr-1.5 font-size-14 font-style font-weight-500', {
className={classNames('mr-1.5 font-size-14 font-style font-weight-500 author-name', {
'text-gray-700': isRetiredUser,
'text-primary-500': !authorLabelMessage && !isRetiredUser,
})}
@@ -100,7 +97,7 @@ const AuthorLabel = ({
{postCreatedAt && (
<span
title={postCreatedAt}
className={classNames('font-family-inter align-content-center', {
className={classNames('align-content-center', {
'text-white': alert,
'text-gray-500': !alert,
})}

View File

@@ -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, {});
@@ -66,19 +65,20 @@ describe('Author label', () => {
['retired__user', null, false, ''],
['staff_user', 'Staff', true, 'text-staff-color'],
['learner_user', null, false, ''],
])('for %s', (
author, authorLabel, linkToProfile, labelColor,
) => {
it('it has author name text',
])('for %s', (author, authorLabel, linkToProfile, labelColor) => {
it(
'it has author name text',
async () => {
renderComponent(author, authorLabel, linkToProfile, labelColor);
const authorElement = container.querySelector('[role=heading]');
const authorName = author.startsWith('retired__user') ? '[Deactivated]' : author;
expect(authorElement).toHaveTextContent(authorName);
});
},
);
it(`it is "${!linkToProfile && 'not'}" clickable when linkToProfile is ${!!linkToProfile}`,
it(
`it is "${!linkToProfile && 'not'}" clickable when linkToProfile is ${!!linkToProfile}`,
async () => {
renderComponent(author, authorLabel, linkToProfile, labelColor);
@@ -87,9 +87,11 @@ describe('Author label', () => {
} else {
expect(screen.queryByTestId('learner-posts-link')).not.toBeInTheDocument();
}
});
},
);
it(`it has "${!linkToProfile && 'not'}" label text and label color when linkToProfile is ${!!linkToProfile}`,
it(
`it has "${!linkToProfile && 'not'}" label text and label color when linkToProfile is ${!!linkToProfile}`,
async () => {
renderComponent(author, authorLabel, linkToProfile, labelColor);
const authorElement = container.querySelector('[role=heading]');
@@ -104,6 +106,7 @@ describe('Author label', () => {
expect(authorElement.parentNode.lastChild).not.toHaveTextContent(label, { exact: true });
expect(authorElement.parentNode).not.toHaveClass(labelColor, { exact: true });
}
});
},
);
});
});

View File

@@ -6,16 +6,16 @@ import { ActionRow, Button, ModalDialog } from '@edx/paragon';
import messages from '../messages';
function Confirmation({
const Confirmation = ({
isOpen,
title,
description,
onClose,
comfirmAction,
closeButtonVaraint,
confirmAction,
closeButtonVariant,
confirmButtonVariant,
confirmButtonText,
}) {
}) => {
const intl = useIntl();
return (
@@ -30,31 +30,31 @@ function Confirmation({
</ModalDialog.Body>
<ModalDialog.Footer>
<ActionRow>
<ModalDialog.CloseButton variant={closeButtonVaraint}>
<ModalDialog.CloseButton variant={closeButtonVariant}>
{intl.formatMessage(messages.confirmationCancel)}
</ModalDialog.CloseButton>
<Button variant={confirmButtonVariant} onClick={comfirmAction}>
<Button variant={confirmButtonVariant} onClick={confirmAction}>
{ confirmButtonText || intl.formatMessage(messages.confirmationConfirm)}
</Button>
</ActionRow>
</ModalDialog.Footer>
</ModalDialog>
);
}
};
Confirmation.propTypes = {
isOpen: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
comfirmAction: PropTypes.func.isRequired,
confirmAction: PropTypes.func.isRequired,
title: PropTypes.string.isRequired,
description: PropTypes.string.isRequired,
closeButtonVaraint: PropTypes.string,
closeButtonVariant: PropTypes.string,
confirmButtonVariant: PropTypes.string,
confirmButtonText: PropTypes.string,
};
Confirmation.defaultProps = {
closeButtonVaraint: 'default',
closeButtonVariant: 'default',
confirmButtonVariant: 'primary',
confirmButtonText: '',
};

View File

@@ -13,12 +13,12 @@ import { PostCommentsContext } from '../post-comments/postCommentsContext';
import AuthorLabel from './AuthorLabel';
import timeLocale from './time-locale';
function EndorsedAlertBanner({
const EndorsedAlertBanner = ({
endorsed,
endorsedAt,
endorsedBy,
endorsedByLabel,
}) {
}) => {
timeago.register('time-locale', timeLocale);
const intl = useIntl();
@@ -43,7 +43,7 @@ function EndorsedAlertBanner({
height: '20px',
}}
/>
<strong className="ml-2 font-family-inter">
<strong className="ml-2">
{intl.formatMessage(isQuestion ? messages.answer : messages.endorsed)}
</strong>
</div>
@@ -62,7 +62,7 @@ function EndorsedAlertBanner({
</Alert>
)
);
}
};
EndorsedAlertBanner.propTypes = {
endorsed: PropTypes.bool.isRequired,

View File

@@ -22,9 +22,7 @@ function buildTestContent(type, buildParams) {
return camelCaseObject(Factory.build(type, { ...buildParamsSnakeCase }, null));
}
function renderComponent(
content, postType,
) {
const renderComponent = (content, postType) => {
render(
<IntlProvider locale="en">
<AppProvider store={store}>
@@ -47,7 +45,7 @@ function renderComponent(
</AppProvider>
</IntlProvider>,
);
}
};
describe.each([
{
@@ -86,7 +84,6 @@ describe.each([
store = initializeStore({
config: {
hasModerationPrivileges: true,
reasonCodesEnabled: true,
},
});
const content = buildTestContent(type, props);

View File

@@ -11,7 +11,7 @@ import {
import {
StarFilled, StarOutline, ThumbUpFilled, ThumbUpOutline,
} from '../../components/icons';
import { useUserCanAddThreadInBlackoutDate } from '../data/hooks';
import { useUserPostingEnabled } from '../data/hooks';
import { PostCommentsContext } from '../post-comments/postCommentsContext';
import ActionsDropdown from './ActionsDropdown';
import { DiscussionContext } from './context';
@@ -31,7 +31,7 @@ const HoverCard = ({
const intl = useIntl();
const { enableInContextSidebar } = useContext(DiscussionContext);
const { isClosed } = useContext(PostCommentsContext);
const userCanAddThreadInBlackoutDate = useUserCanAddThreadInBlackoutDate();
const isUserPrivilegedInPostingRestriction = useUserPostingEnabled();
return (
<div
@@ -39,12 +39,14 @@ const HoverCard = ({
data-testid={`hover-card-${id}`}
id={`hover-card-${id}`}
>
{userCanAddThreadInBlackoutDate && (
{isUserPrivilegedInPostingRestriction && (
<div className="d-flex">
<Button
variant="tertiary"
className={classNames('px-2.5 py-2 border-0 font-style text-gray-700 font-size-12',
{ 'w-100': enableInContextSidebar })}
className={classNames(
'px-2.5 py-2 border-0 font-style text-gray-700 font-size-12',
{ 'w-100': enableInContextSidebar },
)}
onClick={() => handleResponseCommentButton()}
disabled={isClosed}
style={{ lineHeight: '20px' }}
@@ -83,7 +85,7 @@ const HoverCard = ({
iconAs={Icon}
size="sm"
alt="Like"
iconClassNames="like-icon-dimentions"
iconClassNames="like-icon-dimensions"
onClick={(e) => {
e.preventDefault();
onLike();
@@ -97,7 +99,7 @@ const HoverCard = ({
iconAs={Icon}
size="sm"
alt="Follow"
iconClassNames="follow-icon-dimentions"
iconClassNames="follow-icon-dimensions"
onClick={(e) => {
e.preventDefault();
onFollow();
@@ -125,6 +127,7 @@ 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),
onFollow: PropTypes.func,
following: PropTypes.bool,

View File

@@ -3,18 +3,20 @@ 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 { camelCaseObject, initializeMockApp } from '@edx/frontend-platform';
import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
import { initializeStore } from '../../store';
import { executeThunk } from '../../test-utils';
import { getCourseConfigApiUrl } from '../data/api';
import { fetchCourseConfig } from '../data/thunks';
import DiscussionContent from '../discussions-home/DiscussionContent';
import { getCommentsApiUrl } from '../post-comments/data/api';
import { fetchCommentResponses, fetchThreadComments } from '../post-comments/data/thunks';
import { fetchCommentResponses } from '../post-comments/data/thunks';
import { getThreadsApiUrl } from '../posts/data/api';
import { fetchThreads } from '../posts/data/thunks';
import { DiscussionContext } from './context';
@@ -25,45 +27,11 @@ import '../post-comments/data/__factories__';
const commentsApiUrl = getCommentsApiUrl();
const threadsApiUrl = getThreadsApiUrl();
const discussionPostId = 'thread-1';
const questionPostId = 'thread-2';
const courseId = 'course-v1:edX+TestX+Test_Course';
const reverseOrder = true;
const enableInContextSidebar = false;
let store;
let axiosMock;
let container;
async function mockAxiosReturnPagedComments() {
const endorsedArray = [null, false, true];
const pageArray = [1, 2];
endorsedArray.forEach(async (endorsed) => {
const postId = endorsed === null ? discussionPostId : questionPostId;
pageArray.forEach(async (page) => {
const params = {
thread_id: postId,
page,
page_size: undefined,
requested_fields: 'profile_image',
endorsed,
reverse_order: reverseOrder,
enable_in_context_sidebar: enableInContextSidebar,
signal: {},
};
axiosMock.onGet(commentsApiUrl, { ...params }).reply(200, Factory.build('commentsResult', { can_delete: true }, {
threadId: postId,
page,
pageSize: 1,
count: 2,
endorsed,
childCount: page === 1 ? 2 : 0,
}));
await executeThunk(fetchThreadComments(postId, { ...params }), store.dispatch, store.getState);
});
});
}
async function mockAxiosReturnPagedCommentsResponses() {
const parentId = 'comment-1';
const commentsResponsesApiUrl = `${commentsApiUrl}${parentId}/`;
@@ -75,13 +43,15 @@ async function mockAxiosReturnPagedCommentsResponses() {
};
[1, 2].forEach(async (page) => {
axiosMock.onGet(commentsResponsesApiUrl, { params: { ...paramsTemplate, page } }).reply(200,
axiosMock.onGet(commentsResponsesApiUrl, { params: { ...paramsTemplate, page } }).reply(
200,
Factory.build('commentsResult', null, {
parentId,
page,
pageSize: 1,
count: 2,
}));
}),
);
await executeThunk(fetchCommentResponses(parentId), store.dispatch, store.getState);
});
@@ -90,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>
@@ -116,6 +83,7 @@ describe('HoverCard', () => {
username: 'abc123',
administrator: true,
roles: [],
isPostingEnabled: true,
},
});
@@ -123,26 +91,17 @@ describe('HoverCard', () => {
Factory.resetAll();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock.onGet(threadsApiUrl).reply(200, Factory.build('threadsResult'));
axiosMock.onPatch(new RegExp(`${commentsApiUrl}*`)).reply(({ url, data }) => {
const commentId = url.match(/comments\/(?<id>[a-z1-9-]+)\//).groups.id;
const { rawBody } = camelCaseObject(JSON.parse(data));
return [200, Factory.build('comment', {
id: commentId,
rendered_body: rawBody,
raw_body: rawBody,
})];
});
axiosMock.onPost(commentsApiUrl).reply(({ data }) => {
const { rawBody, threadId } = camelCaseObject(JSON.parse(data));
return [200, Factory.build('comment', {
rendered_body: rawBody,
raw_body: rawBody,
thread_id: threadId,
})];
});
axiosMock.onGet(`${getCourseConfigApiUrl()}${courseId}/`).reply(200, { isPostingEnabled: true });
axiosMock.onGet(commentsApiUrl).reply(200, Factory.build('commentsResult', { can_delete: true }, {
threadId: discussionPostId,
endorsed: false,
pageSize: 1,
count: 2,
childCount: 2,
}));
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
await executeThunk(fetchThreads(courseId), store.dispatch, store.getState);
await mockAxiosReturnPagedComments();
await mockAxiosReturnPagedCommentsResponses();
});

View File

@@ -4,7 +4,9 @@ import {
} from 'react';
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';
@@ -22,15 +24,13 @@ import tourCheckpoints from '../tours/constants';
import { selectTours } from '../tours/data/selectors';
import { updateTourShowStatus } from '../tours/data/thunks';
import messages from '../tours/messages';
import { discussionsPath, inBlackoutDateRange } from '../utils';
import { discussionsPath } from '../utils';
import {
selectAreThreadsFiltered,
selectBlackoutDate,
selectEnableInContext,
selectIsCourseAdmin,
selectIsCourseStaff,
selectLearnersTabEnabled,
selectModerationSettings,
selectIsPostingEnabled,
selectPostThreadCount,
selectUserHasModerationPrivileges,
selectUserIsGroupTa,
@@ -40,27 +40,31 @@ import { fetchCourseConfig } from './thunks';
export function useTotalTopicThreadCount() {
const topics = useSelector(selectTopics);
const count = useMemo(() => (
Object.keys(topics)?.reduce((total, topicId) => {
const topic = topics[topicId];
return total + topic.threadCounts.discussion + topic.threadCounts.question;
}, 0)),
[]);
const count = useMemo(
() => (
Object.keys(topics)?.reduce((total, topicId) => {
const topic = topics[topicId];
return total + topic.threadCounts.discussion + topic.threadCounts.question;
}, 0)),
[],
);
return count;
}
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;
}
@@ -83,7 +87,7 @@ export function useCourseDiscussionData(courseId) {
export function useRedirectToThread(courseId, enableInContextSidebar) {
const dispatch = useDispatch();
const history = useHistory();
const navigate = useNavigate();
const location = useLocation();
const redirectToThread = useSelector(
@@ -100,7 +104,7 @@ export function useRedirectToThread(courseId, enableInContextSidebar) {
postId: redirectToThread.threadId,
topicId: redirectToThread.topicId,
})(location);
history.push(newLocation);
navigate({ ...newLocation });
}
}, [redirectToThread]);
}
@@ -157,18 +161,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.
@@ -190,17 +191,16 @@ export const useCurrentDiscussionTopic = () => {
return null;
};
export const useUserCanAddThreadInBlackoutDate = () => {
const blackoutDateRange = useSelector(selectBlackoutDate);
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 isInBlackoutDateRange = useMemo(() => inBlackoutDateRange(blackoutDateRange), [blackoutDateRange]);
return (!(isInBlackoutDateRange) || (isPrivileged));
return (isPostingEnabled || isPrivileged);
};
function camelToConstant(string) {
@@ -222,7 +222,7 @@ export const useTourConfiguration = () => {
), []);
const toursConfig = useMemo(() => (
tours?.map((tour) => (
tours?.map((tour) => Object.keys(tourCheckpoints(intl)).includes(tour.tourName) && (
{
tourId: tour.tourName,
advanceButtonText: intl.formatMessage(messages.advanceButtonText),

View File

@@ -11,7 +11,7 @@ import { initializeStore } from '../../store';
import { executeThunk } from '../../test-utils';
import { DiscussionContext } from '../common/context';
import { getCourseConfigApiUrl } from './api';
import { useCurrentDiscussionTopic, useUserCanAddThreadInBlackoutDate } from './hooks';
import { useCurrentDiscussionTopic, useUserPostingEnabled } from './hooks';
import { fetchCourseConfig } from './thunks';
const courseId = 'course-v1:edX+TestX+Test_Course';
@@ -19,8 +19,8 @@ const courseConfigApiUrl = getCourseConfigApiUrl();
let store;
let axiosMock;
const generateApiResponse = (blackouts = [], isCourseAdmin = false) => ({
blackouts,
const generateApiResponse = (isPostingEnabled, isCourseAdmin = false) => ({
isPostingEnabled,
hasModerationPrivileges: false,
isGroupTa: false,
isCourseAdmin,
@@ -30,14 +30,14 @@ const generateApiResponse = (blackouts = [], isCourseAdmin = false) => ({
describe('Hooks', () => {
describe('useCurrentDiscussionTopic', () => {
function ComponentWithHook() {
const ComponentWithHook = () => {
const topic = useCurrentDiscussionTopic();
return (
<div>
{String(topic)}
</div>
);
}
};
function renderComponent({ topicId, category }) {
return render(
@@ -102,15 +102,15 @@ describe('Hooks', () => {
});
});
describe('useUserCanAddThreadInBlackoutDate', () => {
function ComponentWithHook() {
const userCanAddThreadInBlackoutDate = useUserCanAddThreadInBlackoutDate();
describe('useUserPostingEnabled', () => {
const ComponentWithHook = () => {
const isUserPrivilegedInPostingRestriction = useUserPostingEnabled();
return (
<div>
{String(userCanAddThreadInBlackoutDate)}
{String(isUserPrivilegedInPostingRestriction)}
</div>
);
}
};
function renderComponent() {
return render(
@@ -121,7 +121,7 @@ describe('Hooks', () => {
</IntlProvider>,
);
}
describe('User can add Thread in blackoutdates ', () => {
describe('User can add Thread in Posting Restrictions ', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
@@ -136,37 +136,34 @@ describe('Hooks', () => {
store = initializeStore();
});
test('when blackoutdates are not active and Role is Learner return true', async () => {
test('when posting is not disabled and Role is Learner return true', async () => {
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`)
.reply(200, generateApiResponse([], false));
.reply(200, generateApiResponse(true, false));
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
const { queryByText } = renderComponent();
expect(queryByText('true')).toBeInTheDocument();
});
test('when blackoutdates are not active and Role is not Learner return true', async () => {
test('when posting is not disabled and Role is not Learner return true', async () => {
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`)
.reply(200, generateApiResponse([], true));
.reply(200, generateApiResponse(true, true));
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
const { queryByText } = renderComponent();
expect(queryByText('true')).toBeInTheDocument();
});
test('when blackoutdates are active and Role is Learner return false', async () => {
test('when posting is disabled and Role is Learner return false', async () => {
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`)
.reply(200, generateApiResponse([{
start: '2022-11-25T00:00:00Z',
end: '2050-11-25T23:59:00Z',
}], false));
.reply(200, generateApiResponse(false, false));
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
const { queryByText } = renderComponent();
expect(queryByText('false')).toBeInTheDocument();
});
test('when blackoutdates are active and Role is not Learner return true', async () => {
test('when posting is not disabled and Role is not Learner return true', async () => {
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`)
.reply(200, generateApiResponse([
{ start: '2022-11-25T00:00:00Z', end: '2050-11-25T23:59:00Z' }], true));
.reply(200, generateApiResponse(false, true));
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
const { queryByText } = renderComponent();
expect(queryByText('true')).toBeInTheDocument();
});

View File

@@ -12,16 +12,12 @@ export const selectUserIsStaff = state => state.config.isUserAdmin;
export const selectUserIsGroupTa = state => state.config.isGroupTa;
export const selectconfigLoadingStatus = state => state.config.status;
export const selectLearnersTabEnabled = state => state.config.learnersTabEnabled;
export const selectConfigLoadingStatus = state => state.config.status;
export const selectUserRoles = state => state.config.userRoles;
export const selectDivisionSettings = state => state.config.settings;
export const selectBlackoutDate = state => state.config.blackouts;
export const selectGroupAtSubsection = state => state.config.groupAtSubsection;
export const selectIsCourseAdmin = state => state.config.isCourseAdmin;
@@ -30,10 +26,11 @@ export const selectIsCourseStaff = state => state.config.isCourseStaff;
export const selectEnableInContext = state => state.config.enableInContext;
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;

View File

@@ -7,7 +7,6 @@ const configSlice = createSlice({
name: 'config',
initialState: {
status: RequestStatus.IN_PROGRESS,
blackouts: [],
allowAnonymous: false,
allowAnonymousToPeers: false,
userRoles: [],
@@ -17,14 +16,13 @@ const configSlice = createSlice({
isCourseAdmin: false,
isCourseStaff: false,
isUserAdmin: false,
learnersTabEnabled: false,
isPostingEnabled: false,
settings: {
divisionScheme: 'none',
alwaysDivideInlineDiscussions: false,
dividedInlineDiscussions: [],
dividedCourseWideDiscussions: [],
},
reasonCodesEnabled: false,
editReasons: [],
postCloseReasons: [],
enableInContext: false,

View File

@@ -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'));
@@ -16,20 +16,20 @@ const DiscussionContent = () => {
<div className="d-flex bg-light-400 flex-column w-75 w-xs-100 w-xl-75 align-items-center">
<div className="d-flex flex-column w-100">
<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>

View File

@@ -6,18 +6,16 @@ import PropTypes from 'prop-types';
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 { useWindowSize } from '@edx/paragon';
import Spinner from '../../components/Spinner';
import { RequestStatus, Routes } from '../../data/constants';
import { RequestStatus, Routes as ROUTES } from '../../data/constants';
import { DiscussionContext } from '../common/context';
import {
useContainerSize, useIsOnDesktop, useIsOnXLDesktop, useShowLearnersTab,
} from '../data/hooks';
import { selectconfigLoadingStatus, selectEnableInContext } from '../data/selectors';
import { useContainerSize, useIsOnDesktop, useIsOnXLDesktop } from '../data/hooks';
import { selectConfigLoadingStatus, selectEnableInContext } from '../data/selectors';
const TopicPostsView = lazy(() => import('../in-context-topics/TopicPostsView'));
const InContextTopicsView = lazy(() => import('../in-context-topics/TopicsView'));
@@ -27,13 +25,11 @@ const PostsView = lazy(() => import('../posts/PostsView'));
const LegacyTopicsView = lazy(() => import('../topics/TopicsView'));
const DiscussionSidebar = ({ displaySidebar, postActionBarRef }) => {
const location = useLocation();
const isOnDesktop = useIsOnDesktop();
const isOnXLDesktop = useIsOnXLDesktop();
const { enableInContextSidebar } = useContext(DiscussionContext);
const enableInContext = useSelector(selectEnableInContext);
const configStatus = useSelector(selectconfigLoadingStatus);
const redirectToLearnersTab = useShowLearnersTab();
const configStatus = useSelector(selectConfigLoadingStatus);
const sidebarRef = useRef(null);
const postActionBarHeight = useContainerSize(postActionBarRef);
const { height: windowHeight } = useWindowSize();
@@ -62,47 +58,58 @@ const DiscussionSidebar = ({ displaySidebar, postActionBarRef }) => {
data-testid="sidebar"
>
<Suspense fallback={(<Spinner />)}>
<Switch>
<Routes>
{enableInContext && !enableInContextSidebar && (
<Route
path={Routes.TOPICS.ALL}
component={InContextTopicsView}
exact
/>
<Route
path={ROUTES.TOPICS.ALL}
element={<InContextTopicsView />}
/>
)}
{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} />
[
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 && (
<Redirect
from={Routes.DISCUSSIONS.PATH}
to={{
...location,
pathname: Routes.POSTS.ALL_POSTS,
}}
/>
<Route path={`${ROUTES.DISCUSSIONS.PATH}/*`} element={<Navigate to="posts" />} />
)}
</Switch>
</Routes>
</Suspense>
</div>
);

View File

@@ -3,7 +3,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';
@@ -28,8 +28,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>

View File

@@ -1,20 +1,20 @@
/* eslint-disable react/jsx-no-constructed-context-values */
import React, { lazy, Suspense, useRef } from 'react';
import classNames from 'classnames';
import { useSelector } from 'react-redux';
import {
Route, Switch, useLocation, useRouteMatch,
} from 'react-router';
matchPath, Route, Routes, useLocation, useMatch,
} from 'react-router-dom';
import { LearningHeader as Header } from '@edx/frontend-component-header';
import { getConfig } from '@edx/frontend-platform';
import { Spinner } from '../../components';
import { selectCourseTabs } from '../../components/NavigationBar/data/selectors';
import { ALL_ROUTES, DiscussionProvider, Routes } from '../../data/constants';
import { ALL_ROUTES, DiscussionProvider, Routes as ROUTES } from '../../data/constants';
import { DiscussionContext } from '../common/context';
import {
useCourseDiscussionData, useIsOnDesktop, useRedirectToThread, useShowLearnersTab, useSidebarVisible,
useCourseDiscussionData, useIsOnDesktop, useRedirectToThread, useSidebarVisible,
} from '../data/hooks';
import { selectDiscussionProvider, selectEnableInContext } from '../data/selectors';
import { EmptyLearners, EmptyPosts, EmptyTopics } from '../empty-posts';
@@ -29,10 +29,9 @@ const CourseTabsNavigation = lazy(() => import('../../components/NavigationBar/C
const LegacyBreadcrumbMenu = lazy(() => import('../navigation/breadcrumb-menu/LegacyBreadcrumbMenu'));
const NavigationBar = lazy(() => import('../navigation/navigation-bar/NavigationBar'));
const DiscussionsProductTour = lazy(() => import('../tours/DiscussionsProductTour'));
const BlackoutInformationBanner = lazy(() => import('./BlackoutInformationBanner'));
const DiscussionsRestrictionBanner = lazy(() => import('./DiscussionsRestrictionBanner'));
const DiscussionContent = lazy(() => import('./DiscussionContent'));
const DiscussionSidebar = lazy(() => import('./DiscussionSidebar'));
const InformationBanner = lazy(() => import('./InformationBanner'));
const DiscussionsHome = () => {
const location = useLocation();
@@ -41,13 +40,13 @@ const DiscussionsHome = () => {
const provider = useSelector(selectDiscussionProvider);
const enableInContext = useSelector(selectEnableInContext);
const { courseNumber, courseTitle, org } = useSelector(selectCourseTabs);
const { params: { page } } = useRouteMatch(`${Routes.COMMENTS.PAGE}?`);
const { params } = useRouteMatch(ALL_ROUTES);
const isRedirectToLearners = useShowLearnersTab();
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 isOnDesktop = useIsOnDesktop();
let displaySidebar = useSidebarVisible();
const enableInContextSidebar = Boolean(new URLSearchParams(location.search).get('inContextSidebar') !== null);
const isFeedbackBannerVisible = getConfig().DISPLAY_FEEDBACK_BANNER === 'true';
const {
courseId, postId, topicId, category, learnerUsername,
} = params;
@@ -57,7 +56,7 @@ const DiscussionsHome = () => {
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, onlyshow the sidebar if the content area isn't displayed. */
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 = isOnDesktop; }
@@ -86,7 +85,7 @@ const DiscussionsHome = () => {
>
<div
className={classNames('d-flex flex-row justify-content-between navbar fixed-top', {
'pl-4 pr-3 py-0': enableInContextSidebar,
'pl-4 pr-2 py-0': enableInContextSidebar,
})}
>
{!enableInContextSidebar && (
@@ -94,16 +93,27 @@ const DiscussionsHome = () => {
)}
<PostActionsBar />
</div>
{isFeedbackBannerVisible && <InformationBanner />}
<BlackoutInformationBanner />
<DiscussionsRestrictionBanner />
</div>
{provider === DiscussionProvider.LEGACY && (
<Suspense fallback={(<Spinner />)}>
<Route
path={[Routes.POSTS.PATH, Routes.TOPICS.CATEGORY]}
component={LegacyBreadcrumbMenu}
/>
</Suspense>
<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>
)}
<div className="d-flex flex-row position-relative">
<Suspense fallback={(<Spinner />)}>
@@ -115,21 +125,29 @@ const DiscussionsHome = () => {
</Suspense>
)}
{!displayContentArea && (
<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>
<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 && (

View File

@@ -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';
@@ -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>
@@ -127,7 +127,7 @@ describe('DiscussionsHome', () => {
test('header, course navigation bar and footer are only visible in Discussions MFE', async () => {
renderComponent();
expect(screen.queryByRole('banner')).toBeInTheDocument();
waitFor(() => expect(screen.queryByRole('banner')).toBeInTheDocument());
expect(document.getElementById('courseTabsNavigation')).toBeInTheDocument();
expect(screen.queryByRole('contentinfo')).toBeInTheDocument();
});
@@ -172,7 +172,8 @@ describe('DiscussionsHome', () => {
it.each([
{ searchByEndPoint: 'category/section-topic-1' },
{ searchByEndPoint: 'topics' },
])('should display No Topic selected message on inContext topic pages when user has yet to select a topic %s',
])(
'should display No Topic selected message on inContext topic pages when user has yet to select a topic %s',
async ({ searchByEndPoint }) => {
axiosMock.onGet(getDiscussionsConfigUrl(courseId)).reply(200, {
enableInContext: true, provider: 'openedx', hasModerationPrivileges: true,
@@ -193,12 +194,11 @@ describe('DiscussionsHome', () => {
await renderComponent(`/${courseId}/${searchByEndPoint}`);
expect(screen.queryByText('No topic selected')).toBeInTheDocument();
});
},
);
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`);

View File

@@ -1,23 +1,20 @@
import React, { useCallback, useMemo, useState } from 'react';
import React, { useCallback, useState } from 'react';
import { useSelector } from 'react-redux';
import { useIntl } from '@edx/frontend-platform/i18n';
import { PageBanner } from '@edx/paragon';
import { selectBlackoutDate } from '../data/selectors';
import { RequestStatus } from '../../data/constants';
import { selectConfigLoadingStatus, selectIsPostingEnabled } from '../data/selectors';
import messages from '../messages';
import { inBlackoutDateRange } from '../utils';
const BlackoutInformationBanner = () => {
const DiscussionsRestrictionBanner = () => {
const intl = useIntl();
const blackoutDate = useSelector(selectBlackoutDate);
const isPostingEnabled = useSelector(selectIsPostingEnabled);
const configLoadingStatus = useSelector(selectConfigLoadingStatus);
const [showBanner, setShowBanner] = useState(true);
const isDiscussionsBlackout = useMemo(() => (
inBlackoutDateRange(blackoutDate)
), [blackoutDate]);
const handleDismiss = useCallback(() => {
setShowBanner(false);
}, []);
@@ -25,7 +22,7 @@ const BlackoutInformationBanner = () => {
return (
<PageBanner
variant="accentB"
show={isDiscussionsBlackout && showBanner}
show={!isPostingEnabled && showBanner && configLoadingStatus === RequestStatus.SUCCESSFUL}
dismissible
onDismiss={handleDismiss}
>
@@ -36,4 +33,4 @@ const BlackoutInformationBanner = () => {
);
};
export default BlackoutInformationBanner;
export default DiscussionsRestrictionBanner;

View File

@@ -8,7 +8,7 @@ import { initializeStore } from '../../store';
import { DiscussionContext } from '../common/context';
import { fetchConfigSuccess } from '../data/slices';
import messages from '../messages';
import BlackoutInformationBanner from './BlackoutInformationBanner';
import DiscussionsRestrictionBanner from './DiscussionsRestrictionBanner';
let store;
let container;
@@ -20,13 +20,13 @@ activeEndDate.setDate(activeEndDate.getDate() + 2);
activeStartDate = activeStartDate.toISOString();
activeEndDate = activeEndDate.toISOString();
const getConfigData = (blackouts = []) => ({
const getConfigData = (isPostingEnabled) => ({
id: 'course-v1:edX+DemoX+Demo_Course',
userRoles: ['Admin', 'Student'],
hasModerationPrivileges: false,
isGroupTa: false,
isUserAdmin: false,
blackouts,
isPostingEnabled,
});
function renderComponent() {
@@ -34,7 +34,7 @@ function renderComponent() {
<IntlProvider locale="en">
<AppProvider store={store}>
<DiscussionContext.Provider value={{ courseId }}>
<BlackoutInformationBanner />
<DiscussionsRestrictionBanner />
</DiscussionContext.Provider>
</AppProvider>
</IntlProvider>,
@@ -43,7 +43,7 @@ function renderComponent() {
return container;
}
describe('Blackout Information Banner', () => {
describe('Discussions Restriction Banner', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
@@ -56,13 +56,11 @@ describe('Blackout Information Banner', () => {
});
test.each([
{ blackouts: [], visibility: false },
{ blackouts: ['2021-12-31T10:15', '2021-12-31T10:20'], visibility: false },
{ blackouts: [{ start: activeStartDate, end: activeEndDate }], visibility: true },
{ blackouts: [{ start: activeEndDate, end: activeEndDate }], visibility: false },
])('Test Blackout Banner is visible on app load if blackout date is active', async ({ blackouts, visibility }) => {
{ isPostingEnabled: false, visibility: true },
{ isPostingEnabled: true, visibility: false },
])('Test Discussions Restriction is visible on app load if posting is disabled', async ({ isPostingEnabled, visibility }) => {
store = initializeStore();
await store.dispatch(fetchConfigSuccess(getConfigData(blackouts)));
await store.dispatch(fetchConfigSuccess(getConfigData(isPostingEnabled)));
renderComponent();
if (visibility) {
const element = await screen.findByRole('alert');

View File

@@ -2,11 +2,12 @@ import { useEffect } from 'react';
import { useSelector } from 'react-redux';
import { getConfig } from '@edx/frontend-platform';
import { logError } from '@edx/frontend-platform/logging';
import { RequestStatus } from '../../data/constants';
import {
selectconfigLoadingStatus,
selectConfigLoadingStatus,
selectIsCourseAdmin,
selectIsCourseStaff,
selectUserIsGroupTa,
@@ -18,13 +19,13 @@ export default function useFeedbackWrapper() {
const isUserGroupTA = useSelector(selectUserIsGroupTa);
const isCourseAdmin = useSelector(selectIsCourseAdmin);
const isCourseStaff = useSelector(selectIsCourseStaff);
const configStatus = useSelector(selectconfigLoadingStatus);
const configStatus = useSelector(selectConfigLoadingStatus);
useEffect(() => {
if (configStatus === RequestStatus.SUCCESSFUL) {
let url = '//w.usabilla.com/9e6036348fa1.js';
let url = getConfig().LEARNER_FEEDBACK_URL;
if (isStaff || isUserGroupTA || isCourseAdmin || isCourseStaff) {
url = '//w.usabilla.com/767740a06856.js';
url = getConfig().STAFF_FEEDBACK_URL;
}
try {
// eslint-disable-next-line no-undef

View File

@@ -1,63 +0,0 @@
import React, { useCallback, useState } from 'react';
import { useSelector } from 'react-redux';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Hyperlink, PageBanner } from '@edx/paragon';
import { selectUserIsStaff, selectUserRoles } from '../data/selectors';
import messages from '../messages';
const InformationBanner = () => {
const intl = useIntl();
const [showBanner, setShowBanner] = useState(true);
const userRoles = useSelector(selectUserRoles);
const isAdmin = useSelector(selectUserIsStaff);
const learnMoreLink = 'https://openedx.atlassian.net/wiki/spaces/COMM/pages/3509551260/Overview+New+discussions+experience';
const TAFeedbackLink = process.env.TA_FEEDBACK_FORM;
const staffFeedbackLink = process.env.STAFF_FEEDBACK_FORM;
const hideLearnMoreButton = ((userRoles.includes('Student') && userRoles.length === 1) || !userRoles.length) && !isAdmin;
const showStaffLink = isAdmin || userRoles.includes('Moderator') || userRoles.includes('Administrator');
const handleDismiss = useCallback(() => {
setShowBanner(false);
}, []);
return (
<PageBanner
variant="light"
show={showBanner}
dismissible
onDismiss={handleDismiss}
>
<div className="font-weight-500">
{intl.formatMessage(messages.bannerMessage)}
{!hideLearnMoreButton
&& (
<Hyperlink
destination={learnMoreLink}
target="_blank"
showLaunchIcon={false}
className="pl-2.5"
variant="muted"
isInline
>
{intl.formatMessage(messages.learnMoreBannerLink)}
</Hyperlink>
)}
<Hyperlink
destination={showStaffLink ? staffFeedbackLink : TAFeedbackLink}
target="_blank"
showLaunchIcon={false}
variant="muted"
className="pl-2.5"
isInline
>
{intl.formatMessage(messages.shareFeedback)}
</Hyperlink>
</div>
</PageBanner>
);
};
export default InformationBanner;

View File

@@ -1,136 +0,0 @@
import { render, screen } from '@testing-library/react';
import { IntlProvider } from 'react-intl';
import { initializeMockApp } from '@edx/frontend-platform';
import { AppProvider } from '@edx/frontend-platform/react';
import { initializeStore } from '../../store';
import { DiscussionContext } from '../common/context';
import { fetchConfigSuccess } from '../data/slices';
import messages from '../messages';
import InformationBanner from './InformationBanner';
import '../posts/data/__factories__';
let store;
let container;
const courseId = 'course-v1:edX+DemoX+Demo_Course';
const getConfigData = (isAdmin = true, roles = []) => ({
id: 'course-v1:edX+DemoX+Demo_Course',
userRoles: roles,
hasModerationPrivileges: false,
isGroupTa: false,
isUserAdmin: isAdmin,
});
function renderComponent() {
const wrapper = render(
<IntlProvider locale="en">
<AppProvider store={store}>
<DiscussionContext.Provider value={{ courseId }}>
<InformationBanner />
</DiscussionContext.Provider>
</AppProvider>
</IntlProvider>,
);
container = wrapper.container;
return container;
}
describe('Information Banner learner view', () => {
let element;
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: false,
roles: ['Student'],
},
});
store = initializeStore();
store.dispatch(fetchConfigSuccess(getConfigData(false, ['Student'])));
renderComponent(true);
element = await screen.findByRole('alert');
});
test('Test Banner is visible on app load', async () => {
expect(element).toHaveTextContent(messages.bannerMessage.defaultMessage);
});
test('Test Banner do not have learn more button', async () => {
expect(element).not.toHaveTextContent(messages.learnMoreBannerLink.defaultMessage);
});
test('Test Banner has share feedback button', async () => {
expect(element).toHaveTextContent(messages.shareFeedback.defaultMessage);
});
});
describe('Information Banner moderators/staff/admin view', () => {
let element;
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
store.dispatch(fetchConfigSuccess(getConfigData(true, ['Student', 'Moderator'])));
renderComponent(true);
element = await screen.findByRole('alert');
});
test('Test Banner is visible on app load', async () => {
expect(element).toHaveTextContent(messages.bannerMessage.defaultMessage);
});
test('Test Banner has learn more button', async () => {
expect(element).toHaveTextContent(messages.learnMoreBannerLink.defaultMessage);
});
test('Test Banner has share feedback button', async () => {
expect(element).toHaveTextContent(messages.shareFeedback.defaultMessage);
});
});
describe('User is redirected according to url according to role', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
});
test('TAs are redirected to learners feedback form', async () => {
store.dispatch(fetchConfigSuccess(getConfigData(false, ['Student', 'Community TA'])));
renderComponent(true);
expect(screen.getByText(messages.shareFeedback.defaultMessage)
.closest('a'))
.toHaveAttribute('href', process.env.TA_FEEDBACK_FORM);
});
test('moderators/administrators are redirected to moderators feedback form', async () => {
store.dispatch(fetchConfigSuccess(getConfigData(false, ['Student', 'Moderator', 'Administrator'])));
renderComponent(true);
expect(screen.getByText(messages.shareFeedback.defaultMessage)
.closest('a'))
.toHaveAttribute('href', process.env.STAFF_FEEDBACK_FORM);
});
test('user with only isAdmin true are redirected to moderators feedback form', async () => {
store.dispatch(fetchConfigSuccess(getConfigData(true, ['Student'])));
renderComponent(true);
expect(screen.getByText(messages.shareFeedback.defaultMessage)
.closest('a'))
.toHaveAttribute('href', process.env.STAFF_FEEDBACK_FORM);
});
});

View File

@@ -8,6 +8,7 @@ import { useIntl } from '@edx/frontend-platform/i18n';
import { useIsOnDesktop } from '../data/hooks';
import { selectAreThreadsFiltered, selectPostThreadCount } from '../data/selectors';
import messages from '../messages';
// eslint-disable-next-line import/no-cycle
import { messages as postMessages, showPostEditor } from '../posts';
import EmptyPage from './EmptyPage';

View File

@@ -26,7 +26,7 @@ function renderComponent(location = `/${courseId}/`) {
return render(
<IntlProvider locale="en">
<ResponsiveContext.Provider value={{ width: 1280 }}>
<AppProvider store={store}>
<AppProvider store={store} wrapWithRouter={false}>
<MemoryRouter initialEntries={[location]}>
<EmptyPosts subTitleMessage={messages.emptyMyPosts} />
</MemoryRouter>
@@ -57,7 +57,7 @@ describe('EmptyPage', () => {
store = initializeStore();
});
test('"posts youve interacted with" message shown when no posts in system', async () => {
test('"posts you\'ve interacted with" message shown when no posts in system', async () => {
renderComponent(`/${courseId}/my-posts/`);
expect(
screen.queryByText(messages.emptyMyPosts.defaultMessage),

View File

@@ -1,24 +1,24 @@
import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useRouteMatch } from 'react-router';
import { useParams } from 'react-router-dom';
import { useIntl } from '@edx/frontend-platform/i18n';
import { ALL_ROUTES } from '../../data/constants';
import { useIsOnDesktop, useTotalTopicThreadCount } from '../data/hooks';
import { selectTopicThreadCount } from '../data/selectors';
import messages from '../messages';
// eslint-disable-next-line import/no-cycle
import { messages as postMessages, showPostEditor } from '../posts';
import EmptyPage from './EmptyPage';
const EmptyTopics = () => {
const intl = useIntl();
const match = useRouteMatch(ALL_ROUTES);
const { topicId } = useParams();
const dispatch = useDispatch();
const isOnDesktop = useIsOnDesktop();
const hasGlobalThreads = useTotalTopicThreadCount() > 0;
const topicThreadCount = useSelector(selectTopicThreadCount(match.params.topicId));
const topicThreadCount = useSelector(selectTopicThreadCount(topicId));
const addPost = useCallback(() => (
dispatch(showPostEditor())
@@ -34,7 +34,7 @@ const EmptyTopics = () => {
return null;
}
if (match.params.topicId) {
if (topicId) {
if (topicThreadCount > 0) {
title = messages.noPostSelected;
} else {

View File

@@ -2,14 +2,14 @@ import { render, screen } from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import { IntlProvider } from 'react-intl';
import { Context as ResponsiveContext } from 'react-responsive';
import { MemoryRouter } from 'react-router';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { Factory } from 'rosie';
import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
import { getApiBaseUrl } from '../../data/constants';
import { getApiBaseUrl, Routes as ROUTES } from '../../data/constants';
import { initializeStore } from '../../store';
import { executeThunk } from '../../test-utils';
import messages from '../messages';
@@ -26,9 +26,12 @@ function renderComponent(location = `/${courseId}/topics/`) {
return render(
<IntlProvider locale="en">
<ResponsiveContext.Provider value={{ width: 1280 }}>
<AppProvider store={store}>
<AppProvider store={store} wrapWithRouter={false}>
<MemoryRouter initialEntries={[location]}>
<EmptyTopics />
<Routes>
<Route path={ROUTES.TOPICS.ALL} element={<EmptyTopics />} />
<Route path={ROUTES.TOPICS.TOPIC} element={<EmptyTopics />} />
</Routes>
</MemoryRouter>
</AppProvider>
</ResponsiveContext.Provider>

View File

@@ -1,4 +1,5 @@
export { default as EmptyLearners } from './EmptyLearners';
export { default as EmptyPage } from './EmptyPage';
// eslint-disable-next-line import/no-cycle
export { default as EmptyPosts } from './EmptyPosts';
export { default as EmptyTopics } from './EmptyTopics';

View File

@@ -4,7 +4,9 @@ import {
import MockAdapter from 'axios-mock-adapter';
import { act } from 'react-dom/test-utils';
import { IntlProvider } from 'react-intl';
import { generatePath, MemoryRouter, Route } from 'react-router';
import {
generatePath, MemoryRouter, Route, Routes, useLocation,
} from 'react-router-dom';
import { Factory } from 'rosie';
import { initializeMockApp } from '@edx/frontend-platform';
@@ -12,7 +14,7 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
import { PostActionsBar } from '../../components';
import { Routes } from '../../data/constants';
import { Routes as ROUTES } from '../../data/constants';
import { initializeStore } from '../../store';
import { executeThunk } from '../../test-utils';
import { DiscussionContext } from '../common/context';
@@ -35,16 +37,21 @@ let axiosMock;
let lastLocation;
let container;
const LocationComponent = () => {
lastLocation = useLocation();
return null;
};
async function renderComponent({ topicId, category } = { }) {
let path = `/${courseId}/topics`;
if (topicId) {
path = generatePath(Routes.POSTS.PATH, { courseId, topicId });
path = generatePath(ROUTES.POSTS.PATH, { courseId, topicId });
} else if (category) {
path = generatePath(Routes.TOPICS.CATEGORY, { courseId, category });
path = generatePath(ROUTES.TOPICS.CATEGORY, { courseId, category });
}
const wrapper = await render(
<IntlProvider locale="en">
<AppProvider store={store}>
<AppProvider store={store} wrapWithRouter={false}>
<DiscussionContext.Provider value={{
courseId,
topicId,
@@ -53,19 +60,35 @@ async function renderComponent({ topicId, category } = { }) {
}}
>
<MemoryRouter initialEntries={[path]}>
<Route exact path={[Routes.POSTS.PATH, Routes.TOPICS.CATEGORY]}>
<TopicPostsView />
</Route>
<Route exact path={[Routes.TOPICS.ALL]}>
<PostActionsBar />
<TopicsView />
</Route>
<Route
render={({ location }) => {
lastLocation = location;
return null;
}}
/>
<Routes>
{
[
ROUTES.POSTS.PATH,
ROUTES.TOPICS.CATEGORY,
].map((route) => (
<Route
key={route}
path={route}
element={(
<>
<TopicPostsView />
<LocationComponent />
</>
)}
/>
))
}
<Route
path={ROUTES.TOPICS.ALL}
element={(
<>
<PostActionsBar />
<TopicsView />
<LocationComponent />
</>
)}
/>
</Routes>
</MemoryRouter>
</DiscussionContext.Provider>
</AppProvider>
@@ -92,7 +115,6 @@ describe('InContext Topic Posts View', () => {
enableInContext: true,
provider: 'openedx',
hasModerationPrivileges: true,
blackouts: [],
},
});
Factory.resetAll();
@@ -206,7 +228,8 @@ describe('InContext Topic Posts View', () => {
test.each([
{ searchText: 'hello world', output: 'Showing 0 results for', resultCount: 0 },
{ searchText: 'introduction', output: 'Showing 8 results for', resultCount: 8 },
])('It should have a search bar with a clear button and \'$output\' results found text.',
])(
'It should have a search bar with a clear button and \'$output\' results found text.',
async ({ searchText, output, resultCount }) => {
await setupTopicsMockResponse();
await renderComponent();
@@ -226,7 +249,8 @@ describe('InContext Topic Posts View', () => {
expect(clearButton).toBeInTheDocument();
expect(units).toHaveLength(resultCount);
});
});
},
);
it('When click on the clear button it should move to main topics pages.', async () => {
await setupTopicsMockResponse();
@@ -253,7 +277,8 @@ describe('InContext Topic Posts View', () => {
});
});
it('should display Nothing here yet and No topic exists message when topics and selectedSubsectionUnits are empty',
it(
'should display Nothing here yet and No topic exists message when topics and selectedSubsectionUnits are empty',
async () => {
await setupTopicsMockResponse(0, 0, 0);
await renderComponent({ topicId: 'test-topic', category: 'test-category' });
@@ -261,7 +286,8 @@ describe('InContext Topic Posts View', () => {
await waitFor(() => expect(within(container).queryByText('Nothing here yet')).toBeInTheDocument());
expect(within(container).queryByText('No topic exists')).toBeInTheDocument();
expect(within(container).queryByText('Unnamed Topic')).toBeInTheDocument();
});
},
);
it('should display all topics when search by an empty search string', async () => {
await setupTopicsMockResponse();

View File

@@ -5,7 +5,9 @@ import {
import MockAdapter from 'axios-mock-adapter';
import { act } from 'react-dom/test-utils';
import { IntlProvider } from 'react-intl';
import { MemoryRouter, Route } from 'react-router';
import {
MemoryRouter, Route, Routes, useLocation,
} from 'react-router-dom';
import { Factory } from 'rosie';
import { initializeMockApp } from '@edx/frontend-platform';
@@ -32,24 +34,21 @@ let axiosMock;
let lastLocation;
let container;
const LocationComponent = () => {
lastLocation = useLocation();
return null;
};
function renderComponent() {
const wrapper = render(
<IntlProvider locale="en">
<AppProvider store={store}>
<AppProvider store={store} wrapWithRouter={false}>
<DiscussionContext.Provider value={{ courseId, category }}>
<MemoryRouter initialEntries={[`/${courseId}/topics/`]}>
<Route path="/:courseId/topics/">
<TopicsView />
</Route>
<Route path="/:courseId/category/:category">
<TopicPostsView />
</Route>
<Route
render={({ location }) => {
lastLocation = location;
return null;
}}
/>
<Routes>
<Route path="/:courseId/topics/*" element={<><TopicsView /><LocationComponent /></>} />
<Route path="/:courseId/category/:category" element={<><TopicPostsView /><LocationComponent /></>} />
</Routes>
</MemoryRouter>
</DiscussionContext.Provider>
</AppProvider>

View File

@@ -1,7 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useHistory } from 'react-router-dom';
import { useNavigate } from 'react-router-dom';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Icon, IconButton, Spinner } from '@edx/paragon';
@@ -9,10 +9,10 @@ import { ArrowBack } from '@edx/paragon/icons';
import messages from '../messages';
function BackButton({
const BackButton = ({
intl, path, title, loading,
}) {
const history = useHistory();
}) => {
const navigate = useNavigate();
return (
<>
@@ -22,7 +22,7 @@ function BackButton({
iconAs={Icon}
style={{ padding: '18px' }}
size="inline"
onClick={() => history.push(path)}
onClick={() => navigate(path)}
alt={intl.formatMessage(messages.backAlt)}
/>
<div className="d-flex flex-fill justify-content-center align-items-center mr-4.5">
@@ -32,7 +32,7 @@ function BackButton({
<div className="border-bottom border-light-400" />
</>
);
}
};
BackButton.propTypes = {
intl: intlShape.isRequired,

View File

@@ -1,11 +1,10 @@
import React, { useCallback, useContext } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useRouteMatch } from 'react-router';
import { useParams } from 'react-router-dom';
import { useIntl } from '@edx/frontend-platform/i18n';
import { ALL_ROUTES } from '../../../data/constants';
import { DiscussionContext } from '../../common/context';
import { useIsOnDesktop } from '../../data/hooks';
import { selectPostThreadCount } from '../../data/selectors';
@@ -16,11 +15,11 @@ import { selectCourseWareThreadsCount, selectTotalTopicsThreadsCount } from '../
const EmptyTopics = () => {
const intl = useIntl();
const match = useRouteMatch(ALL_ROUTES);
const { category, topicId } = useParams();
const dispatch = useDispatch();
const isOnDesktop = useIsOnDesktop();
const { enableInContextSidebar } = useContext(DiscussionContext);
const courseWareThreadsCount = useSelector(selectCourseWareThreadsCount(match.params.category));
const courseWareThreadsCount = useSelector(selectCourseWareThreadsCount(category));
const topicThreadsCount = useSelector(selectPostThreadCount);
// hasGlobalThreads is used to determine if there are any post available in courseware and non-courseware topics
const hasGlobalThreads = useSelector(selectTotalTopicsThreadsCount) > 0;
@@ -39,7 +38,7 @@ const EmptyTopics = () => {
return null;
}
if (match.params.topicId) {
if (topicId) {
if (topicThreadsCount > 0) {
title = messages.noPostSelected;
} else {
@@ -48,7 +47,7 @@ const EmptyTopics = () => {
subTitle = messages.emptyTopic;
fullWidth = true;
}
} else if (match.params.category) {
} else if (category) {
if (enableInContextSidebar && topicThreadsCount > 0) {
title = messages.noPostSelected;
} else if (courseWareThreadsCount > 0) {

View File

@@ -20,12 +20,21 @@ Factory.define('sub-section')
.sequence('id', ['topicPrefix'], (idx, topicPrefix) => `${topicPrefix}-topic-${idx}`)
.sequence('display-name', ['sectionPrefix'], (idx, sectionPrefix) => `Introduction ${sectionPrefix + idx}`)
.option('courseId', null, 'course-v1:edX+DemoX+Demo_Course')
.sequence('legacy_web_url', ['id', 'courseId'],
(idx, id, courseId) => `${getApiBaseUrl}/courses/${courseId}/jump_to/block-v1:${id}?experience=legacy`)
.sequence('lms_web_url', ['id', 'courseId'],
(idx, id, courseId) => `${getApiBaseUrl}/courses/${courseId}/jump_to/block-v1:${id}`)
.sequence('student_view_url', ['id', 'courseId'],
(idx, id) => `${getApiBaseUrl}/xblock/block-v1:${id}`)
.sequence(
'legacy_web_url',
['id', 'courseId'],
(idx, id, courseId) => `${getApiBaseUrl}/courses/${courseId}/jump_to/block-v1:${id}?experience=legacy`,
)
.sequence(
'lms_web_url',
['id', 'courseId'],
(idx, id, courseId) => `${getApiBaseUrl}/courses/${courseId}/jump_to/block-v1:${id}`,
)
.sequence(
'student_view_url',
['id', 'courseId'],
(idx, id) => `${getApiBaseUrl}/xblock/block-v1:${id}`,
)
.attr('type', null, 'sequential')
.attr('activeFlags', null, true)
.attr('thread_counts', ['discussionCount', 'questionCount'], (discCount, questCount) => {
@@ -51,12 +60,21 @@ Factory.define('section')
.attr('courseware', null, true)
.sequence('display-name', (idx) => `Introduction ${idx}`)
.option('courseId', null, 'course-v1:edX+DemoX+Demo_Course')
.sequence('legacy_web_url', ['id', 'courseId'],
(idx, id, courseId) => `${getApiBaseUrl}/courses/${courseId}/jump_to/${courseId.replace('course-v1:', 'block-v1:')}+type@chapter+block@${id}?experience=legacy`)
.sequence('lms_web_url', ['id', 'courseId'],
(idx, id, courseId) => `${getApiBaseUrl}/courses/${courseId}/jump_to/${courseId.replace('course-v1:', 'block-v1:')}+type@chapter+block@${id}`)
.sequence('student_view_url', ['id', 'courseId'],
(idx, id, courseId) => `${getApiBaseUrl}/xblock/${courseId.replace('course-v1:', 'block-v1:')}+type@chapter+block@${id}`)
.sequence(
'legacy_web_url',
['id', 'courseId'],
(idx, id, courseId) => `${getApiBaseUrl}/courses/${courseId}/jump_to/${courseId.replace('course-v1:', 'block-v1:')}+type@chapter+block@${id}?experience=legacy`,
)
.sequence(
'lms_web_url',
['id', 'courseId'],
(idx, id, courseId) => `${getApiBaseUrl}/courses/${courseId}/jump_to/${courseId.replace('course-v1:', 'block-v1:')}+type@chapter+block@${id}`,
)
.sequence(
'student_view_url',
['id', 'courseId'],
(idx, id, courseId) => `${getApiBaseUrl}/xblock/${courseId.replace('course-v1:', 'block-v1:')}+type@chapter+block@${id}`,
)
.attr('type', null, 'chapter')
.attr('children', ['id', 'display-name'], (id, name) => {
Factory.reset('sub-section');

View File

@@ -8,7 +8,7 @@ import { SearchField } from '@edx/paragon';
import { setFilter } from '../data';
import messages from '../messages';
function TopicSearchResultBar({ intl }) {
const TopicSearchResultBar = ({ intl }) => {
const dispatch = useDispatch();
return (
@@ -21,7 +21,7 @@ function TopicSearchResultBar({ intl }) {
/>
</div>
);
}
};
TopicSearchResultBar.propTypes = {
intl: intlShape.isRequired,

View File

@@ -2,8 +2,7 @@ import React, { useCallback, useMemo } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useParams } from 'react-router';
import { Link } from 'react-router-dom';
import { Link, useParams } from 'react-router-dom';
import { useIntl } from '@edx/frontend-platform/i18n';
@@ -41,7 +40,7 @@ const SectionBaseGroup = ({
role="option"
data-subsection-id={subsection.id}
data-testid="subsection-group"
to={sectionUrl(subsection.id)}
to={sectionUrl(subsection.id)()}
onClick={() => isSelected(subsection.id)}
aria-current={isSelected(section.id) ? 'page' : undefined}
tabIndex={(isSelected(subsection.id) || index === 0) ? 0 : -1}

View File

@@ -1,11 +1,11 @@
/* eslint-disable react/prop-types */
/* eslint-disable no-unused-vars, react/forbid-prop-types */
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useSelector } from 'react-redux';
import { useParams } from 'react-router';
import { Link } from 'react-router-dom';
import { Link, useParams } from 'react-router-dom';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Icon, OverlayTrigger, Tooltip } from '@edx/paragon';
@@ -41,7 +41,7 @@ const Topic = ({
'border-light-400 border-bottom': showDivider,
})}
data-topic-id={topic.id}
to={topicUrl}
to={topicUrl()}
onClick={() => isSelected(topic.id)}
aria-current={isSelected(topic.id) ? 'page' : undefined}
role="option"

View File

@@ -4,7 +4,7 @@ import React, {
import capitalize from 'lodash/capitalize';
import { useDispatch, useSelector } from 'react-redux';
import { useHistory, useLocation } from 'react-router-dom';
import { useLocation, useNavigate } from 'react-router-dom';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
@@ -35,7 +35,7 @@ import messages from './messages';
const LearnerPostsView = () => {
const intl = useIntl();
const location = useLocation();
const history = useHistory();
const navigate = useNavigate();
const dispatch = useDispatch();
const postsIds = useSelector(selectAllThreadsIds);
@@ -83,7 +83,7 @@ const LearnerPostsView = () => {
iconAs={Icon}
style={{ padding: '18px' }}
size="inline"
onClick={() => history.push(discussionsPath(Routes.LEARNERS.PATH, { courseId })(location))}
onClick={() => navigate({ ...discussionsPath(Routes.LEARNERS.PATH, { courseId })(location) })}
alt={intl.formatMessage(messages.back)}
/>
<div className="text-primary-500 font-style font-weight-bold py-2.5">

View File

@@ -6,7 +6,9 @@ import {
import MockAdapter from 'axios-mock-adapter';
import { act } from 'react-dom/test-utils';
import { IntlProvider } from 'react-intl';
import { MemoryRouter, Route } from 'react-router';
import {
MemoryRouter, Route, Routes, useLocation,
} from 'react-router-dom';
import { Factory } from 'rosie';
import { initializeMockApp } from '@edx/frontend-platform';
@@ -33,26 +35,26 @@ const username = 'abc123';
let container;
let lastLocation;
const LocationComponent = () => {
lastLocation = useLocation();
return null;
};
async function renderComponent() {
const wrapper = render(
<IntlProvider locale="en">
<AppProvider store={store}>
<AppProvider store={store} wrapWithRouter={false}>
<DiscussionContext.Provider
value={{
learnerUsername: username,
courseId,
page: 'learners',
}}
>
<MemoryRouter initialEntries={[`/${courseId}/learners/${username}/posts`]}>
<Route path="/:courseId/learners/:learnerUsername/posts">
<LearnerPostsView />
</Route>
<Route
render={({ location }) => {
lastLocation = location;
return null;
}}
/>
<Routes>
<Route path="/:courseId/learners/:learnerUsername?/posts?" element={<><LearnerPostsView /><LocationComponent /></>} />
</Routes>
</MemoryRouter>
</DiscussionContext.Provider>
</AppProvider>
@@ -110,7 +112,8 @@ describe('Learner Posts View', () => {
expect(backButton).toBeInTheDocument();
});
test('Learner title bar should redirect to the learners list when clicking on the back button',
test(
'Learner title bar should redirect to the learners list when clicking on the back button',
async () => {
await renderComponent();
@@ -122,7 +125,8 @@ describe('Learner Posts View', () => {
await waitFor(() => {
expect(lastLocation.pathname.endsWith('/learners')).toBeTruthy();
});
});
},
);
it('should display a post-filter bar and All posts sorted by recent activity text.', async () => {
await renderComponent();

View File

@@ -1,16 +1,14 @@
import React, { useCallback, useEffect, useMemo } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import {
Redirect, useLocation, useParams,
} from 'react-router';
import { useParams } from 'react-router-dom';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Button, Spinner } from '@edx/paragon';
import SearchInfo from '../../components/SearchInfo';
import { RequestStatus, Routes } from '../../data/constants';
import { selectconfigLoadingStatus, selectLearnersTabEnabled } from '../data/selectors';
import { RequestStatus } from '../../data/constants';
import { selectConfigLoadingStatus } from '../data/selectors';
import NoResults from '../posts/NoResults';
import {
learnersLoadingStatus,
@@ -27,25 +25,21 @@ import messages from './messages';
const LearnersView = () => {
const intl = useIntl();
const { courseId } = useParams();
const location = useLocation();
const dispatch = useDispatch();
const orderBy = useSelector(selectLearnerSorting());
const nextPage = useSelector(selectLearnerNextPage());
const loadingStatus = useSelector(learnersLoadingStatus());
const usernameSearch = useSelector(selectUsernameSearch());
const courseConfigLoadingStatus = useSelector(selectconfigLoadingStatus);
const learnersTabEnabled = useSelector(selectLearnersTabEnabled);
const courseConfigLoadingStatus = useSelector(selectConfigLoadingStatus);
const learners = useSelector(selectAllLearners);
useEffect(() => {
if (learnersTabEnabled) {
if (usernameSearch) {
dispatch(fetchLearners(courseId, { orderBy, usernameSearch }));
} else {
dispatch(fetchLearners(courseId, { orderBy }));
}
if (usernameSearch) {
dispatch(fetchLearners(courseId, { orderBy, usernameSearch }));
} else {
dispatch(fetchLearners(courseId, { orderBy }));
}
}, [courseId, orderBy, learnersTabEnabled, usernameSearch]);
}, [courseId, orderBy, usernameSearch]);
const loadPage = useCallback(async () => {
if (nextPage) {
@@ -63,11 +57,12 @@ const LearnersView = () => {
const renderLearnersList = useMemo(() => (
(
courseConfigLoadingStatus === RequestStatus.SUCCESSFUL && learnersTabEnabled && learners.map((learner) => (
courseConfigLoadingStatus === RequestStatus.SUCCESSFUL && learners.map((learner) => (
<LearnerCard learner={learner} key={learner.username} />
))
// eslint-disable-next-line react/jsx-no-useless-fragment
) || <></>
), [courseConfigLoadingStatus, learnersTabEnabled, learners]);
), [courseConfigLoadingStatus, learners]);
return (
<div className="d-flex flex-column border-right border-light-400">
@@ -82,14 +77,6 @@ const LearnersView = () => {
/>
)}
<div className="list-group list-group-flush learner" role="list">
{courseConfigLoadingStatus === RequestStatus.SUCCESSFUL && !learnersTabEnabled && (
<Redirect
to={{
...location,
pathname: Routes.DISCUSSIONS.PATH,
}}
/>
)}
{renderLearnersList}
{loadingStatus === RequestStatus.IN_PROGRESS ? (
<div className="d-flex justify-content-center p-4">

View File

@@ -1,3 +1,4 @@
/* eslint-disable default-param-last */
import React from 'react';
import {
@@ -6,7 +7,7 @@ import {
import MockAdapter from 'axios-mock-adapter';
import { act } from 'react-dom/test-utils';
import { IntlProvider } from 'react-intl';
import { MemoryRouter, Route } from 'react-router';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { Factory } from 'rosie';
import { initializeMockApp } from '@edx/frontend-platform';
@@ -33,7 +34,7 @@ let container;
function renderComponent() {
const wrapper = render(
<IntlProvider locale="en">
<AppProvider store={store}>
<AppProvider store={store} wrapWithRouter={false}>
<DiscussionContext.Provider value={{
page: 'learners',
learnerUsername: 'learner-1',
@@ -41,10 +42,17 @@ function renderComponent() {
}}
>
<MemoryRouter initialEntries={[`/${courseId}/`]}>
<Route path="/:courseId/">
<PostActionsBar />
<LearnersView />
</Route>
<Routes>
<Route
path="/:courseId/"
element={(
<>
<PostActionsBar />
<LearnersView />
</>
)}
/>
</Routes>
</MemoryRouter>
</DiscussionContext.Provider>
</AppProvider>
@@ -61,7 +69,6 @@ describe('LearnersView', () => {
username: 'test_user',
administrator: true,
roles: [],
learnersTabEnabled: false,
},
});
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
@@ -98,7 +105,6 @@ describe('LearnersView', () => {
async function assignPrivilages(hasModerationPrivileges = false) {
axiosMock.onGet(getDiscussionsConfigUrl(courseId)).reply(200, {
learners_tab_enabled: true,
user_is_privileged: true,
hasModerationPrivileges,
});
@@ -106,13 +112,6 @@ describe('LearnersView', () => {
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
}
test('Learners tab is disabled by default', async () => {
await setUpLearnerMockResponse();
await renderComponent();
expect(screen.queryByText('learner-1')).not.toBeInTheDocument();
});
test('Learners tab is enabled', async () => {
await setUpLearnerMockResponse();
await assignPrivilages();
@@ -201,7 +200,8 @@ describe('LearnersView', () => {
username:
['learner-1', 'learner-2'],
},
])('should have a search bar with a clear button and \'$output\' results found text.',
])(
'should have a search bar with a clear button and \'$output\' results found text.',
async ({
searchText, output, learnersCount, username,
}) => {
@@ -227,7 +227,8 @@ describe('LearnersView', () => {
expect(clearButton).toBeInTheDocument();
expect(leaners).toHaveLength(learnersCount);
});
});
},
);
test('When click on the clear button it should move to a list of all learners.', async () => {
await setUpLearnerMockResponse();
@@ -257,7 +258,8 @@ describe('LearnersView', () => {
expect(learners).toHaveLength(3);
});
it('should display reported and previously reported message by passing activeFlags or inactiveFlags',
it(
'should display reported and previously reported message by passing activeFlags or inactiveFlags',
async () => {
await setUpLearnerMockResponse(2, 2, 1, ['learner-1', 'learner-2'], '', 1, 1);
await assignPrivilages(true);
@@ -274,7 +276,8 @@ describe('LearnersView', () => {
expect(reportedIcon).toBeInTheDocument();
expect(reported).toBeInTheDocument();
expect(previouslyReported).toBeInTheDocument();
});
},
);
it('should display load more button and display more learners by clicking on button.', async () => {
await setUpLearnerMockResponse();

View File

@@ -29,7 +29,8 @@ describe('Learner api test cases', () => {
axiosMock.reset();
});
it('Successfully get and store API response for the learner\'s list and learners posts in redux',
it(
'Successfully get and store API response for the learner\'s list and learners posts in redux',
async () => {
const learners = await setupLearnerMockResponse();
const threads = await setupPostsMockResponse();
@@ -38,20 +39,23 @@ describe('Learner api test cases', () => {
expect(Object.values(learners.learnerProfiles)).toHaveLength(3);
expect(threads.status).toEqual('successful');
expect(Object.values(threads.threadsById)).toHaveLength(2);
});
},
);
it.each([
{ status: 'statusUnread', search: 'Title', cohort: 'post' },
{ status: 'statusUnanswered', search: 'Title', cohort: 'post' },
{ status: 'statusReported', search: 'Title', cohort: 'post' },
{ status: 'statusUnresponded', search: 'Title', cohort: 'post' },
])('Successfully fetch user posts based on %s filters',
])(
'Successfully fetch user posts based on %s filters',
async ({ status, search, cohort }) => {
const threads = await setupPostsMockResponse({ filters: { status, search, cohort } });
expect(threads.status).toEqual('successful');
expect(Object.values(threads.threadsById)).toHaveLength(2);
});
},
);
it('Failed to fetch learners', async () => {
const learners = await setupLearnerMockResponse({ learnerCourseId: courseId2 });

View File

@@ -37,7 +37,8 @@ describe('Learner redux test cases', () => {
test('Successfully load initial states in redux', async () => {
executeThunk(
fetchLearners('course-v1:edX+DemoX+Demo_Course', { usernameSearch: 'learner-1' }),
store.dispatch, store.getState,
store.dispatch,
store.getState,
);
const { learners } = store.getState();
@@ -55,7 +56,8 @@ describe('Learner redux test cases', () => {
expect(learners.postFilter.cohort).toEqual('');
});
test('Successfully store a learner posts stats data as pages object in redux',
test(
'Successfully store a learner posts stats data as pages object in redux',
async () => {
const learners = await setupLearnerMockResponse();
const page = learners.pages[0];
@@ -65,9 +67,11 @@ describe('Learner redux test cases', () => {
expect(statsObject.responses).toEqual(3);
expect(statsObject.threads).toEqual(1);
expect(statsObject.replies).toEqual(0);
});
},
);
test('Successfully store the nextPage, totalPages, totalLearners, and sortedBy data in redux',
test(
'Successfully store the nextPage, totalPages, totalLearners, and sortedBy data in redux',
async () => {
const learners = await setupLearnerMockResponse();
@@ -75,7 +79,8 @@ describe('Learner redux test cases', () => {
expect(learners.totalPages).toEqual(2);
expect(learners.totalLearners).toEqual(3);
expect(learners.sortedBy).toEqual('activity');
});
},
);
test('Successfully updated the learner\'s sort data in redux', async () => {
const learners = await setupLearnerMockResponse();
@@ -106,7 +111,8 @@ describe('Learner redux test cases', () => {
expect(updatedLearners.pages).toHaveLength(0);
});
test('Successfully update the learner\'s search query in redux when searching for a learner',
test(
'Successfully update the learner\'s search query in redux when searching for a learner',
async () => {
const learners = await setupLearnerMockResponse();
@@ -116,5 +122,6 @@ describe('Learner redux test cases', () => {
const updatedLearners = store.getState().learners;
expect(updatedLearners.usernameSearch).toEqual('learner-2');
});
},
);
});

View File

@@ -12,7 +12,7 @@ import { fetchCourseCohorts } from '../../cohorts/data/thunks';
import { selectUserHasModerationPrivileges, selectUserIsGroupTa } from '../../data/selectors';
import { setPostFilter } from '../data/slices';
function LearnerPostFilterBar() {
const LearnerPostFilterBar = () => {
const dispatch = useDispatch();
const { courseId } = useParams();
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
@@ -98,6 +98,6 @@ function LearnerPostFilterBar() {
showCohortsFilter={userHasModerationPrivileges || userIsGroupTa}
/>
);
}
};
export default LearnerPostFilterBar;

View File

@@ -5,12 +5,10 @@ import {
waitFor,
} from '@testing-library/react';
import { IntlProvider } from 'react-intl';
import { generatePath, MemoryRouter, Route } from 'react-router';
import { initializeMockApp } from '@edx/frontend-platform';
import { AppProvider } from '@edx/frontend-platform/react';
import { Routes } from '../../../data/constants';
import { initializeStore } from '../../../store';
import { DiscussionContext } from '../../common/context';
import LearnerPostFilterBar from './LearnerPostFilterBar';
@@ -18,32 +16,21 @@ import LearnerPostFilterBar from './LearnerPostFilterBar';
let store;
const username = 'abc123';
const courseId = 'course-v1:edX+DemoX+Demo_Course';
const path = generatePath(
Routes.LEARNERS.POSTS,
{ courseId, learnerUsername: username },
);
function renderComponent() {
return render(
<IntlProvider locale="en">
<AppProvider store={store}>
<DiscussionContext.Provider
value={{
learnerUsername: username,
courseId,
}}
>
<MemoryRouter initialEntries={[path]}>
<Route
path={Routes.LEARNERS.POSTS}
component={LearnerPostFilterBar}
/>
</MemoryRouter>
</DiscussionContext.Provider>
</AppProvider>
</IntlProvider>,
);
}
const renderComponent = () => render(
<IntlProvider locale="en">
<AppProvider store={store}>
<DiscussionContext.Provider
value={{
learnerUsername: username,
courseId,
}}
>
<LearnerPostFilterBar />
</DiscussionContext.Provider>
</AppProvider>
</IntlProvider>,
);
describe('LearnerPostFilterBar', () => {
beforeEach(async () => {

View File

@@ -18,7 +18,7 @@ const LearnerCard = ({ learner }) => {
0: enableInContextSidebar ? 'in-context' : undefined,
learnerUsername: learner.username,
courseId,
});
})();
return (
<Link

View File

@@ -168,24 +168,9 @@ const messages = defineMessages({
defaultMessage: 'anonymous',
description: 'Author name displayed when a post is anonymous',
},
bannerMessage: {
id: 'discussion.banner.welcomeMessage',
defaultMessage: '🎉 Welcome to the new and improved discussions experience!',
description: 'Information banner welcome text',
},
learnMoreBannerLink: {
id: 'discussion.banner.learnMore',
defaultMessage: 'Learn more',
description: 'learn more button to redirect users to know more about new discussion experience ',
},
shareFeedback: {
id: 'discussion.banner.shareFeedback',
defaultMessage: 'Share feedback',
description: 'Share feedback button to open feedback forms',
},
blackoutDiscussionInformation: {
id: 'discussion.blackoutBanner.information',
defaultMessage: 'Posting in discussions is temporarily disabled by the course team',
defaultMessage: 'Posting in discussions is disabled by the course team',
description: 'Informative text when discussion posting is disabled',
},
imageWarningMessage: {

View File

@@ -29,7 +29,7 @@ const BreadcrumbDropdown = ({
key="null"
active={!currentItem}
as={Link}
to={showAllPath}
to={showAllPath()}
>
{showAllMsg}
</Dropdown.Item>
@@ -39,7 +39,7 @@ const BreadcrumbDropdown = ({
key={itemLabelFunc(item)}
active={itemActiveFunc(item)}
as={Link}
to={itemPathFunc(item)}
to={itemPathFunc(item)()}
>
{itemLabelFunc(item)}
</Dropdown.Item>

View File

@@ -1,7 +1,7 @@
import React, { useCallback } from 'react';
import { useSelector } from 'react-redux';
import { useRouteMatch } from 'react-router';
import { useParams } from 'react-router-dom';
import { Routes } from '../../../data/constants';
import {
@@ -15,12 +15,10 @@ import BreadcrumbDropdown from './BreadcrumbDropdown';
const LegacyBreadcrumbMenu = () => {
const {
params: {
courseId,
category,
topicId: currentTopicId,
},
} = useRouteMatch([Routes.TOPICS.CATEGORY, Routes.TOPICS.TOPIC]);
courseId,
category,
topicId: currentTopicId,
} = useParams();
const currentTopic = useSelector(selectTopic(currentTopicId));
const currentCategory = category || currentTopic?.categoryId;
const decodedCurrentCategory = String(currentCategory).replace('%23', '#');

View File

@@ -5,14 +5,14 @@ import {
} from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import { IntlProvider } from 'react-intl';
import { MemoryRouter, Route } from 'react-router';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { Factory } from 'rosie';
import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
import { getApiBaseUrl, Routes } from '../../../data/constants';
import { getApiBaseUrl, Routes as ROUTES } from '../../../data/constants';
import { initializeStore } from '../../../store';
import { executeThunk } from '../../../test-utils';
import { fetchCourseTopics } from '../../topics/data/thunks';
@@ -28,15 +28,22 @@ let axiosMock;
function renderComponent(path) {
render(
<IntlProvider locale="en">
<AppProvider store={store}>
<AppProvider store={store} wrapWithRouter={false}>
<MemoryRouter initialEntries={[path]}>
<Route
path={[
Routes.POSTS.PATH,
Routes.TOPICS.CATEGORY,
]}
component={LegacyBreadcrumbMenu}
/>
<Routes>
{
[
ROUTES.POSTS.PATH,
ROUTES.TOPICS.CATEGORY,
].map((route) => (
<Route
key={route}
path={route}
element={<LegacyBreadcrumbMenu />}
/>
))
}
</Routes>
</MemoryRouter>
</AppProvider>
</IntlProvider>,

View File

@@ -1,21 +1,20 @@
import React, { useContext, useMemo } from 'react';
import { matchPath } from 'react-router';
import { NavLink } from 'react-router-dom';
import { matchPath, NavLink, useLocation } from 'react-router-dom';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Nav } from '@edx/paragon';
import { Routes } from '../../../data/constants';
import { DiscussionContext } from '../../common/context';
import { useShowLearnersTab } from '../../data/hooks';
import { discussionsPath } from '../../utils';
import messages from './messages';
const NavigationBar = () => {
const intl = useIntl();
const { courseId } = useContext(DiscussionContext);
const showLearnersTab = useShowLearnersTab();
const location = useLocation();
const isTopicsNavActive = Boolean(matchPath({ path: `${Routes.TOPICS.CATEGORY}/*` }, location.pathname));
const navLinks = useMemo(() => ([
{
@@ -28,19 +27,14 @@ const NavigationBar = () => {
},
{
route: Routes.TOPICS.ALL,
isActive: (match, location) => Boolean(matchPath(location.pathname, { path: Routes.TOPICS.PATH })),
labelMessage: messages.allTopics,
},
]), []);
{
route: Routes.LEARNERS.PATH,
labelMessage: messages.learners,
},
useMemo(() => {
if (showLearnersTab) {
navLinks.push({
route: Routes.LEARNERS.PATH,
labelMessage: messages.learners,
});
}
}, [showLearnersTab]);
]), []);
return (
<Nav variant="pills" className="py-2 nav-button-group">
@@ -49,8 +43,8 @@ const NavigationBar = () => {
<Nav.Link
key={link.route}
as={NavLink}
to={discussionsPath(link.route, { courseId })}
isActive={link.isActive}
to={discussionsPath(link.route, { courseId })()}
className={isTopicsNavActive && link.route === Routes.TOPICS.ALL && 'active'}
>
{intl.formatMessage(link.labelMessage)}
</Nav.Link>

View File

@@ -2,14 +2,16 @@ import React, {
Suspense, useCallback, useContext, useEffect, useState,
} from 'react';
import { useHistory, useLocation } from 'react-router-dom';
import { useLocation, useNavigate } from 'react-router-dom';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Button, Icon, IconButton } from '@edx/paragon';
import { ArrowBack } from '@edx/paragon/icons';
import Spinner from '../../components/Spinner';
import { EndorsementStatus, PostsPages, ThreadType } from '../../data/constants';
import {
EndorsementStatus, PostsPages, ThreadType,
} from '../../data/constants';
import { useDispatchWithState } from '../../data/hooks';
import { DiscussionContext } from '../common/context';
import { useIsOnDesktop } from '../data/hooks';
@@ -27,7 +29,7 @@ const CommentsView = React.lazy(() => import('./comments/CommentsView'));
const PostCommentsView = () => {
const intl = useIntl();
const history = useHistory();
const navigate = useNavigate();
const location = useLocation();
const isOnDesktop = useIsOnDesktop();
const [addingResponse, setAddingResponse] = useState(false);
@@ -37,6 +39,9 @@ const PostCommentsView = () => {
} = useContext(DiscussionContext);
const commentsCount = useCommentsCount(postId);
const { closed, id: threadId, type } = usePost(postId);
const redirectUrl = discussionsPath(PostsPages[page], {
courseId, learnerUsername, category, topicId,
})(location);
useEffect(() => {
if (!threadId) {
@@ -74,6 +79,7 @@ const PostCommentsView = () => {
}
return (
// eslint-disable-next-line react/jsx-no-constructed-context-values
<PostCommentsContext.Provider value={{
isClosed: closed,
postType: type,
@@ -88,9 +94,7 @@ const PostCommentsView = () => {
variant="plain"
className="px-0 line-height-24 py-0 my-1.5 border-0 font-weight-normal font-style text-primary-500"
iconBefore={ArrowBack}
onClick={() => history.push(discussionsPath(PostsPages[page], {
courseId, learnerUsername, category, topicId,
})(location))}
onClick={() => navigate({ ...redirectUrl })}
size="sm"
>
{intl.formatMessage(messages.backAlt)}
@@ -105,9 +109,7 @@ const PostCommentsView = () => {
style={{ padding: '18px' }}
size="inline"
className="ml-4 mt-4"
onClick={() => history.push(discussionsPath(PostsPages[page], {
courseId, learnerUsername, category, topicId,
})(location))}
onClick={() => navigate({ ...redirectUrl })}
alt={intl.formatMessage(messages.backAlt)}
/>
)

View File

@@ -3,13 +3,16 @@ import {
} from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import { IntlProvider } from 'react-intl';
import { MemoryRouter, Route } from 'react-router';
import {
MemoryRouter, Route, Routes, useLocation,
} from 'react-router-dom';
import { Factory } from 'rosie';
import { camelCaseObject, initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
import { getApiBaseUrl } from '../../data/constants';
import { initializeStore } from '../../store';
import { executeThunk } from '../../test-utils';
import { getCohortsApiUrl } from '../cohorts/data/api';
@@ -20,12 +23,13 @@ import { fetchCourseConfig } from '../data/thunks';
import DiscussionContent from '../discussions-home/DiscussionContent';
import { getThreadsApiUrl } from '../posts/data/api';
import { fetchThread, fetchThreads } from '../posts/data/thunks';
import { fetchCourseTopics } from '../topics/data/thunks';
import { getDiscussionTourUrl } from '../tours/data/api';
import { selectTours } from '../tours/data/selectors';
import { fetchDiscussionTours } from '../tours/data/thunks';
import discussionTourFactory from '../tours/data/tours.factory';
import { getCommentsApiUrl } from './data/api';
import { fetchCommentResponses, fetchThreadComments, removeComment } from './data/thunks';
import { fetchCommentResponses, removeComment } from './data/thunks';
import '../posts/data/__factories__';
import './data/__factories__';
@@ -39,44 +43,21 @@ const discussionPostId = 'thread-1';
const questionPostId = 'thread-2';
const closedPostId = 'thread-2';
const courseId = 'course-v1:edX+TestX+Test_Course';
const reverseOrder = true;
const enableInContextSidebar = false;
const topicsApiUrl = `${getApiBaseUrl()}/api/discussion/v1/course_topics/${courseId}`;
let store;
let axiosMock;
let testLocation;
let container;
let unmount;
async function mockAxiosReturnPagedComments() {
const endorsedArray = [null, false, true];
const pageArray = [1, 2];
endorsedArray.forEach(async (endorsed) => {
const postId = endorsed === null ? discussionPostId : questionPostId;
pageArray.forEach(async (page) => {
const params = {
thread_id: postId,
page,
page_size: undefined,
requested_fields: 'profile_image',
endorsed,
reverse_order: reverseOrder,
enable_in_context_sidebar: enableInContextSidebar,
signal: {},
};
axiosMock.onGet(commentsApiUrl, { ...params }).reply(200, Factory.build('commentsResult', { can_delete: true }, {
threadId: postId,
page,
pageSize: 1,
count: 2,
endorsed,
childCount: page === 1 ? 2 : 0,
}));
await executeThunk(fetchThreadComments(postId, { ...params }), store.dispatch, store.getState);
});
});
async function mockAxiosReturnPagedComments(threadId, endorsed = false, page = 1, count = 2) {
axiosMock.onGet(commentsApiUrl).reply(200, Factory.build('commentsResult', { can_delete: true }, {
threadId,
endorsed,
pageSize: 1,
count,
childCount: page === 1 ? 2 : 0,
}));
}
async function mockAxiosReturnPagedCommentsResponses() {
@@ -90,13 +71,16 @@ async function mockAxiosReturnPagedCommentsResponses() {
};
[1, 2].forEach(async (page) => {
axiosMock.onGet(commentsResponsesApiUrl, { params: { ...paramsTemplate, page } }).reply(200,
axiosMock.onGet(commentsResponsesApiUrl, { params: { ...paramsTemplate, page } }).reply(
200,
Factory.build('commentsResult', null, {
threadId: discussionPostId,
parentId,
page,
pageSize: 1,
count: 2,
}));
}),
);
await executeThunk(fetchCommentResponses(parentId), store.dispatch, store.getState);
});
@@ -107,22 +91,45 @@ async function getThreadAPIResponse(attr = null) {
await executeThunk(fetchThread(discussionPostId), store.dispatch, store.getState);
}
function renderComponent(postId) {
async function setupCourseConfig() {
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`).reply(200, {
has_moderation_privileges: true,
isPostingEnabled: true,
editReasons: [
{ code: 'reason-1', label: 'reason 1' },
{ code: 'reason-2', label: 'reason 2' },
],
postCloseReasons: [
{ code: 'reason-1', label: 'reason 1' },
{ code: 'reason-2', label: 'reason 2' },
],
});
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/settings`).reply(200, {});
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
}
const LocationComponent = () => {
testLocation = useLocation();
return null;
};
function renderComponent(postId, isClosed = false, page = 'posts', path = `/${courseId}/posts/${postId}`) {
const wrapper = render(
<IntlProvider locale="en">
<AppProvider store={store}>
<AppProvider store={store} wrapWithRouter={false}>
<DiscussionContext.Provider
value={{ courseId, postId }}
value={{
courseId, postId, page, isClosed, topicId: 'topic-id',
}}
>
<MemoryRouter initialEntries={[`/${courseId}/posts/${postId}`]}>
<MemoryRouter initialEntries={[path]}>
<DiscussionContent />
<Route
path="*"
render={({ location }) => {
testLocation = location;
return null;
}}
/>
<Routes>
<Route
path="*"
element={<LocationComponent />}
/>
</Routes>
</MemoryRouter>
</DiscussionContext.Provider>
</AppProvider>
@@ -132,6 +139,45 @@ function renderComponent(postId) {
unmount = wrapper.unmount;
}
describe('PostView', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
Factory.resetAll();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock.onGet(topicsApiUrl)
.reply(200, {
non_courseware_topics: Factory.buildList('topic', 1, {}, { topicPrefix: 'non-courseware-' }),
courseware_topics: Factory.buildList('category', 1, {}, { name: 'courseware' }),
});
executeThunk(fetchCourseTopics(courseId), store.dispatch, store.getState);
});
it('should show Topic Info for non-courseware topics', async () => {
await getThreadAPIResponse({ id: 'thread-1', topic_id: 'non-courseware-topic-1' });
await waitFor(() => renderComponent(discussionPostId));
expect(await screen.findByText('Related to')).toBeInTheDocument();
expect(await screen.findByText('non-courseware-topic 1')).toBeInTheDocument();
});
it('should show Topic Info for courseware topics with category', async () => {
await getThreadAPIResponse({ id: 'thread-2', topic_id: 'courseware-topic-2' });
await waitFor(() => renderComponent('thread-2'));
expect(await screen.findByText('Related to')).toBeInTheDocument();
expect(await screen.findByText('category-1 / courseware-topic 2')).toBeInTheDocument();
});
});
describe('ThreadView', () => {
beforeEach(async () => {
initializeMockApp({
@@ -165,10 +211,12 @@ describe('ThreadView', () => {
thread_id: threadId,
})];
});
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`).reply(200, { isPostingEnabled: true });
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
await executeThunk(fetchCourseCohorts(courseId), store.dispatch, store.getState);
await mockAxiosReturnPagedComments(discussionPostId);
await executeThunk(fetchThreads(courseId), store.dispatch, store.getState);
await mockAxiosReturnPagedComments();
await mockAxiosReturnPagedCommentsResponses();
});
@@ -181,6 +229,16 @@ describe('ThreadView', () => {
expect(JSON.parse(axiosMock.history.patch[axiosMock.history.patch.length - 1].data)).toMatchObject(data);
}
it('should not allow posting a comment on a closed post', async () => {
axiosMock.reset();
await mockAxiosReturnPagedComments(closedPostId, true);
await waitFor(() => renderComponent(closedPostId, true));
const comments = await waitFor(() => screen.findAllByTestId('comment-comment-4'));
const hoverCard = within(comments[0]).getByTestId('hover-card-comment-4');
expect(within(hoverCard).getByRole('button', { name: /Add comment/i })).toBeDisabled();
});
it('should display post content', async () => {
await waitFor(() => renderComponent(discussionPostId));
const post = await waitFor(() => screen.getByTestId('post-thread-1'));
@@ -261,7 +319,7 @@ describe('ThreadView', () => {
await act(async () => { fireEvent.click(screen.getByText(/submit/i)); });
expect(screen.queryByTestId('tinymce-editor')).not.toBeInTheDocument();
await waitFor(async () => expect(await screen.findByTestId('reply-comment-7')).toBeInTheDocument());
await waitFor(async () => expect(await screen.findByTestId('reply-comment-2')).toBeInTheDocument());
});
it('should allow editing an existing comment', async () => {
@@ -279,23 +337,6 @@ describe('ThreadView', () => {
});
});
async function setupCourseConfig(reasonCodesEnabled = true) {
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`).reply(200, {
has_moderation_privileges: true,
reason_codes_enabled: reasonCodesEnabled,
editReasons: [
{ code: 'reason-1', label: 'reason 1' },
{ code: 'reason-2', label: 'reason 2' },
],
postCloseReasons: [
{ code: 'reason-1', label: 'reason 1' },
{ code: 'reason-2', label: 'reason 2' },
],
});
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/settings`).reply(200, {});
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
}
it('should show reason codes when closing a post', async () => {
await setupCourseConfig();
await waitFor(() => renderComponent(discussionPostId));
@@ -325,12 +366,45 @@ describe('ThreadView', () => {
assertLastUpdateData({ closed: true, close_reason_code: 'reason-1' });
});
it('should close the post directly if reason codes are not enabled', async () => {
await setupCourseConfig(false);
await waitFor(() => renderComponent(discussionPostId));
it('should show reason codes when editing an existing comment', async () => {
setupCourseConfig();
renderComponent(discussionPostId);
const comment = await waitFor(() => screen.findByTestId('comment-comment-1'));
const hoverCard = within(comment).getByTestId('hover-card-comment-1');
await act(async () => {
fireEvent.click(
within(hoverCard).getByRole('button', { name: /actions menu/i }),
);
});
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
});
expect(screen.queryByRole('combobox', { name: /reason for editing/i })).toBeInTheDocument();
expect(screen.getAllByRole('option', { name: /reason \d/i })).toHaveLength(2);
await act(async () => {
fireEvent.change(
screen.queryByRole('combobox', { name: /reason for editing/i }),
{ target: { value: null } },
);
});
await act(async () => {
fireEvent.change(screen.queryByRole('combobox', { name: /reason for editing/i }), { target: { value: 'reason-1' } });
});
await act(async () => {
fireEvent.change(screen.getByTestId('tinymce-editor'), { target: { value: 'testing123' } });
});
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /submit/i }));
});
assertLastUpdateData({ edit_reason_code: 'reason-1' });
});
const post = await screen.findByTestId('post-thread-1');
const hoverCard = within(post).getByTestId('hover-card-thread-1');
it('should reopen the post', async () => {
await setupCourseConfig();
renderComponent(closedPostId);
const post = screen.getByTestId('post-thread-2');
const hoverCard = within(post).getByTestId('hover-card-thread-2');
await act(async () => {
fireEvent.click(
within(hoverCard).getByRole('button', { name: /actions menu/i }),
@@ -338,34 +412,12 @@ describe('ThreadView', () => {
});
expect(screen.queryByRole('dialog', { name: /close post/i })).not.toBeInTheDocument();
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /close/i }));
fireEvent.click(screen.getByRole('button', { name: /reopen/i }));
});
expect(screen.queryByRole('dialog', { name: /close post/i })).not.toBeInTheDocument();
assertLastUpdateData({ closed: true });
assertLastUpdateData({ closed: false });
});
it.each([true, false])(
'should reopen the post directly when reason codes enabled=%s',
async (reasonCodesEnabled) => {
await setupCourseConfig(reasonCodesEnabled);
renderComponent(closedPostId);
const post = screen.getByTestId('post-thread-2');
const hoverCard = within(post).getByTestId('hover-card-thread-2');
await act(async () => {
fireEvent.click(
within(hoverCard).getByRole('button', { name: /actions menu/i }),
);
});
expect(screen.queryByRole('dialog', { name: /close post/i })).not.toBeInTheDocument();
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /reopen/i }));
});
expect(screen.queryByRole('dialog', { name: /close post/i })).not.toBeInTheDocument();
assertLastUpdateData({ closed: false });
},
);
it('should show the editor if the post is edited', async () => {
await setupCourseConfig(false);
await waitFor(() => renderComponent(discussionPostId));
@@ -383,6 +435,28 @@ describe('ThreadView', () => {
expect(testLocation.pathname).toBe(`/${courseId}/posts/${discussionPostId}/edit`);
});
it('should show the editor if the post is edited on topics page', async () => {
await setupCourseConfig(false);
await waitFor(() => renderComponent(
discussionPostId,
false,
'topics',
`/${courseId}/topics/topic-id/posts/${discussionPostId}`,
));
const post = await screen.findByTestId('post-thread-1');
const hoverCard = within(post).getByTestId('hover-card-thread-1');
await act(async () => {
fireEvent.click(
within(hoverCard).getByRole('button', { name: /actions menu/i }),
);
});
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
});
expect(testLocation.pathname).toBe(`/${courseId}/topics/topic-id/posts/${discussionPostId}/edit`);
});
it('should allow pinning the post', async () => {
await waitFor(() => renderComponent(discussionPostId));
const post = await screen.findByTestId('post-thread-1');
@@ -398,6 +472,20 @@ describe('ThreadView', () => {
assertLastUpdateData({ pinned: false });
});
it('should allow copying a link to the post', async () => {
await waitFor(() => renderComponent(discussionPostId));
const post = await screen.findByTestId('post-thread-1');
const hoverCard = within(post).getByTestId('hover-card-thread-1');
Object.assign(navigator, { clipboard: { writeText: jest.fn() } });
await act(async () => {
fireEvent.click(within(hoverCard).getByRole('button', { name: /actions menu/i }));
});
await act(async () => {
fireEvent.click(within(hoverCard).getByRole('button', { name: /copy link/i }));
});
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(`http://localhost/${courseId}/posts/${discussionPostId}`);
});
it('should allow reporting the post', async () => {
await waitFor(() => renderComponent(discussionPostId));
const post = await screen.findByTestId('post-thread-1');
@@ -483,8 +571,10 @@ describe('ThreadView', () => {
});
describe('for discussion thread', () => {
const findLoadMoreCommentsButton = () => screen.findByTestId('load-more-comments');
it('shown post not found when post id does not belong to course', async () => {
renderComponent('unloaded-id');
await waitFor(() => renderComponent('unloaded-id'));
expect(await screen.findByText('Thread not found', { exact: true }))
.toBeInTheDocument();
});
@@ -497,6 +587,70 @@ describe('ThreadView', () => {
.not
.toBeInTheDocument();
});
it('pressing load more button will load next page of comments', async () => {
await waitFor(() => renderComponent(discussionPostId));
await mockAxiosReturnPagedComments(discussionPostId, false, 2);
const loadMoreButton = await findLoadMoreCommentsButton();
await act(async () => {
fireEvent.click(loadMoreButton);
});
await screen.findByTestId('comment-comment-1');
await screen.findByTestId('comment-comment-4');
});
it('newly loaded comments are appended to the old ones', async () => {
await waitFor(() => renderComponent(discussionPostId));
await mockAxiosReturnPagedComments(discussionPostId, false, 2);
const loadMoreButton = await findLoadMoreCommentsButton();
await act(async () => {
fireEvent.click(loadMoreButton);
});
await screen.findByTestId('comment-comment-1');
// check that comments from the first page are also displayed
expect(screen.queryByTestId('comment-comment-4'))
.toBeInTheDocument();
});
});
describe('for question thread', () => {
const findLoadMoreCommentsButtons = () => screen.findByTestId('load-more-comments');
it('initially loads only the first page', async () => {
await mockAxiosReturnPagedComments(questionPostId);
act(() => renderComponent(questionPostId));
expect(await screen.findByTestId('comment-comment-4'))
.toBeInTheDocument();
expect(screen.queryByTestId('comment-comment-5'))
.not
.toBeInTheDocument();
});
it('pressing load more button will load next page of comments', async () => {
await mockAxiosReturnPagedComments(questionPostId);
await waitFor(() => renderComponent(questionPostId));
const loadMoreButton = await findLoadMoreCommentsButtons();
expect(await screen.findByTestId('comment-comment-4'))
.toBeInTheDocument();
// Comments from next page should not be loaded yet.
expect(await screen.queryByTestId('comment-comment-5'))
.not
.toBeInTheDocument();
await mockAxiosReturnPagedComments(questionPostId, false, 2, 1);
await act(async () => {
fireEvent.click(loadMoreButton);
});
// Endorsed comment from next page should be loaded now.
await waitFor(() => expect(screen.queryByTestId('comment-comment-5'))
.toBeInTheDocument());
});
});
describe('for comments replies', () => {
@@ -505,8 +659,8 @@ describe('ThreadView', () => {
it('initially loads only the first page', async () => {
await waitFor(() => renderComponent(discussionPostId));
await waitFor(() => screen.findByTestId('reply-comment-7'));
expect(screen.queryByTestId('reply-comment-8')).not.toBeInTheDocument();
await waitFor(() => screen.findByTestId('reply-comment-2'));
expect(screen.queryByTestId('reply-comment-3')).not.toBeInTheDocument();
});
it('pressing load more button will load next page of responses', async () => {
@@ -516,8 +670,7 @@ describe('ThreadView', () => {
await act(async () => {
fireEvent.click(loadMoreButton);
});
await screen.findByTestId('reply-comment-8');
await screen.findByTestId('reply-comment-3');
});
it('newly loaded responses are appended to the old ones', async () => {
@@ -528,9 +681,9 @@ describe('ThreadView', () => {
fireEvent.click(loadMoreButton);
});
await screen.findByTestId('reply-comment-8');
await screen.findByTestId('reply-comment-3');
// check that comments from the first page are also displayed
expect(screen.queryByTestId('reply-comment-7')).toBeInTheDocument();
expect(screen.queryByTestId('reply-comment-2')).toBeInTheDocument();
});
it('load more button is hidden when no more responses pages to load', async () => {
@@ -541,8 +694,7 @@ describe('ThreadView', () => {
fireEvent.click(loadMoreButton);
});
await screen.findByTestId('reply-comment-8');
await expect(findLoadMoreCommentsResponsesButton()).rejects.toThrow();
await screen.findByTestId('reply-comment-3');
});
});
@@ -576,21 +728,21 @@ describe('ThreadView', () => {
it('shows action dropdown for replies', async () => {
await waitFor(() => renderComponent(discussionPostId));
const reply = await waitFor(() => screen.findByTestId('reply-comment-7'));
const reply = await waitFor(() => screen.findByTestId('reply-comment-2'));
expect(within(reply).getByRole('button', { name: /actions menu/i })).toBeInTheDocument();
});
it('should display reply content', async () => {
await waitFor(() => renderComponent(discussionPostId));
const reply = await waitFor(() => screen.findByTestId('reply-comment-7'));
expect(within(reply).queryByTestId('comment-7')).toBeInTheDocument();
const reply = await waitFor(() => screen.findByTestId('reply-comment-2'));
expect(within(reply).queryByTestId('comment-2')).toBeInTheDocument();
});
it('shows delete confirmation modal', async () => {
await waitFor(() => renderComponent(discussionPostId));
const reply = await waitFor(() => screen.findByTestId('reply-comment-7'));
const reply = await waitFor(() => screen.findByTestId('reply-comment-2'));
await act(async () => { fireEvent.click(within(reply).getByRole('button', { name: /actions menu/i })); });
await act(async () => { fireEvent.click(screen.queryByRole('button', { name: /Delete/i })); });

View File

@@ -5,7 +5,7 @@ import { useIntl } from '@edx/frontend-platform/i18n';
import { Button, Spinner } from '@edx/paragon';
import { EndorsementStatus } from '../../../data/constants';
import { useUserCanAddThreadInBlackoutDate } from '../../data/hooks';
import { useUserPostingEnabled } from '../../data/hooks';
import { isLastElementOfList } from '../../utils';
import { usePostComments } from '../data/hooks';
import messages from '../messages';
@@ -16,7 +16,8 @@ const CommentsView = ({ endorsed }) => {
const intl = useIntl();
const [addingResponse, setAddingResponse] = useState(false);
const { isClosed } = useContext(PostCommentsContext);
const userCanAddThreadInBlackoutDate = useUserCanAddThreadInBlackoutDate();
const isUserPrivilegedInPostingRestriction = useUserPostingEnabled();
const {
endorsedCommentsIds,
unEndorsedCommentsIds,
@@ -35,7 +36,7 @@ const CommentsView = ({ endorsed }) => {
const handleDefinition = useCallback((message, commentsLength) => (
<div
className="mx-4 my-14px text-gray-700 font-style"
className="comment-line mx-4 my-14px text-gray-700 font-style"
role="heading"
aria-level="2"
>
@@ -65,13 +66,14 @@ const CommentsView = ({ endorsed }) => {
)}
{isLoading && !showLoadMoreResponses && (
<div className="mb-2 mt-3 d-flex justify-content-center">
<Spinner animation="border" variant="primary" className="spinner-dimentions" />
<Spinner animation="border" variant="primary" className="spinner-dimensions" />
</div>
)}
</div>
), [hasMorePages, isLoading, handleLoadMoreResponses]);
return (
// eslint-disable-next-line react/jsx-no-useless-fragment
<>
{((hasMorePages && isLoading) || !isLoading) && (
<>
@@ -88,7 +90,7 @@ const CommentsView = ({ endorsed }) => {
{handleDefinition(messages.responseCount, unEndorsedCommentsIds.length)}
{unEndorsedCommentsIds.length === 0 && <br />}
{handleComments(unEndorsedCommentsIds, false)}
{(userCanAddThreadInBlackoutDate && !!unEndorsedCommentsIds.length && !isClosed) && (
{(isUserPrivilegedInPostingRestriction && !!unEndorsedCommentsIds.length && !isClosed) && (
<div className="mx-4">
{!addingResponse && (
<Button

View File

@@ -15,7 +15,7 @@ import { AlertBanner, Confirmation, EndorsedAlertBanner } from '../../../common'
import { DiscussionContext } from '../../../common/context';
import HoverCard from '../../../common/HoverCard';
import { ContentTypes } from '../../../data/constants';
import { useUserCanAddThreadInBlackoutDate } from '../../../data/hooks';
import { useUserPostingEnabled } from '../../../data/hooks';
import { fetchThread } from '../../../posts/data/thunks';
import LikeButton from '../../../posts/post/LikeButton';
import { useActions } from '../../../utils';
@@ -61,7 +61,7 @@ const Comment = ({
const currentPage = useSelector(selectCommentCurrentPage(id));
const sortedOrder = useSelector(selectCommentSortOrder);
const actions = useActions(ContentTypes.COMMENT, id);
const userCanAddThreadInBlackoutDate = useUserCanAddThreadInBlackoutDate();
const isUserPrivilegedInPostingRestriction = useUserPostingEnabled();
useEffect(() => {
// If the comment has a parent comment, it won't have any children, so don't fetch them.
@@ -119,10 +119,10 @@ const Comment = ({
), [id, currentPage, sortedOrder]);
const handleAddCommentButton = useCallback(() => {
if (userCanAddThreadInBlackoutDate) {
if (isUserPrivilegedInPostingRestriction) {
setReplying(true);
}
}, [userCanAddThreadInBlackoutDate]);
}, [isUserPrivilegedInPostingRestriction]);
const handleCommentLike = useCallback(async () => {
await dispatch(editComment(id, { voted: !voted }));
@@ -154,8 +154,8 @@ const Comment = ({
title={intl.formatMessage(messages.deleteResponseTitle)}
description={intl.formatMessage(messages.deleteResponseDescription)}
onClose={hideDeleteConfirmation}
comfirmAction={handleDeleteConfirmation}
closeButtonVaraint="tertiary"
confirmAction={handleDeleteConfirmation}
closeButtonVariant="tertiary"
confirmButtonText={intl.formatMessage(messages.deleteConfirmationDelete)}
/>
{!abuseFlagged && (
@@ -164,7 +164,7 @@ const Comment = ({
title={intl.formatMessage(messages.reportResponseTitle)}
description={intl.formatMessage(messages.reportResponseDescription)}
onClose={hideReportConfirmation}
comfirmAction={handleReportConfirmation}
confirmAction={handleReportConfirmation}
confirmButtonVariant="danger"
/>
)}
@@ -265,7 +265,7 @@ const Comment = ({
/>
</div>
) : (
!isClosed && userCanAddThreadInBlackoutDate && (inlineReplies.length >= 5) && (
!isClosed && isUserPrivilegedInPostingRestriction && (inlineReplies.length >= 5) && (
<Button
className="d-flex flex-grow mt-2 font-size-14 font-style font-weight-500 text-primary-500"
variant="plain"

View File

@@ -24,12 +24,12 @@ import { formikCompatibleHandler, isFormikFieldInvalid } from '../../../utils';
import { addComment, editComment } from '../../data/thunks';
import messages from '../../messages';
function CommentEditor({
const CommentEditor = ({
comment,
edit,
formClasses,
onCloseEditor,
}) {
}) => {
const {
id, threadId, parentId, rawBody, author, lastEdit,
} = comment;
@@ -40,10 +40,10 @@ function CommentEditor({
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
const userIsGroupTa = useSelector(selectUserIsGroupTa);
const userIsStaff = useSelector(selectUserIsStaff);
const { reasonCodesEnabled, editReasons } = useSelector(selectModerationSettings);
const { editReasons } = useSelector(selectModerationSettings);
const [submitting, dispatch] = useDispatchWithState();
const canDisplayEditReason = (reasonCodesEnabled && edit
const canDisplayEditReason = (edit
&& (userHasModerationPrivileges || userIsGroupTa || userIsStaff)
&& author !== authenticatedUser.username
);
@@ -60,7 +60,8 @@ function CommentEditor({
const initialValues = {
comment: rawBody,
editReasonCode: lastEdit?.reasonCode || (userIsStaff ? 'violates-guidelines' : ''),
// eslint-disable-next-line react/prop-types
editReasonCode: lastEdit?.reasonCode || (userIsStaff && canDisplayEditReason ? 'violates-guidelines' : undefined),
};
const handleCloseEditor = useCallback((resetForm) => {
@@ -176,13 +177,13 @@ function CommentEditor({
)}
</Formik>
);
}
};
CommentEditor.propTypes = {
comment: PropTypes.shape({
author: PropTypes.string,
id: PropTypes.string,
lastEdit: PropTypes.object,
lastEdit: PropTypes.shape({}),
parentId: PropTypes.string,
rawBody: PropTypes.string,
threadId: PropTypes.string.isRequired,

View File

@@ -83,8 +83,8 @@ const Reply = ({ responseId }) => {
title={intl.formatMessage(messages.deleteCommentTitle)}
description={intl.formatMessage(messages.deleteCommentDescription)}
onClose={hideDeleteConfirmation}
comfirmAction={handleDeleteConfirmation}
closeButtonVaraint="tertiary"
confirmAction={handleDeleteConfirmation}
closeButtonVariant="tertiary"
confirmButtonText={intl.formatMessage(messages.deleteConfirmationDelete)}
/>
{!abuseFlagged && (
@@ -93,7 +93,7 @@ const Reply = ({ responseId }) => {
title={intl.formatMessage(messages.reportCommentTitle)}
description={intl.formatMessage(messages.reportCommentDescription)}
onClose={hideReportConfirmation}
comfirmAction={handleReportConfirmation}
confirmAction={handleReportConfirmation}
confirmButtonVariant="danger"
/>
)}

View File

@@ -59,6 +59,7 @@ Factory.define('commentsResult')
return Factory.buildList('comment', len, {
thread_id: threadId,
parent_id: parentId,
endorsed,
}, {
endorsedBy: endorsed ? 'staff' : null,
childCount,

View File

@@ -20,16 +20,14 @@ export const getCommentsApiUrl = () => `${getConfig().LMS_BASE_URL}/api/discussi
* @param enableInContextSidebar
* @returns {Promise<{}>}
*/
export async function getThreadComments(
threadId, {
endorsed,
page,
pageSize,
reverseOrder,
enableInContextSidebar = false,
signal,
} = {},
) {
export const getThreadComments = async (threadId, {
endorsed,
page,
pageSize,
reverseOrder,
enableInContextSidebar = false,
signal,
} = {}) => {
const params = snakeCaseObject({
threadId,
endorsed: EndorsementValue[endorsed],
@@ -42,7 +40,7 @@ export async function getThreadComments(
const { data } = await getAuthenticatedHttpClient().get(getCommentsApiUrl(), { params: { ...params, signal } });
return data;
}
};
/**
* Fetches a responses to a comment.
@@ -51,13 +49,11 @@ export async function getThreadComments(
* @param {number=} pageSize
* @returns {Promise<{}>}
*/
export async function getCommentResponses(
commentId, {
page,
pageSize,
reverseOrder,
} = {},
) {
export const getCommentResponses = async (commentId, {
page,
pageSize,
reverseOrder,
} = {}) => {
const url = `${getCommentsApiUrl()}${commentId}/`;
const params = snakeCaseObject({
page,
@@ -68,7 +64,7 @@ export async function getCommentResponses(
const { data } = await getAuthenticatedHttpClient()
.get(url, { params });
return data;
}
};
/**
* Posts a comment.
@@ -78,13 +74,13 @@ export async function getCommentResponses(
* @param {boolean} enableInContextSidebar
* @returns {Promise<{}>}
*/
export async function postComment(comment, threadId, parentId = null, enableInContextSidebar = false) {
export const postComment = async (comment, threadId, parentId = null, enableInContextSidebar = false) => {
const { data } = await getAuthenticatedHttpClient()
.post(getCommentsApiUrl(), snakeCaseObject({
threadId, raw_body: comment, parentId, enableInContextSidebar,
}));
return data;
}
};
/**
* Updates existing comment.
@@ -96,13 +92,13 @@ export async function postComment(comment, threadId, parentId = null, enableInCo
* @param {string=} editReasonCode The moderation reason code for editing.
* @returns {Promise<{}>}
*/
export async function updateComment(commentId, {
export const updateComment = async (commentId, {
comment,
voted,
flagged,
endorsed,
editReasonCode,
}) {
}) => {
const url = `${getCommentsApiUrl()}${commentId}/`;
const postData = snakeCaseObject({
raw_body: comment,
@@ -115,14 +111,14 @@ export async function updateComment(commentId, {
const { data } = await getAuthenticatedHttpClient()
.patch(url, postData, { headers: { 'Content-Type': 'application/merge-patch+json' } });
return data;
}
};
/**
* Deletes existing comment.
* @param {string} commentId ID of comment to delete
*/
export async function deleteComment(commentId) {
export const deleteComment = async (commentId) => {
const url = `${getCommentsApiUrl()}${commentId}/`;
await getAuthenticatedHttpClient()
.delete(url);
}
};

View File

@@ -141,7 +141,7 @@ const commentsSlice = createSlice({
const commentRemoveListType = !endorsed ? EndorsementStatus.ENDORSED : EndorsementStatus.UNENDORSED;
state.commentsInThreads[threadId][commentRemoveListType] = (
state.commentsInThreads[threadId]?.[commentRemoveListType]?.filter(item => item !== commentId)
state.commentsInThreads[threadId]?.[commentRemoveListType]?.filter(item => item !== commentId)
);
state.commentsInThreads[threadId][commentAddListtype] = [
...state.commentsInThreads[threadId][commentAddListtype], payload.id,

View File

@@ -21,7 +21,7 @@ function renderComponent(location = `/${courseId}/`) {
return render(
<IntlProvider locale="en">
<ResponsiveContext.Provider value={{ width: 1280 }}>
<AppProvider store={store}>
<AppProvider store={store} wrapWithRouter={false}>
<MemoryRouter initialEntries={[location]}>
<NoResults />
</MemoryRouter>

View File

@@ -11,7 +11,7 @@ import { Button, Spinner } from '@edx/paragon';
import { RequestStatus } from '../../data/constants';
import { DiscussionContext } from '../common/context';
import { selectconfigLoadingStatus, selectUserHasModerationPrivileges, selectUserIsStaff } from '../data/selectors';
import { selectConfigLoadingStatus, selectUserHasModerationPrivileges, selectUserIsStaff } from '../data/selectors';
import { fetchUserPosts } from '../learners/data/thunks';
import messages from '../messages';
import { usePostList } from './data/hooks';
@@ -35,7 +35,7 @@ const PostsList = ({
const nextPage = useSelector(selectThreadNextPage());
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
const userIsStaff = useSelector(selectUserIsStaff);
const configStatus = useSelector(selectconfigLoadingStatus);
const configStatus = useSelector(selectConfigLoadingStatus);
const sortedPostsIds = usePostList(postsIds);
const showOwnPosts = page === 'my-posts';

View File

@@ -5,15 +5,15 @@ import MockAdapter from 'axios-mock-adapter';
import { act } from 'react-dom/test-utils';
import { IntlProvider } from 'react-intl';
import {
generatePath, MemoryRouter, Route, Switch,
} from 'react-router';
generatePath, MemoryRouter, Route, Routes,
} from 'react-router-dom';
import { Factory } from 'rosie';
import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
import { getApiBaseUrl, Routes, ThreadType } from '../../data/constants';
import { getApiBaseUrl, Routes as ROUTES, ThreadType } from '../../data/constants';
import { initializeStore } from '../../store';
import { executeThunk } from '../../test-utils';
import { getCohortsApiUrl } from '../cohorts/data/api';
@@ -39,24 +39,24 @@ const username = 'abc123';
async function renderComponent({
postId, topicId, category, myPosts, enableInContextSidebar = false,
} = { myPosts: false }) {
let path = generatePath(Routes.POSTS.ALL_POSTS, { courseId });
let page;
let path = generatePath(ROUTES.POSTS.ALL_POSTS, { courseId });
let page = 'posts';
if (postId) {
path = generatePath(Routes.POSTS.ALL_POSTS, { courseId, postId });
path = generatePath(ROUTES.POSTS.ALL_POSTS, { courseId, postId });
page = 'posts';
} else if (topicId) {
path = generatePath(Routes.POSTS.PATH, { courseId, topicId });
page = 'posts';
path = generatePath(ROUTES.POSTS.PATH, { courseId, topicId });
page = 'topics';
} else if (category) {
path = generatePath(Routes.TOPICS.CATEGORY, { courseId, category });
path = generatePath(ROUTES.TOPICS.CATEGORY, { courseId, category });
page = 'category';
} else if (myPosts) {
path = generatePath(Routes.POSTS.MY_POSTS, { courseId });
path = generatePath(ROUTES.POSTS.MY_POSTS, { courseId });
page = 'my-posts';
}
await render(
<IntlProvider locale="en">
<AppProvider store={store}>
<AppProvider store={store} wrapWithRouter={false}>
<MemoryRouter initialEntries={[path]}>
<DiscussionContext.Provider value={{
courseId,
@@ -67,15 +67,18 @@ async function renderComponent({
enableInContextSidebar,
}}
>
<Switch>
<Route path={Routes.POSTS.MY_POSTS}>
<PostsView />
</Route>
<Route
path={[Routes.POSTS.PATH, Routes.POSTS.ALL_POSTS, Routes.TOPICS.CATEGORY]}
component={PostsView}
/>
</Switch>
<Routes>
{
[
ROUTES.POSTS.PATH,
ROUTES.POSTS.MY_POSTS,
ROUTES.POSTS.ALL_POSTS,
ROUTES.TOPICS.CATEGORY,
].map((route) => (
<Route key={route} path={route} element={<PostsView />} />
))
}
</Routes>
</DiscussionContext.Provider>
</MemoryRouter>
</AppProvider>

View File

@@ -26,6 +26,7 @@ Factory.define('thread')
'type',
'voted',
'pinned',
'copy_link',
],
author: 'test_user',
author_label: 'Staff',

View File

@@ -28,22 +28,20 @@ export const getCoursesApiUrl = () => `${getConfig().LMS_BASE_URL}/api/discussio
* @param {number} cohort
* @returns {Promise<{}>}
*/
export async function getThreads(
courseId, {
topicIds,
page,
pageSize,
textSearch,
orderBy,
following,
view,
author,
flagged,
threadType,
countFlagged,
cohort,
} = {},
) {
export const getThreads = async (courseId, {
topicIds,
page,
pageSize,
textSearch,
orderBy,
following,
view,
author,
flagged,
threadType,
countFlagged,
cohort,
} = {}) => {
const params = snakeCaseObject({
courseId,
page,
@@ -62,19 +60,19 @@ export async function getThreads(
});
const { data } = await getAuthenticatedHttpClient().get(getThreadsApiUrl(), { params });
return data;
}
};
/**
* Fetches a single thread.
* @param {string} threadId
* @returns {Promise<{}>}
*/
export async function getThread(threadId, courseId) {
export const getThread = async (threadId, courseId) => {
const params = { requested_fields: 'profile_image', course_id: courseId };
const url = `${getThreadsApiUrl()}${threadId}/`;
const { data } = await getAuthenticatedHttpClient().get(url, { params });
return data;
}
};
/**
* Posts a new thread.
@@ -90,7 +88,7 @@ export async function getThread(threadId, courseId) {
* @param {boolean} enableInContextSidebar
* @returns {Promise<{}>}
*/
export async function postThread(
export const postThread = async (
courseId,
topicId,
type,
@@ -103,7 +101,7 @@ export async function postThread(
anonymousToPeers,
} = {},
enableInContextSidebar = false,
) {
) => {
const postData = snakeCaseObject({
courseId,
topicId,
@@ -119,7 +117,7 @@ export async function postThread(
const { data } = await getAuthenticatedHttpClient()
.post(getThreadsApiUrl(), postData);
return data;
}
};
/**
* Updates an existing thread.
@@ -138,7 +136,7 @@ export async function postThread(
* @param {string} closeReasonCode
* @returns {Promise<{}>}
*/
export async function updateThread(threadId, {
export const updateThread = async (threadId, {
flagged,
voted,
read,
@@ -151,7 +149,7 @@ export async function updateThread(threadId, {
pinned,
editReasonCode,
closeReasonCode,
} = {}) {
} = {}) => {
const url = `${getThreadsApiUrl()}${threadId}/`;
const patchData = snakeCaseObject({
topicId,
@@ -170,17 +168,17 @@ export async function updateThread(threadId, {
const { data } = await getAuthenticatedHttpClient()
.patch(url, patchData, { headers: { 'Content-Type': 'application/merge-patch+json' } });
return data;
}
};
/**
* Deletes a thread.
* @param {string} threadId
*/
export async function deleteThread(threadId) {
export const deleteThread = async (threadId) => {
const url = `${getThreadsApiUrl()}${threadId}/`;
await getAuthenticatedHttpClient()
.delete(url);
}
};
/**
* Upload a file.
@@ -190,7 +188,7 @@ export async function deleteThread(threadId) {
* @param {string} threadKey
* @returns {Promise<{ location: string }>}
*/
export async function uploadFile(blob, filename, courseId, threadKey) {
export const uploadFile = async (blob, filename, courseId, threadKey) => {
const uploadUrl = `${getCoursesApiUrl()}${courseId}/upload`;
const formData = new FormData();
formData.append('thread_key', threadKey);
@@ -200,4 +198,4 @@ export async function uploadFile(blob, filename, courseId, threadKey) {
throw new Error(data.developer_message);
}
return data;
}
};

View File

@@ -2,5 +2,6 @@
export { showPostEditor } from './data';
export { default as Post } from './post/Post';
export { default as messages } from './post-actions-bar/messages';
// eslint-disable-next-line import/no-cycle
export { default as PostEditor } from './post-editor/PostEditor';
export { default as PostsView } from './PostsView';

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