Compare commits
73 Commits
inf-890
...
Ayesha/INF
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed3addc021 | ||
|
|
b467298d9a | ||
|
|
b5d036a54d | ||
|
|
bc997108ef | ||
|
|
6ae5130c14 | ||
|
|
67d79cb3aa | ||
|
|
f31a0e71f3 | ||
|
|
9761787c89 | ||
|
|
e5a21f4a75 | ||
|
|
1d89e9556a | ||
|
|
b35632df64 | ||
|
|
b36c0266fd | ||
|
|
0d5df18ab2 | ||
|
|
c61435546d | ||
|
|
df4a3c2a73 | ||
|
|
ac635edcb8 | ||
|
|
c4f7115732 | ||
|
|
5cc8ba43fe | ||
|
|
68505821bb | ||
|
|
c6d953fe7b | ||
|
|
a479f5ae5b | ||
|
|
48b2b9de64 | ||
|
|
352fa0eacf | ||
|
|
6930e90c78 | ||
|
|
4994de9615 | ||
|
|
6d90da7aa2 | ||
|
|
bcb43dfdff | ||
|
|
7d7221b144 | ||
|
|
7cd93bd8d2 | ||
|
|
6afa9840aa | ||
|
|
46ddd6d885 | ||
|
|
5bd15655f6 | ||
|
|
27f73e3c23 | ||
|
|
67700e8974 | ||
|
|
1cb8ad3018 | ||
|
|
4068b9e46a | ||
|
|
5db3a18cb7 | ||
|
|
3a6a783f21 | ||
|
|
69e0689ab9 | ||
|
|
f795d9f836 | ||
|
|
68c18526fb | ||
|
|
f49f272afa | ||
|
|
67212254f7 | ||
|
|
af5b10a575 | ||
|
|
fb2be35d00 | ||
|
|
10adf1171b | ||
|
|
2609380bd8 | ||
|
|
61d0f9a7ea | ||
|
|
ea235cf6ca | ||
|
|
b8f11c3046 | ||
|
|
1769692d22 | ||
|
|
3d6b71c247 | ||
|
|
bd42521f6b | ||
|
|
445caca4e4 | ||
|
|
4a2b32494d | ||
|
|
a16bd783a0 | ||
|
|
df1a16ee85 | ||
|
|
656935336e | ||
|
|
2498f74556 | ||
|
|
99ad3aff53 | ||
|
|
e2bb68a1cd | ||
|
|
f694b480b5 | ||
|
|
228a771a39 | ||
|
|
c821033a64 | ||
|
|
7ce4566df3 | ||
|
|
a02771f96f | ||
|
|
8c53a7a19e | ||
|
|
c8500a0c1e | ||
|
|
f7ad94997d | ||
|
|
733a74d9e4 | ||
|
|
b2b33b76f7 | ||
|
|
70f6541585 | ||
|
|
822854953f |
5
.env
5
.env
@@ -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=''
|
||||
|
||||
@@ -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=''
|
||||
|
||||
@@ -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=''
|
||||
|
||||
14
.eslintrc.js
14
.eslintrc.js
@@ -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',
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
7
.github/workflows/ci.yml
vendored
7
.github/workflows/ci.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/lockfileversion-check.yml
vendored
2
.github/workflows/lockfileversion-check.yml
vendored
@@ -10,4 +10,4 @@ on:
|
||||
|
||||
jobs:
|
||||
version-check:
|
||||
uses: openedx/.github/.github/workflows/lockfileversion-check.yml@master
|
||||
uses: openedx/.github/.github/workflows/lockfile-check.yml@master
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
process.env.TA_FEEDBACK_FORM= 'https://learner-form.test';
|
||||
process.env.STAFF_FEEDBACK_FORM= 'https://staff-form.test';
|
||||
20
Makefile
20
Makefile
@@ -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:
|
||||
|
||||
70
README.rst
70
README.rst
@@ -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.
|
||||
@@ -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
34525
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
48
package.json
48
package.json
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
|
||||
// eslint-disable-next-line react/function-component-definition
|
||||
export default function InsertLink() {
|
||||
return (
|
||||
<svg
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
|
||||
// eslint-disable-next-line react/function-component-definition
|
||||
export default function Issue() {
|
||||
return (
|
||||
<svg
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
|
||||
// eslint-disable-next-line react/function-component-definition
|
||||
export default function People() {
|
||||
return (
|
||||
<svg
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
|
||||
// eslint-disable-next-line react/function-component-definition
|
||||
export default function PushPin() {
|
||||
return (
|
||||
<svg
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
|
||||
// eslint-disable-next-line react/function-component-definition
|
||||
export default function Question() {
|
||||
return (
|
||||
<svg
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
|
||||
// eslint-disable-next-line react/function-component-definition
|
||||
export default function QuestionAnswer() {
|
||||
return (
|
||||
<svg
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
|
||||
// eslint-disable-next-line react/function-component-definition
|
||||
export default function QuestionAnswerOutline() {
|
||||
return (
|
||||
<svg
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
|
||||
// eslint-disable-next-line react/function-component-definition
|
||||
export default function StarFilled() {
|
||||
return (
|
||||
<svg
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
|
||||
// eslint-disable-next-line react/function-component-definition
|
||||
export default function StarOutline() {
|
||||
return (
|
||||
<svg
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
|
||||
// eslint-disable-next-line react/function-component-definition
|
||||
export default function ThumbUpFilled() {
|
||||
return (
|
||||
<svg
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
|
||||
// eslint-disable-next-line react/function-component-definition
|
||||
export default function ThumbUpOutline() {
|
||||
return (
|
||||
<svg
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -90,7 +90,6 @@ describe.each([
|
||||
store = initializeStore({
|
||||
config: {
|
||||
hasModerationPrivileges: true,
|
||||
reasonCodesEnabled: true,
|
||||
},
|
||||
});
|
||||
const content = buildTestContent(type, props);
|
||||
|
||||
@@ -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,
|
||||
})}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: '',
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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`);
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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');
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -18,7 +18,7 @@ const LearnerCard = ({ learner }) => {
|
||||
0: enableInContextSidebar ? 'in-context' : undefined,
|
||||
learnerUsername: learner.username,
|
||||
courseId,
|
||||
});
|
||||
})();
|
||||
|
||||
return (
|
||||
<Link
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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', '#');
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -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 })); });
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -59,6 +59,7 @@ Factory.define('commentsResult')
|
||||
return Factory.buildList('comment', len, {
|
||||
thread_id: threadId,
|
||||
parent_id: parentId,
|
||||
endorsed,
|
||||
}, {
|
||||
endorsedBy: endorsed ? 'staff' : null,
|
||||
childCount,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -26,6 +26,7 @@ Factory.define('thread')
|
||||
'type',
|
||||
'voted',
|
||||
'pinned',
|
||||
'copy_link',
|
||||
],
|
||||
author: 'test_user',
|
||||
author_label: 'Staff',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user