Compare commits
1 Commits
aansari/si
...
inf-890
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ddb7a2c13b |
5
.env
5
.env
@@ -20,5 +20,6 @@ SEGMENT_KEY=''
|
||||
SITE_NAME=''
|
||||
USER_INFO_COOKIE_NAME=''
|
||||
SUPPORT_URL=''
|
||||
LEARNER_FEEDBACK_URL=''
|
||||
STAFF_FEEDBACK_URL=''
|
||||
TA_FEEDBACK_FORM= ''
|
||||
STAFF_FEEDBACK_FORM= ''
|
||||
DISPLAY_FEEDBACK_BANNER='false'
|
||||
|
||||
@@ -21,5 +21,6 @@ SEGMENT_KEY=''
|
||||
SITE_NAME=localhost
|
||||
USER_INFO_COOKIE_NAME='edx-user-info'
|
||||
SUPPORT_URL='https://support.edx.org'
|
||||
LEARNER_FEEDBACK_URL=''
|
||||
STAFF_FEEDBACK_URL=''
|
||||
TA_FEEDBACK_FORM='https://learner-form.test'
|
||||
STAFF_FEEDBACK_FORM='https://staff-form.test'
|
||||
DISPLAY_FEEDBACK_BANNER='false'
|
||||
|
||||
@@ -19,5 +19,6 @@ SEGMENT_KEY=''
|
||||
SITE_NAME='localhost'
|
||||
USER_INFO_COOKIE_NAME='edx-user-info'
|
||||
SUPPORT_URL='https://support.edx.org'
|
||||
LEARNER_FEEDBACK_URL=''
|
||||
STAFF_FEEDBACK_URL=''
|
||||
TA_FEEDBACK_FORM='https://learner-form.test'
|
||||
STAFF_FEEDBACK_FORM='https://staff-form.test'
|
||||
DISPLAY_FEEDBACK_BANNER='false'
|
||||
|
||||
14
.eslintrc.js
14
.eslintrc.js
@@ -1,10 +1,9 @@
|
||||
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',
|
||||
@@ -26,6 +25,7 @@ module.exports = createConfig(
|
||||
},
|
||||
],
|
||||
'simple-import-sort/exports': 'error',
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
7
.github/workflows/ci.yml
vendored
7
.github/workflows/ci.yml
vendored
@@ -9,17 +9,18 @@ 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: ${{ env.NODE_VER }}
|
||||
node-version: ${{ matrix.node }}
|
||||
- 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/lockfile-check.yml@master
|
||||
uses: openedx/.github/.github/workflows/lockfileversion-check.yml@master
|
||||
|
||||
2
.jest/setEnvVars.js
Normal file
2
.jest/setEnvVars.js
Normal file
@@ -0,0 +1,2 @@
|
||||
process.env.TA_FEEDBACK_FORM= 'https://learner-form.test';
|
||||
process.env.STAFF_FEEDBACK_FORM= 'https://staff-form.test';
|
||||
20
Makefile
20
Makefile
@@ -1,14 +1,13 @@
|
||||
export TRANSIFEX_RESOURCE = frontend-app-discussions
|
||||
transifex_resource = frontend-app-discussions
|
||||
transifex_langs = "ar,cs,de_DE,es_419,es_AR,es_ES,fa_IR,fr,fr_CA,fr_FR,hi,it_IT,pl,pt_PT,tr_TR,uk,ru,zh_CN"
|
||||
transifex_langs = "ar,fr,es_419,zh_CN,tr_TR,pl,fr_CA,fr_FR,de_DE,it_IT"
|
||||
|
||||
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-formatjs
|
||||
transifex_temp = ./temp/babel-plugin-react-intl
|
||||
|
||||
NPM_TESTS=build i18n_extract lint test
|
||||
|
||||
@@ -56,24 +55,9 @@ 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,42 +1,15 @@
|
||||
########################
|
||||
frontend-app-discussions
|
||||
########################
|
||||
========================
|
||||
|
||||
|Codecov| |license|
|
||||
|Build Status| |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:
|
||||
|
||||
@@ -53,7 +26,7 @@ Cloning and Startup
|
||||
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.
|
||||
@@ -66,8 +39,7 @@ 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
|
||||
@@ -78,45 +50,39 @@ 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@openedx.org.
|
||||
Please do not report security issues in public. Please email security@edx.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.
|
||||
|
||||
Reporting Security Issues
|
||||
=========================
|
||||
|
||||
Please do not report security issues in public. Please email security@openedx.org.
|
||||
.. |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
|
||||
@@ -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>/.env.test'],
|
||||
setupFiles: ['<rootDir>/.jest/setEnvVars.js'],
|
||||
setupFilesAfterEnv: [
|
||||
'<rootDir>/src/setupTest.js',
|
||||
],
|
||||
|
||||
34098
package-lock.json
generated
34098
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
34
package.json
34
package.json
@@ -11,7 +11,7 @@
|
||||
],
|
||||
"scripts": {
|
||||
"build": "fedx-scripts webpack",
|
||||
"i18n_extract": "fedx-scripts formatjs extract",
|
||||
"i18n_extract": "BABEL_ENV=i18n fedx-scripts babel src --quiet > /dev/null",
|
||||
"lint": "fedx-scripts eslint --ext .js --ext .jsx .",
|
||||
"lint:fix": "fedx-scripts eslint --ext .js --ext .jsx . --fix",
|
||||
"snapshot": "fedx-scripts jest --updateSnapshot",
|
||||
@@ -33,40 +33,40 @@
|
||||
"url": "https://github.com/openedx/frontend-app-discussions/issues"
|
||||
},
|
||||
"dependencies": {
|
||||
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
|
||||
"@edx/frontend-component-footer": "12.5.1",
|
||||
"@edx/frontend-component-header": "4.9.1",
|
||||
"@edx/frontend-platform": "4.6.3",
|
||||
"@edx/paragon": "20.44.0",
|
||||
"@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",
|
||||
"@tinymce/tinymce-react": "3.13.1",
|
||||
"babel-polyfill": "6.26.0",
|
||||
"classnames": "2.3.2",
|
||||
"classnames": "2.3.1",
|
||||
"core-js": "3.21.1",
|
||||
"dompurify": "^2.4.3",
|
||||
"formik": "2.2.9",
|
||||
"lodash.snakecase": "4.1.1",
|
||||
"prop-types": "15.8.1",
|
||||
"raw-loader": "4.0.2",
|
||||
"react": "17.0.2",
|
||||
"react-dom": "17.0.2",
|
||||
"react-redux": "7.2.9",
|
||||
"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",
|
||||
"timeago.js": "4.0.2",
|
||||
"tinymce": "5.10.7",
|
||||
"tinymce": "5.10.2",
|
||||
"yup": "0.31.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@edx/browserslist-config": "1.2.0",
|
||||
"@edx/frontend-build": "13.0.5",
|
||||
"@edx/reactifex": "1.1.0",
|
||||
"@testing-library/jest-dom": "5.17.0",
|
||||
"@testing-library/react": "12.1.5",
|
||||
"@edx/browserslist-config": "1.1.0",
|
||||
"@edx/frontend-build": "11.0.1",
|
||||
"@edx/reactifex": "1.0.3",
|
||||
"@testing-library/jest-dom": "5.16.2",
|
||||
"@testing-library/react": "12.1.4",
|
||||
"@testing-library/user-event": "13.5.0",
|
||||
"axios-mock-adapter": "1.22.0",
|
||||
"axios-mock-adapter": "1.20.0",
|
||||
"babel-plugin-react-intl": "8.2.25",
|
||||
"eslint-plugin-simple-import-sort": "7.0.0",
|
||||
"glob": "7.2.0",
|
||||
|
||||
@@ -1,33 +1,9 @@
|
||||
{
|
||||
"extends": [
|
||||
"config:base",
|
||||
"schedule:weekly",
|
||||
":automergeLinters",
|
||||
":automergeMinor",
|
||||
":automergeTesters",
|
||||
":enableVulnerabilityAlerts",
|
||||
":rebaseStalePrs",
|
||||
":semanticCommits",
|
||||
":updateNotScheduled"
|
||||
"config:base"
|
||||
],
|
||||
"packageRules": [
|
||||
{
|
||||
"matchDepTypes": [
|
||||
"devDependencies"
|
||||
],
|
||||
"matchUpdateTypes": [
|
||||
"lockFileMaintenance",
|
||||
"minor",
|
||||
"patch",
|
||||
"pin"
|
||||
],
|
||||
"automerge": true
|
||||
},
|
||||
{
|
||||
"matchPackagePatterns": ["@edx", "@openedx"],
|
||||
"matchUpdateTypes": ["minor", "patch"],
|
||||
"automerge": true
|
||||
}
|
||||
],
|
||||
"timezone": "America/New_York"
|
||||
"patch": {
|
||||
"automerge": true
|
||||
},
|
||||
"rebaseStalePrs": true
|
||||
}
|
||||
|
||||
@@ -17,23 +17,21 @@ import {
|
||||
} from '../data/constants';
|
||||
import { selectCourseCohorts } from '../discussions/cohorts/data/selectors';
|
||||
import messages from '../discussions/posts/post-filter-bar/messages';
|
||||
import ActionItem from '../discussions/posts/post-filter-bar/PostFilterBar';
|
||||
import { ActionItem } from '../discussions/posts/post-filter-bar/PostFilterBar';
|
||||
|
||||
const FilterBar = ({
|
||||
function FilterBar({
|
||||
intl,
|
||||
filters,
|
||||
selectedFilters,
|
||||
onFilterChange,
|
||||
showCohortsFilter,
|
||||
}) => {
|
||||
}) {
|
||||
const [isOpen, setOpen] = useState(false);
|
||||
const cohorts = useSelector(selectCourseCohorts);
|
||||
const { status } = useSelector(state => state.cohorts);
|
||||
const selectedCohort = useMemo(
|
||||
() => cohorts.find(cohort => (
|
||||
toString(cohort.id) === selectedFilters.cohort)),
|
||||
[selectedFilters.cohort],
|
||||
);
|
||||
const selectedCohort = useMemo(() => cohorts.find(cohort => (
|
||||
toString(cohort.id) === selectedFilters.cohort)),
|
||||
[selectedFilters.cohort]);
|
||||
|
||||
const allFilters = [
|
||||
{
|
||||
@@ -185,7 +183,7 @@ const FilterBar = ({
|
||||
</Collapsible.Body>
|
||||
</Collapsible.Advanced>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
FilterBar.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { memo, useEffect } from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import classNames from 'classnames';
|
||||
@@ -6,46 +6,43 @@ import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import withConditionalInContextRendering from '../../discussions/common/withConditionalInContextRendering';
|
||||
import { useCourseId } from '../../discussions/data/hooks';
|
||||
import { fetchTab } from './data/thunks';
|
||||
import Tabs from './tabs/Tabs';
|
||||
import messages from './messages';
|
||||
|
||||
import './navBar.scss';
|
||||
|
||||
const CourseTabsNavigation = ({ activeTab, className, rootSlug }) => {
|
||||
const CourseTabsNavigation = ({
|
||||
activeTab, className, courseId, rootSlug,
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
const intl = useIntl();
|
||||
const courseId = useCourseId();
|
||||
const tabs = useSelector(state => state.courseTabs.tabs);
|
||||
|
||||
useEffect(() => {
|
||||
if (courseId) {
|
||||
dispatch(fetchTab(courseId, rootSlug));
|
||||
}
|
||||
dispatch(fetchTab(courseId, rootSlug));
|
||||
}, [courseId]);
|
||||
|
||||
console.log('CourseTabsNavigation');
|
||||
|
||||
return (
|
||||
<div id="courseTabsNavigation" tabIndex="-1" 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 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>
|
||||
);
|
||||
};
|
||||
@@ -54,12 +51,13 @@ CourseTabsNavigation.propTypes = {
|
||||
activeTab: PropTypes.string,
|
||||
className: PropTypes.string,
|
||||
rootSlug: PropTypes.string,
|
||||
courseId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
CourseTabsNavigation.defaultProps = {
|
||||
activeTab: 'discussion',
|
||||
activeTab: undefined,
|
||||
className: null,
|
||||
rootSlug: 'outline',
|
||||
};
|
||||
|
||||
export default memo(withConditionalInContextRendering(CourseTabsNavigation, false));
|
||||
export default React.memo(CourseTabsNavigation);
|
||||
|
||||
@@ -56,10 +56,8 @@ 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,3 +1,3 @@
|
||||
const selectCourseTabs = state => state.courseTabs;
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
|
||||
export default selectCourseTabs;
|
||||
export const selectCourseTabs = state => state.courseTabs;
|
||||
|
||||
2
src/components/NavigationBar/index.js
Normal file
2
src/components/NavigationBar/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export { default as CourseTabsNavigation } from './CourseTabsNavigation';
|
||||
@@ -1,11 +1,10 @@
|
||||
@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';
|
||||
|
||||
const Tabs = ({ children, className, ...attrs }) => {
|
||||
export default function Tabs({ children, className, ...attrs }) {
|
||||
const [
|
||||
indexOfLastVisibleChild,
|
||||
containerElementRef,
|
||||
@@ -31,28 +31,25 @@ const 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]);
|
||||
|
||||
@@ -65,7 +62,7 @@ const Tabs = ({ children, className, ...attrs }) => {
|
||||
{tabChildren}
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
Tabs.propTypes = {
|
||||
children: PropTypes.node,
|
||||
@@ -76,5 +73,3 @@ Tabs.defaultProps = {
|
||||
children: null,
|
||||
className: undefined,
|
||||
};
|
||||
|
||||
export default Tabs;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, {
|
||||
useCallback, useEffect, useMemo, useRef, useState,
|
||||
useCallback, useContext, useEffect, useState,
|
||||
} from 'react';
|
||||
|
||||
import camelCase from 'lodash/camelCase';
|
||||
@@ -9,7 +9,7 @@ import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Icon, SearchField } from '@edx/paragon';
|
||||
import { Search as SearchIcon } from '@edx/paragon/icons';
|
||||
|
||||
import { useCurrentPage } from '../discussions/data/hooks';
|
||||
import { DiscussionContext } from '../discussions/common/context';
|
||||
import { setUsernameSearch } from '../discussions/learners/data';
|
||||
import { setSearchQuery } from '../discussions/posts/data';
|
||||
import postsMessages from '../discussions/posts/post-actions-bar/messages';
|
||||
@@ -18,37 +18,35 @@ import { setFilter as setTopicFilter } from '../discussions/topics/data/slices';
|
||||
const Search = () => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
const page = useCurrentPage();
|
||||
const { page } = useContext(DiscussionContext);
|
||||
const postSearch = useSelector(({ threads }) => threads.filters.search);
|
||||
const topicSearch = useSelector(({ topics }) => topics.filter);
|
||||
const learnerSearch = useSelector(({ learners }) => learners.usernameSearch);
|
||||
const isPostSearch = ['posts', 'my-posts'].includes(page);
|
||||
const isTopicSearch = 'topics'.includes(page);
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const previousSearchValueRef = useRef('');
|
||||
let currentValue = '';
|
||||
|
||||
const currentValue = useMemo(() => {
|
||||
if (isPostSearch) {
|
||||
return postSearch;
|
||||
} if (isTopicSearch) {
|
||||
return topicSearch;
|
||||
}
|
||||
return learnerSearch;
|
||||
}, [postSearch, topicSearch, learnerSearch]);
|
||||
if (isPostSearch) {
|
||||
currentValue = postSearch;
|
||||
} else if (isTopicSearch) {
|
||||
currentValue = topicSearch;
|
||||
} else {
|
||||
currentValue = learnerSearch;
|
||||
}
|
||||
|
||||
const onClear = useCallback(() => {
|
||||
dispatch(setSearchQuery(''));
|
||||
dispatch(setTopicFilter(''));
|
||||
dispatch(setUsernameSearch(''));
|
||||
previousSearchValueRef.current = '';
|
||||
}, [previousSearchValueRef]);
|
||||
}, []);
|
||||
|
||||
const onChange = useCallback((query) => {
|
||||
setSearchValue(query);
|
||||
}, []);
|
||||
|
||||
const onSubmit = useCallback((query) => {
|
||||
if (query === '' || query === previousSearchValueRef.current) {
|
||||
if (query === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -59,8 +57,7 @@ const Search = () => {
|
||||
} else if (page === 'learners') {
|
||||
dispatch(setUsernameSearch(query));
|
||||
}
|
||||
previousSearchValueRef.current = query;
|
||||
}, [page, searchValue, previousSearchValueRef]);
|
||||
}, [page, searchValue]);
|
||||
|
||||
const handleIconClick = useCallback((e) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
|
||||
import { Editor } from '@tinymce/tinymce-react';
|
||||
import { useLocation, useParams } from 'react-router';
|
||||
import { useParams } from 'react-router';
|
||||
// TinyMCE so the global var exists
|
||||
// eslint-disable-next-line no-unused-vars,import/no-extraneous-dependencies
|
||||
import tinymce from 'tinymce/tinymce';
|
||||
@@ -42,14 +42,13 @@ 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 */
|
||||
const TinyMCEEditor = (props) => {
|
||||
function TinyMCEEditor(props) {
|
||||
// note that skin and content_css is disabled to avoid the normal
|
||||
// loading process and is instead loaded as a string via content_style
|
||||
const 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) => {
|
||||
@@ -100,29 +99,6 @@ const 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
|
||||
@@ -176,6 +152,6 @@ const TinyMCEEditor = (props) => {
|
||||
</AlertModal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export default React.memo(TinyMCEEditor);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from 'react';
|
||||
|
||||
// eslint-disable-next-line react/function-component-definition
|
||||
export default function InsertLink() {
|
||||
return (
|
||||
<svg
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from 'react';
|
||||
|
||||
// eslint-disable-next-line react/function-component-definition
|
||||
export default function Issue() {
|
||||
return (
|
||||
<svg
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from 'react';
|
||||
|
||||
// eslint-disable-next-line react/function-component-definition
|
||||
export default function People() {
|
||||
return (
|
||||
<svg
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from 'react';
|
||||
|
||||
// eslint-disable-next-line react/function-component-definition
|
||||
export default function PushPin() {
|
||||
return (
|
||||
<svg
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from 'react';
|
||||
|
||||
// eslint-disable-next-line react/function-component-definition
|
||||
export default function Question() {
|
||||
return (
|
||||
<svg
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from 'react';
|
||||
|
||||
// eslint-disable-next-line react/function-component-definition
|
||||
export default function QuestionAnswer() {
|
||||
return (
|
||||
<svg
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from 'react';
|
||||
|
||||
// eslint-disable-next-line react/function-component-definition
|
||||
export default function QuestionAnswerOutline() {
|
||||
return (
|
||||
<svg
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from 'react';
|
||||
|
||||
// eslint-disable-next-line react/function-component-definition
|
||||
export default function StarFilled() {
|
||||
return (
|
||||
<svg
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from 'react';
|
||||
|
||||
// eslint-disable-next-line react/function-component-definition
|
||||
export default function StarOutline() {
|
||||
return (
|
||||
<svg
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from 'react';
|
||||
|
||||
// eslint-disable-next-line react/function-component-definition
|
||||
export default function ThumbUpFilled() {
|
||||
return (
|
||||
<svg
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from 'react';
|
||||
|
||||
// eslint-disable-next-line react/function-component-definition
|
||||
export default function ThumbUpOutline() {
|
||||
return (
|
||||
<svg
|
||||
|
||||
@@ -137,7 +137,7 @@ export const DiscussionProvider = {
|
||||
OPEN_EDX: 'openedx',
|
||||
};
|
||||
|
||||
export const BASE_PATH = `${getConfig().PUBLIC_PATH}:courseId`;
|
||||
const BASE_PATH = `${getConfig().PUBLIC_PATH}:courseId`;
|
||||
|
||||
export const Routes = {
|
||||
DISCUSSIONS: {
|
||||
|
||||
@@ -13,23 +13,23 @@ import {
|
||||
import { MoreHoriz } from '@edx/paragon/icons';
|
||||
|
||||
import { ContentActions } from '../../data/constants';
|
||||
import { selectIsPostingEnabled } from '../data/selectors';
|
||||
import { selectBlackoutDate } from '../data/selectors';
|
||||
import messages from '../messages';
|
||||
import { useActions } from '../utils';
|
||||
import { inBlackoutDateRange, useActions } from '../utils';
|
||||
|
||||
const ActionsDropdown = ({
|
||||
function 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 isPostingEnabled = useSelector(selectIsPostingEnabled);
|
||||
const blackoutDateRange = useSelector(selectBlackoutDate);
|
||||
const actions = useActions(contentType, id);
|
||||
|
||||
const handleActions = useCallback((action) => {
|
||||
@@ -41,12 +41,12 @@ const ActionsDropdown = ({
|
||||
}
|
||||
}, [actionHandlers]);
|
||||
|
||||
// Find and remove edit action if in Posting is disabled.
|
||||
// Find and remove edit action if in blackout date range.
|
||||
useMemo(() => {
|
||||
if (!isPostingEnabled) {
|
||||
if (inBlackoutDateRange(blackoutDateRange)) {
|
||||
actions.splice(actions.findIndex(action => action.id === 'edit'), 1);
|
||||
}
|
||||
}, [actions, isPostingEnabled]);
|
||||
}, [actions, blackoutDateRange]);
|
||||
|
||||
const onClickButton = useCallback(() => {
|
||||
setTarget(buttonRef.current);
|
||||
@@ -68,7 +68,7 @@ const ActionsDropdown = ({
|
||||
disabled={disabled}
|
||||
size={iconSize}
|
||||
ref={buttonRef}
|
||||
iconClassNames={dropDownIconSize ? 'dropdown-icon-dimensions' : ''}
|
||||
iconClassNames={dropDownIconSize ? 'dropdown-icon-dimentions' : ''}
|
||||
/>
|
||||
<div className="actions-dropdown">
|
||||
<ModalPopup
|
||||
@@ -109,7 +109,7 @@ const ActionsDropdown = ({
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
ActionsDropdown.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
|
||||
@@ -13,8 +13,6 @@ 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';
|
||||
@@ -31,7 +29,6 @@ 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';
|
||||
@@ -173,7 +170,7 @@ const findOpenActionsDropdownButton = async () => (
|
||||
);
|
||||
|
||||
describe('ActionsDropdown', () => {
|
||||
beforeEach(async () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
@@ -185,11 +182,6 @@ 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) => {
|
||||
|
||||
@@ -100,7 +100,7 @@ const AuthorLabel = ({
|
||||
{postCreatedAt && (
|
||||
<span
|
||||
title={postCreatedAt}
|
||||
className={classNames('align-content-center', {
|
||||
className={classNames('font-family-inter align-content-center', {
|
||||
'text-white': alert,
|
||||
'text-gray-500': !alert,
|
||||
})}
|
||||
|
||||
@@ -66,20 +66,19 @@ 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);
|
||||
|
||||
@@ -88,11 +87,9 @@ 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]');
|
||||
@@ -107,7 +104,6 @@ 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';
|
||||
|
||||
const Confirmation = ({
|
||||
function Confirmation({
|
||||
isOpen,
|
||||
title,
|
||||
description,
|
||||
onClose,
|
||||
confirmAction,
|
||||
closeButtonVariant,
|
||||
comfirmAction,
|
||||
closeButtonVaraint,
|
||||
confirmButtonVariant,
|
||||
confirmButtonText,
|
||||
}) => {
|
||||
}) {
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
@@ -30,31 +30,31 @@ const Confirmation = ({
|
||||
</ModalDialog.Body>
|
||||
<ModalDialog.Footer>
|
||||
<ActionRow>
|
||||
<ModalDialog.CloseButton variant={closeButtonVariant}>
|
||||
<ModalDialog.CloseButton variant={closeButtonVaraint}>
|
||||
{intl.formatMessage(messages.confirmationCancel)}
|
||||
</ModalDialog.CloseButton>
|
||||
<Button variant={confirmButtonVariant} onClick={confirmAction}>
|
||||
<Button variant={confirmButtonVariant} onClick={comfirmAction}>
|
||||
{ confirmButtonText || intl.formatMessage(messages.confirmationConfirm)}
|
||||
</Button>
|
||||
</ActionRow>
|
||||
</ModalDialog.Footer>
|
||||
</ModalDialog>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
Confirmation.propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
confirmAction: PropTypes.func.isRequired,
|
||||
comfirmAction: PropTypes.func.isRequired,
|
||||
title: PropTypes.string.isRequired,
|
||||
description: PropTypes.string.isRequired,
|
||||
closeButtonVariant: PropTypes.string,
|
||||
closeButtonVaraint: PropTypes.string,
|
||||
confirmButtonVariant: PropTypes.string,
|
||||
confirmButtonText: PropTypes.string,
|
||||
};
|
||||
|
||||
Confirmation.defaultProps = {
|
||||
closeButtonVariant: 'default',
|
||||
closeButtonVaraint: 'default',
|
||||
confirmButtonVariant: 'primary',
|
||||
confirmButtonText: '',
|
||||
};
|
||||
|
||||
@@ -13,12 +13,12 @@ import { PostCommentsContext } from '../post-comments/postCommentsContext';
|
||||
import AuthorLabel from './AuthorLabel';
|
||||
import timeLocale from './time-locale';
|
||||
|
||||
const EndorsedAlertBanner = ({
|
||||
function EndorsedAlertBanner({
|
||||
endorsed,
|
||||
endorsedAt,
|
||||
endorsedBy,
|
||||
endorsedByLabel,
|
||||
}) => {
|
||||
}) {
|
||||
timeago.register('time-locale', timeLocale);
|
||||
|
||||
const intl = useIntl();
|
||||
@@ -43,7 +43,7 @@ const EndorsedAlertBanner = ({
|
||||
height: '20px',
|
||||
}}
|
||||
/>
|
||||
<strong className="ml-2">
|
||||
<strong className="ml-2 font-family-inter">
|
||||
{intl.formatMessage(isQuestion ? messages.answer : messages.endorsed)}
|
||||
</strong>
|
||||
</div>
|
||||
@@ -62,7 +62,7 @@ const EndorsedAlertBanner = ({
|
||||
</Alert>
|
||||
)
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
EndorsedAlertBanner.propTypes = {
|
||||
endorsed: PropTypes.bool.isRequired,
|
||||
|
||||
@@ -22,7 +22,9 @@ function buildTestContent(type, buildParams) {
|
||||
return camelCaseObject(Factory.build(type, { ...buildParamsSnakeCase }, null));
|
||||
}
|
||||
|
||||
const renderComponent = (content, postType) => {
|
||||
function renderComponent(
|
||||
content, postType,
|
||||
) {
|
||||
render(
|
||||
<IntlProvider locale="en">
|
||||
<AppProvider store={store}>
|
||||
@@ -45,7 +47,7 @@ const renderComponent = (content, postType) => {
|
||||
</AppProvider>
|
||||
</IntlProvider>,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
describe.each([
|
||||
{
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import {
|
||||
StarFilled, StarOutline, ThumbUpFilled, ThumbUpOutline,
|
||||
} from '../../components/icons';
|
||||
import { useUserPostingEnabled } from '../data/hooks';
|
||||
import { useUserCanAddThreadInBlackoutDate } 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 isUserPrivilegedInPostingRestriction = useUserPostingEnabled();
|
||||
const userCanAddThreadInBlackoutDate = useUserCanAddThreadInBlackoutDate();
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -39,14 +39,12 @@ const HoverCard = ({
|
||||
data-testid={`hover-card-${id}`}
|
||||
id={`hover-card-${id}`}
|
||||
>
|
||||
{isUserPrivilegedInPostingRestriction && (
|
||||
{userCanAddThreadInBlackoutDate && (
|
||||
<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' }}
|
||||
@@ -85,7 +83,7 @@ const HoverCard = ({
|
||||
iconAs={Icon}
|
||||
size="sm"
|
||||
alt="Like"
|
||||
iconClassNames="like-icon-dimensions"
|
||||
iconClassNames="like-icon-dimentions"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onLike();
|
||||
@@ -99,7 +97,7 @@ const HoverCard = ({
|
||||
iconAs={Icon}
|
||||
size="sm"
|
||||
alt="Follow"
|
||||
iconClassNames="follow-icon-dimensions"
|
||||
iconClassNames="follow-icon-dimentions"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onFollow();
|
||||
@@ -127,7 +125,6 @@ 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,
|
||||
|
||||
@@ -6,17 +6,15 @@ import { IntlProvider } from 'react-intl';
|
||||
import { MemoryRouter, Route } from 'react-router';
|
||||
import { Factory } from 'rosie';
|
||||
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { camelCaseObject, 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 } from '../post-comments/data/thunks';
|
||||
import { fetchCommentResponses, fetchThreadComments } from '../post-comments/data/thunks';
|
||||
import { getThreadsApiUrl } from '../posts/data/api';
|
||||
import { fetchThreads } from '../posts/data/thunks';
|
||||
import { DiscussionContext } from './context';
|
||||
@@ -27,11 +25,45 @@ 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}/`;
|
||||
@@ -43,15 +75,13 @@ 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);
|
||||
});
|
||||
@@ -86,7 +116,6 @@ describe('HoverCard', () => {
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
isPostingEnabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -94,17 +123,26 @@ describe('HoverCard', () => {
|
||||
Factory.resetAll();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
axiosMock.onGet(threadsApiUrl).reply(200, Factory.build('threadsResult'));
|
||||
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,
|
||||
}));
|
||||
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,
|
||||
})];
|
||||
});
|
||||
|
||||
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
|
||||
await executeThunk(fetchThreads(courseId), store.dispatch, store.getState);
|
||||
await mockAxiosReturnPagedComments();
|
||||
await mockAxiosReturnPagedCommentsResponses();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { useEnableInContextSidebar } from '../data/hooks';
|
||||
|
||||
const withConditionalInContextRendering = (WrappedComponent, condition) => (
|
||||
function SidebarConditionalRenderer(props) {
|
||||
const enableInContextSidebar = useEnableInContextSidebar();
|
||||
|
||||
return enableInContextSidebar === condition && <WrappedComponent {...props} />;
|
||||
}
|
||||
);
|
||||
|
||||
export default withConditionalInContextRendering;
|
||||
@@ -11,9 +11,7 @@ import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { AppContext } from '@edx/frontend-platform/react';
|
||||
import { breakpoints, useWindowSize } from '@edx/paragon';
|
||||
|
||||
import {
|
||||
ALL_ROUTES, BASE_PATH, RequestStatus, Routes,
|
||||
} from '../../data/constants';
|
||||
import { RequestStatus, Routes } from '../../data/constants';
|
||||
import { selectTopicsUnderCategory } from '../../data/selectors';
|
||||
import { fetchCourseBlocks } from '../../data/thunks';
|
||||
import { DiscussionContext } from '../common/context';
|
||||
@@ -22,15 +20,15 @@ import { threadsLoadingStatus } from '../posts/data/selectors';
|
||||
import { selectTopics } from '../topics/data/selectors';
|
||||
import tourCheckpoints from '../tours/constants';
|
||||
import { selectTours } from '../tours/data/selectors';
|
||||
import { fetchDiscussionTours, updateTourShowStatus } from '../tours/data/thunks';
|
||||
import { updateTourShowStatus } from '../tours/data/thunks';
|
||||
import messages from '../tours/messages';
|
||||
import { discussionsPath } from '../utils';
|
||||
import { discussionsPath, inBlackoutDateRange } from '../utils';
|
||||
import {
|
||||
selectAreThreadsFiltered,
|
||||
selectBlackoutDate,
|
||||
selectEnableInContext,
|
||||
selectIsCourseAdmin,
|
||||
selectIsCourseStaff,
|
||||
selectIsPostingEnabled,
|
||||
selectLearnersTabEnabled,
|
||||
selectModerationSettings,
|
||||
selectPostThreadCount,
|
||||
@@ -42,14 +40,12 @@ 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;
|
||||
}
|
||||
@@ -71,6 +67,44 @@ export const useSidebarVisible = () => {
|
||||
return !hideSidebar;
|
||||
};
|
||||
|
||||
export function useCourseDiscussionData(courseId) {
|
||||
const dispatch = useDispatch();
|
||||
const { authenticatedUser } = useContext(AppContext);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchBaseData() {
|
||||
await dispatch(fetchCourseConfig(courseId));
|
||||
await dispatch(fetchCourseBlocks(courseId, authenticatedUser.username));
|
||||
}
|
||||
|
||||
fetchBaseData();
|
||||
}, [courseId]);
|
||||
}
|
||||
|
||||
export function useRedirectToThread(courseId, enableInContextSidebar) {
|
||||
const dispatch = useDispatch();
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
|
||||
const redirectToThread = useSelector(
|
||||
(state) => state.threads.redirectToThread,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// After posting a new thread we'd like to redirect users to it, the topic and post id are temporarily
|
||||
// stored in redirectToThread
|
||||
if (redirectToThread) {
|
||||
dispatch(clearRedirect());
|
||||
const newLocation = discussionsPath(Routes.COMMENTS.PAGES[enableInContextSidebar ? 'topics' : 'my-posts'], {
|
||||
courseId,
|
||||
postId: redirectToThread.threadId,
|
||||
topicId: redirectToThread.topicId,
|
||||
})(location);
|
||||
history.push(newLocation);
|
||||
}
|
||||
}, [redirectToThread]);
|
||||
}
|
||||
|
||||
export function useIsOnDesktop() {
|
||||
const windowSize = useWindowSize();
|
||||
return windowSize.width >= breakpoints.medium.minWidth;
|
||||
@@ -156,16 +190,17 @@ export const useCurrentDiscussionTopic = () => {
|
||||
return null;
|
||||
};
|
||||
|
||||
export const useUserPostingEnabled = () => {
|
||||
const isPostingEnabled = useSelector(selectIsPostingEnabled);
|
||||
export const useUserCanAddThreadInBlackoutDate = () => {
|
||||
const blackoutDateRange = useSelector(selectBlackoutDate);
|
||||
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 (isPostingEnabled || isPrivileged);
|
||||
return (!(isInBlackoutDateRange) || (isPrivileged));
|
||||
};
|
||||
|
||||
function camelToConstant(string) {
|
||||
@@ -187,7 +222,7 @@ export const useTourConfiguration = () => {
|
||||
), []);
|
||||
|
||||
const toursConfig = useMemo(() => (
|
||||
tours?.map((tour) => Object.keys(tourCheckpoints(intl)).includes(tour.tourName) && (
|
||||
tours?.map((tour) => (
|
||||
{
|
||||
tourId: tour.tourName,
|
||||
advanceButtonText: intl.formatMessage(messages.advanceButtonText),
|
||||
@@ -224,89 +259,3 @@ export const useDebounce = (value, delay) => {
|
||||
);
|
||||
return debouncedValue;
|
||||
};
|
||||
|
||||
export const useEnableInContextSidebar = () => {
|
||||
const location = useLocation();
|
||||
|
||||
return Boolean(new URLSearchParams(location.search).get('inContextSidebar') !== null);
|
||||
};
|
||||
|
||||
export const useCourseId = () => {
|
||||
const { params: { courseId } } = useRouteMatch(BASE_PATH);
|
||||
|
||||
return courseId;
|
||||
};
|
||||
|
||||
export const useCurrentPage = () => {
|
||||
const { params: { page } } = useRouteMatch(`${Routes.COMMENTS.PAGE}?`);
|
||||
|
||||
return page;
|
||||
};
|
||||
|
||||
export const usePostId = () => {
|
||||
const { params: { postId } } = useRouteMatch(ALL_ROUTES);
|
||||
|
||||
return postId;
|
||||
};
|
||||
|
||||
export const useLearnerUsername = () => {
|
||||
const { params: { learnerUsername } } = useRouteMatch(ALL_ROUTES);
|
||||
|
||||
return learnerUsername;
|
||||
};
|
||||
|
||||
export const useTopicId = () => {
|
||||
const { params: { topicId } } = useRouteMatch(ALL_ROUTES);
|
||||
|
||||
return topicId;
|
||||
};
|
||||
|
||||
export const useCategory = () => {
|
||||
const { params: { category } } = useRouteMatch(ALL_ROUTES);
|
||||
|
||||
return category;
|
||||
};
|
||||
|
||||
export function useRedirectToThread() {
|
||||
const dispatch = useDispatch();
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
const courseId = useCourseId();
|
||||
const enableInContextSidebar = useEnableInContextSidebar();
|
||||
|
||||
const redirectToThread = useSelector(
|
||||
(state) => state.threads.redirectToThread,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// After posting a new thread we'd like to redirect users to it, the topic and post id are temporarily
|
||||
// stored in redirectToThread
|
||||
if (redirectToThread) {
|
||||
dispatch(clearRedirect());
|
||||
const newLocation = discussionsPath(Routes.COMMENTS.PAGES[enableInContextSidebar ? 'topics' : 'my-posts'], {
|
||||
courseId,
|
||||
postId: redirectToThread.threadId,
|
||||
topicId: redirectToThread.topicId,
|
||||
})(location);
|
||||
history.push(newLocation);
|
||||
}
|
||||
}, [redirectToThread]);
|
||||
}
|
||||
|
||||
export function useCourseDiscussionData() {
|
||||
const dispatch = useDispatch();
|
||||
const courseId = useCourseId();
|
||||
const { authenticatedUser } = useContext(AppContext);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchBaseData() {
|
||||
await Promise.all([
|
||||
dispatch(fetchCourseConfig(courseId)),
|
||||
dispatch(fetchCourseBlocks(courseId, authenticatedUser.username)),
|
||||
dispatch(fetchDiscussionTours()),
|
||||
]);
|
||||
}
|
||||
|
||||
fetchBaseData();
|
||||
}, [courseId]);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { initializeStore } from '../../store';
|
||||
import { executeThunk } from '../../test-utils';
|
||||
import { DiscussionContext } from '../common/context';
|
||||
import { getCourseConfigApiUrl } from './api';
|
||||
import { useCurrentDiscussionTopic, useUserPostingEnabled } from './hooks';
|
||||
import { useCurrentDiscussionTopic, useUserCanAddThreadInBlackoutDate } 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 = (isPostingEnabled, isCourseAdmin = false) => ({
|
||||
isPostingEnabled,
|
||||
const generateApiResponse = (blackouts = [], isCourseAdmin = false) => ({
|
||||
blackouts,
|
||||
hasModerationPrivileges: false,
|
||||
isGroupTa: false,
|
||||
isCourseAdmin,
|
||||
@@ -30,14 +30,14 @@ const generateApiResponse = (isPostingEnabled, isCourseAdmin = false) => ({
|
||||
|
||||
describe('Hooks', () => {
|
||||
describe('useCurrentDiscussionTopic', () => {
|
||||
const ComponentWithHook = () => {
|
||||
function ComponentWithHook() {
|
||||
const topic = useCurrentDiscussionTopic();
|
||||
return (
|
||||
<div>
|
||||
{String(topic)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function renderComponent({ topicId, category }) {
|
||||
return render(
|
||||
@@ -102,15 +102,15 @@ describe('Hooks', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('useUserPostingEnabled', () => {
|
||||
const ComponentWithHook = () => {
|
||||
const isUserPrivilegedInPostingRestriction = useUserPostingEnabled();
|
||||
describe('useUserCanAddThreadInBlackoutDate', () => {
|
||||
function ComponentWithHook() {
|
||||
const userCanAddThreadInBlackoutDate = useUserCanAddThreadInBlackoutDate();
|
||||
return (
|
||||
<div>
|
||||
{String(isUserPrivilegedInPostingRestriction)}
|
||||
{String(userCanAddThreadInBlackoutDate)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function renderComponent() {
|
||||
return render(
|
||||
@@ -121,7 +121,7 @@ describe('Hooks', () => {
|
||||
</IntlProvider>,
|
||||
);
|
||||
}
|
||||
describe('User can add Thread in Posting Restrictions ', () => {
|
||||
describe('User can add Thread in blackoutdates ', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
@@ -136,34 +136,37 @@ describe('Hooks', () => {
|
||||
store = initializeStore();
|
||||
});
|
||||
|
||||
test('when posting is not disabled and Role is Learner return true', async () => {
|
||||
test('when blackoutdates are not active and Role is Learner return true', async () => {
|
||||
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`)
|
||||
.reply(200, generateApiResponse(true, false));
|
||||
.reply(200, generateApiResponse([], false));
|
||||
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
|
||||
const { queryByText } = renderComponent();
|
||||
expect(queryByText('true')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('when posting is not disabled and Role is not Learner return true', async () => {
|
||||
test('when blackoutdates are not active and Role is not Learner return true', async () => {
|
||||
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`)
|
||||
.reply(200, generateApiResponse(true, true));
|
||||
.reply(200, generateApiResponse([], true));
|
||||
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
|
||||
const { queryByText } = renderComponent();
|
||||
expect(queryByText('true')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('when posting is disabled and Role is Learner return false', async () => {
|
||||
test('when blackoutdates are active and Role is Learner return false', async () => {
|
||||
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`)
|
||||
.reply(200, generateApiResponse(false, false));
|
||||
.reply(200, generateApiResponse([{
|
||||
start: '2022-11-25T00:00:00Z',
|
||||
end: '2050-11-25T23:59:00Z',
|
||||
}], false));
|
||||
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
|
||||
const { queryByText } = renderComponent();
|
||||
expect(queryByText('false')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('when posting is not disabled and Role is not Learner return true', async () => {
|
||||
test('when blackoutdates are active and Role is not Learner return true', async () => {
|
||||
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`)
|
||||
.reply(200, generateApiResponse(false, true));
|
||||
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
|
||||
.reply(200, generateApiResponse([
|
||||
{ start: '2022-11-25T00:00:00Z', end: '2050-11-25T23:59:00Z' }], true));
|
||||
const { queryByText } = renderComponent();
|
||||
expect(queryByText('true')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ export const selectUserIsStaff = state => state.config.isUserAdmin;
|
||||
|
||||
export const selectUserIsGroupTa = state => state.config.isGroupTa;
|
||||
|
||||
export const selectConfigLoadingStatus = state => state.config.status;
|
||||
export const selectconfigLoadingStatus = state => state.config.status;
|
||||
|
||||
export const selectLearnersTabEnabled = state => state.config.learnersTabEnabled;
|
||||
|
||||
@@ -20,6 +20,8 @@ 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;
|
||||
@@ -28,8 +30,6 @@ 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,
|
||||
|
||||
@@ -7,6 +7,7 @@ const configSlice = createSlice({
|
||||
name: 'config',
|
||||
initialState: {
|
||||
status: RequestStatus.IN_PROGRESS,
|
||||
blackouts: [],
|
||||
allowAnonymous: false,
|
||||
allowAnonymousToPeers: false,
|
||||
userRoles: [],
|
||||
@@ -17,7 +18,6 @@ const configSlice = createSlice({
|
||||
isCourseStaff: false,
|
||||
isUserAdmin: false,
|
||||
learnersTabEnabled: false,
|
||||
isPostingEnabled: false,
|
||||
settings: {
|
||||
divisionScheme: 'none',
|
||||
alwaysDivideInlineDiscussions: false,
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { PageBanner } from '@edx/paragon';
|
||||
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import { selectConfigLoadingStatus, selectIsPostingEnabled } from '../data/selectors';
|
||||
import { selectBlackoutDate } from '../data/selectors';
|
||||
import messages from '../messages';
|
||||
import { inBlackoutDateRange } from '../utils';
|
||||
|
||||
const DiscussionsRestrictionBanner = () => {
|
||||
const BlackoutInformationBanner = () => {
|
||||
const intl = useIntl();
|
||||
const isPostingEnabled = useSelector(selectIsPostingEnabled);
|
||||
const configLoadingStatus = useSelector(selectConfigLoadingStatus);
|
||||
const blackoutDate = useSelector(selectBlackoutDate);
|
||||
const [showBanner, setShowBanner] = useState(true);
|
||||
|
||||
const isDiscussionsBlackout = useMemo(() => (
|
||||
inBlackoutDateRange(blackoutDate)
|
||||
), [blackoutDate]);
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
setShowBanner(false);
|
||||
}, []);
|
||||
@@ -22,7 +25,7 @@ const DiscussionsRestrictionBanner = () => {
|
||||
return (
|
||||
<PageBanner
|
||||
variant="accentB"
|
||||
show={!isPostingEnabled && showBanner && configLoadingStatus === RequestStatus.SUCCESSFUL}
|
||||
show={isDiscussionsBlackout && showBanner}
|
||||
dismissible
|
||||
onDismiss={handleDismiss}
|
||||
>
|
||||
@@ -33,4 +36,4 @@ const DiscussionsRestrictionBanner = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default DiscussionsRestrictionBanner;
|
||||
export default BlackoutInformationBanner;
|
||||
@@ -8,7 +8,7 @@ import { initializeStore } from '../../store';
|
||||
import { DiscussionContext } from '../common/context';
|
||||
import { fetchConfigSuccess } from '../data/slices';
|
||||
import messages from '../messages';
|
||||
import DiscussionsRestrictionBanner from './DiscussionsRestrictionBanner';
|
||||
import BlackoutInformationBanner from './BlackoutInformationBanner';
|
||||
|
||||
let store;
|
||||
let container;
|
||||
@@ -20,13 +20,13 @@ activeEndDate.setDate(activeEndDate.getDate() + 2);
|
||||
activeStartDate = activeStartDate.toISOString();
|
||||
activeEndDate = activeEndDate.toISOString();
|
||||
|
||||
const getConfigData = (isPostingEnabled) => ({
|
||||
const getConfigData = (blackouts = []) => ({
|
||||
id: 'course-v1:edX+DemoX+Demo_Course',
|
||||
userRoles: ['Admin', 'Student'],
|
||||
hasModerationPrivileges: false,
|
||||
isGroupTa: false,
|
||||
isUserAdmin: false,
|
||||
isPostingEnabled,
|
||||
blackouts,
|
||||
});
|
||||
|
||||
function renderComponent() {
|
||||
@@ -34,7 +34,7 @@ function renderComponent() {
|
||||
<IntlProvider locale="en">
|
||||
<AppProvider store={store}>
|
||||
<DiscussionContext.Provider value={{ courseId }}>
|
||||
<DiscussionsRestrictionBanner />
|
||||
<BlackoutInformationBanner />
|
||||
</DiscussionContext.Provider>
|
||||
</AppProvider>
|
||||
</IntlProvider>,
|
||||
@@ -43,7 +43,7 @@ function renderComponent() {
|
||||
return container;
|
||||
}
|
||||
|
||||
describe('Discussions Restriction Banner', () => {
|
||||
describe('Blackout Information Banner', () => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
@@ -56,11 +56,13 @@ describe('Discussions Restriction Banner', () => {
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ isPostingEnabled: false, visibility: true },
|
||||
{ isPostingEnabled: true, visibility: false },
|
||||
])('Test Discussions Restriction is visible on app load if posting is disabled', async ({ isPostingEnabled, visibility }) => {
|
||||
{ blackouts: [], visibility: false },
|
||||
{ blackouts: ['2021-12-31T10:15', '2021-12-31T10:20'], visibility: false },
|
||||
{ blackouts: [{ start: activeStartDate, end: activeEndDate }], visibility: true },
|
||||
{ blackouts: [{ start: activeEndDate, end: activeEndDate }], visibility: false },
|
||||
])('Test Blackout Banner is visible on app load if blackout date is active', async ({ blackouts, visibility }) => {
|
||||
store = initializeStore();
|
||||
await store.dispatch(fetchConfigSuccess(getConfigData(isPostingEnabled)));
|
||||
await store.dispatch(fetchConfigSuccess(getConfigData(blackouts)));
|
||||
renderComponent();
|
||||
if (visibility) {
|
||||
const element = await screen.findByRole('alert');
|
||||
@@ -1,20 +0,0 @@
|
||||
import React, { memo } from 'react';
|
||||
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { LearningHeader } from '@edx/frontend-component-header';
|
||||
|
||||
import selectCourseTabs from '../../components/NavigationBar/data/selectors';
|
||||
import withConditionalInContextRendering from '../common/withConditionalInContextRendering';
|
||||
|
||||
const CourseHeader = () => {
|
||||
const { courseNumber, courseTitle, org } = useSelector(selectCourseTabs);
|
||||
|
||||
console.log('CourseHeader', courseNumber, courseTitle, org);
|
||||
|
||||
return (courseNumber || courseTitle || org) && (
|
||||
<LearningHeader courseOrg={org} courseNumber={courseNumber} courseTitle={courseTitle} />
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(withConditionalInContextRendering(CourseHeader, false));
|
||||
@@ -1,24 +0,0 @@
|
||||
import React, { memo } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { useEnableInContextSidebar } from '../data/hooks';
|
||||
import NavigationBar from '../navigation/navigation-bar/NavigationBar';
|
||||
import PostActionsBar from '../posts/post-actions-bar/PostActionsBar';
|
||||
|
||||
const DiscussionActionBar = () => {
|
||||
const enableInContextSidebar = useEnableInContextSidebar();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames('d-flex flex-row justify-content-between navbar fixed-top', {
|
||||
'pl-4 pr-3 py-0': enableInContextSidebar,
|
||||
})}
|
||||
>
|
||||
<NavigationBar />
|
||||
<PostActionsBar />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(DiscussionActionBar);
|
||||
@@ -1,9 +0,0 @@
|
||||
import React, { memo } from 'react';
|
||||
|
||||
import Footer from '@edx/frontend-component-footer';
|
||||
|
||||
import withConditionalInContextRendering from '../common/withConditionalInContextRendering';
|
||||
|
||||
const DiscussionFooter = () => <Footer />;
|
||||
|
||||
export default memo(withConditionalInContextRendering(DiscussionFooter, false));
|
||||
@@ -1,17 +0,0 @@
|
||||
import React, { memo } from 'react';
|
||||
|
||||
import CourseTabsNavigation from '../../components/NavigationBar/CourseTabsNavigation';
|
||||
import CourseHeader from './CourseHeader';
|
||||
|
||||
const DiscussionHeader = () => {
|
||||
console.log('DiscussionHeader');
|
||||
|
||||
return (
|
||||
<>
|
||||
<CourseHeader />
|
||||
<CourseTabsNavigation />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(DiscussionHeader);
|
||||
@@ -1,53 +0,0 @@
|
||||
import React, { lazy, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { useEnableInContextSidebar } from '../data/hooks';
|
||||
import DiscussionsProductTour from '../tours/DiscussionsProductTour';
|
||||
import DiscussionActionBar from './DiscussionActionBar';
|
||||
import DiscussionFooter from './DiscussionFooter';
|
||||
import DiscussionHeader from './DiscussionHeader';
|
||||
import DiscussionSidebar from './DiscussionSidebar';
|
||||
import InfoPage from './InfoPage';
|
||||
import LayoutSwitcher from './LayoutSwitcher';
|
||||
import LegacyBreadcrumb from './LegacyBreadcrumb';
|
||||
|
||||
const DiscussionsRestrictionBanner = lazy(() => import('./DiscussionsRestrictionBanner'));
|
||||
|
||||
const DiscussionsLayout = ({ children }) => {
|
||||
const postActionBarRef = useRef(null);
|
||||
const enableInContextSidebar = useEnableInContextSidebar();
|
||||
|
||||
return (
|
||||
<>
|
||||
<DiscussionHeader />
|
||||
<main className="container-fluid d-flex flex-column p-0 w-100" id="main" tabIndex="-1">
|
||||
<div
|
||||
ref={postActionBarRef}
|
||||
className={classNames('header-action-bar', {
|
||||
'shadow-none border-light-300 border-bottom': enableInContextSidebar,
|
||||
})}
|
||||
>
|
||||
<DiscussionActionBar />
|
||||
<DiscussionsRestrictionBanner />
|
||||
</div>
|
||||
<LegacyBreadcrumb />
|
||||
<LayoutSwitcher
|
||||
sidebar={<DiscussionSidebar postActionBarRef={postActionBarRef} />}
|
||||
infoPage={<InfoPage />}
|
||||
>
|
||||
{children}
|
||||
</LayoutSwitcher>
|
||||
<DiscussionsProductTour />
|
||||
</main>
|
||||
<DiscussionFooter />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
DiscussionsLayout.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
};
|
||||
|
||||
export default React.memo(DiscussionsLayout);
|
||||
@@ -1,16 +1,23 @@
|
||||
import React, { lazy, Suspense } from 'react';
|
||||
import React, {
|
||||
lazy, Suspense, useContext, useEffect, useRef,
|
||||
} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { useSelector } from 'react-redux';
|
||||
import {
|
||||
Redirect, Route, Switch, useLocation,
|
||||
} from 'react-router';
|
||||
|
||||
import { useWindowSize } from '@edx/paragon';
|
||||
|
||||
import Spinner from '../../components/Spinner';
|
||||
import { RequestStatus, Routes } from '../../data/constants';
|
||||
import { useEnableInContextSidebar, useShowLearnersTab } from '../data/hooks';
|
||||
import { selectConfigLoadingStatus, selectEnableInContext } from '../data/selectors';
|
||||
import ResizableSidebar from './ResizableSidebar';
|
||||
import { DiscussionContext } from '../common/context';
|
||||
import {
|
||||
useContainerSize, useIsOnDesktop, useIsOnXLDesktop, useShowLearnersTab,
|
||||
} 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'));
|
||||
@@ -19,24 +26,51 @@ const LearnersView = lazy(() => import('../learners/LearnersView'));
|
||||
const PostsView = lazy(() => import('../posts/PostsView'));
|
||||
const LegacyTopicsView = lazy(() => import('../topics/TopicsView'));
|
||||
|
||||
const DiscussionSidebar = ({ postActionBarRef }) => {
|
||||
const DiscussionSidebar = ({ displaySidebar, postActionBarRef }) => {
|
||||
const location = useLocation();
|
||||
const enableInContextSidebar = useEnableInContextSidebar();
|
||||
const isOnDesktop = useIsOnDesktop();
|
||||
const isOnXLDesktop = useIsOnXLDesktop();
|
||||
const { enableInContextSidebar } = useContext(DiscussionContext);
|
||||
const enableInContext = useSelector(selectEnableInContext);
|
||||
const configStatus = useSelector(selectConfigLoadingStatus);
|
||||
const configStatus = useSelector(selectconfigLoadingStatus);
|
||||
const redirectToLearnersTab = useShowLearnersTab();
|
||||
const sidebarRef = useRef(null);
|
||||
const postActionBarHeight = useContainerSize(postActionBarRef);
|
||||
const { height: windowHeight } = useWindowSize();
|
||||
|
||||
const memoizedRedirection = React.useMemo(() => (
|
||||
<Suspense fallback={(<Spinner />)}>
|
||||
<Switch>
|
||||
{enableInContext && !enableInContextSidebar && (
|
||||
useEffect(() => {
|
||||
if (sidebarRef && postActionBarHeight && !enableInContextSidebar) {
|
||||
if (isOnDesktop) {
|
||||
sidebarRef.current.style.maxHeight = `${windowHeight - postActionBarHeight}px`;
|
||||
}
|
||||
sidebarRef.current.style.minHeight = `${windowHeight - postActionBarHeight}px`;
|
||||
sidebarRef.current.style.top = `${postActionBarHeight}px`;
|
||||
}
|
||||
}, [sidebarRef, postActionBarHeight, enableInContextSidebar]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={sidebarRef}
|
||||
className={classNames('flex-column position-sticky', {
|
||||
'd-none': !displaySidebar,
|
||||
'd-flex overflow-auto box-shadow-centered-1': displaySidebar,
|
||||
'w-100': !isOnDesktop,
|
||||
'sidebar-desktop-width': isOnDesktop && !isOnXLDesktop,
|
||||
'w-25 sidebar-XL-width': isOnXLDesktop,
|
||||
'min-content-height': !enableInContextSidebar,
|
||||
})}
|
||||
data-testid="sidebar"
|
||||
>
|
||||
<Suspense fallback={(<Spinner />)}>
|
||||
<Switch>
|
||||
{enableInContext && !enableInContextSidebar && (
|
||||
<Route
|
||||
path={Routes.TOPICS.ALL}
|
||||
component={InContextTopicsView}
|
||||
exact
|
||||
/>
|
||||
)}
|
||||
{enableInContext && !enableInContextSidebar && (
|
||||
)}
|
||||
{enableInContext && !enableInContextSidebar && (
|
||||
<Route
|
||||
path={[
|
||||
Routes.TOPICS.TOPIC,
|
||||
@@ -47,19 +81,19 @@ const DiscussionSidebar = ({ postActionBarRef }) => {
|
||||
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.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 && (
|
||||
)}
|
||||
{redirectToLearnersTab && (
|
||||
<Route path={Routes.LEARNERS.PATH} component={LearnersView} />
|
||||
)}
|
||||
{configStatus === RequestStatus.SUCCESSFUL && (
|
||||
)}
|
||||
{configStatus === RequestStatus.SUCCESSFUL && (
|
||||
<Redirect
|
||||
from={Routes.DISCUSSIONS.PATH}
|
||||
to={{
|
||||
@@ -67,23 +101,24 @@ const DiscussionSidebar = ({ postActionBarRef }) => {
|
||||
pathname: Routes.POSTS.ALL_POSTS,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Switch>
|
||||
</Suspense>
|
||||
), [enableInContext, enableInContextSidebar, configStatus, location, redirectToLearnersTab]);
|
||||
|
||||
return (
|
||||
<ResizableSidebar postActionBarRef={postActionBarRef}>
|
||||
{memoizedRedirection}
|
||||
</ResizableSidebar>
|
||||
)}
|
||||
</Switch>
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
DiscussionSidebar.propTypes = {
|
||||
displaySidebar: PropTypes.bool,
|
||||
postActionBarRef: PropTypes.oneOfType([
|
||||
PropTypes.func,
|
||||
PropTypes.shape({ current: PropTypes.instanceOf(Element) }),
|
||||
]).isRequired,
|
||||
]),
|
||||
};
|
||||
|
||||
DiscussionSidebar.defaultProps = {
|
||||
displaySidebar: false,
|
||||
postActionBarRef: null,
|
||||
};
|
||||
|
||||
export default React.memo(DiscussionSidebar);
|
||||
|
||||
@@ -1,47 +1,143 @@
|
||||
import React, { lazy, Suspense, useMemo } from 'react';
|
||||
import React, { lazy, Suspense, useRef } from 'react';
|
||||
|
||||
import { useRouteMatch } from 'react-router';
|
||||
import classNames from 'classnames';
|
||||
import { useSelector } from 'react-redux';
|
||||
import {
|
||||
Route, Switch, useLocation, useRouteMatch,
|
||||
} from 'react-router';
|
||||
|
||||
import { LearningHeader as Header } from '@edx/frontend-component-header';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
import { Spinner } from '../../components';
|
||||
import { ALL_ROUTES } from '../../data/constants';
|
||||
import { selectCourseTabs } from '../../components/NavigationBar/data/selectors';
|
||||
import { ALL_ROUTES, DiscussionProvider, Routes } from '../../data/constants';
|
||||
import { DiscussionContext } from '../common/context';
|
||||
import {
|
||||
useCourseDiscussionData, useCurrentPage, useEnableInContextSidebar, useRedirectToThread,
|
||||
useCourseDiscussionData, useIsOnDesktop, useRedirectToThread, useShowLearnersTab, useSidebarVisible,
|
||||
} from '../data/hooks';
|
||||
import DiscussionLayout from './DiscussionLayout';
|
||||
import { selectDiscussionProvider, selectEnableInContext } from '../data/selectors';
|
||||
import { EmptyLearners, EmptyPosts, EmptyTopics } from '../empty-posts';
|
||||
import { EmptyTopic as InContextEmptyTopics } from '../in-context-topics/components';
|
||||
import messages from '../messages';
|
||||
import { selectPostEditorVisible } from '../posts/data/selectors';
|
||||
import useFeedbackWrapper from './FeedbackWrapper';
|
||||
|
||||
const Footer = lazy(() => import('@edx/frontend-component-footer'));
|
||||
const PostActionsBar = lazy(() => import('../posts/post-actions-bar/PostActionsBar'));
|
||||
const CourseTabsNavigation = lazy(() => import('../../components/NavigationBar/CourseTabsNavigation'));
|
||||
const LegacyBreadcrumbMenu = lazy(() => import('../navigation/breadcrumb-menu/LegacyBreadcrumbMenu'));
|
||||
const NavigationBar = lazy(() => import('../navigation/navigation-bar/NavigationBar'));
|
||||
const DiscussionsProductTour = lazy(() => import('../tours/DiscussionsProductTour'));
|
||||
const BlackoutInformationBanner = lazy(() => import('./BlackoutInformationBanner'));
|
||||
const DiscussionContent = lazy(() => import('./DiscussionContent'));
|
||||
const DiscussionSidebar = lazy(() => import('./DiscussionSidebar'));
|
||||
const InformationBanner = lazy(() => import('./InformationBanner'));
|
||||
|
||||
const DiscussionsHome = () => {
|
||||
useCourseDiscussionData();
|
||||
useRedirectToThread();
|
||||
useFeedbackWrapper();
|
||||
const page = useCurrentPage();
|
||||
const enableInContextSidebar = useEnableInContextSidebar();
|
||||
const location = useLocation();
|
||||
const postActionBarRef = useRef(null);
|
||||
const postEditorVisible = useSelector(selectPostEditorVisible);
|
||||
const provider = useSelector(selectDiscussionProvider);
|
||||
const enableInContext = useSelector(selectEnableInContext);
|
||||
const { courseNumber, courseTitle, org } = useSelector(selectCourseTabs);
|
||||
const { params: { page } } = useRouteMatch(`${Routes.COMMENTS.PAGE}?`);
|
||||
const { params } = useRouteMatch(ALL_ROUTES);
|
||||
const isRedirectToLearners = useShowLearnersTab();
|
||||
const isOnDesktop = useIsOnDesktop();
|
||||
let displaySidebar = useSidebarVisible();
|
||||
const enableInContextSidebar = Boolean(new URLSearchParams(location.search).get('inContextSidebar') !== null);
|
||||
const isFeedbackBannerVisible = getConfig().DISPLAY_FEEDBACK_BANNER === 'true';
|
||||
const {
|
||||
params: {
|
||||
courseId, postId, topicId, category, learnerUsername,
|
||||
},
|
||||
} = useRouteMatch(ALL_ROUTES);
|
||||
courseId, postId, topicId, category, learnerUsername,
|
||||
} = params;
|
||||
|
||||
const contextValues = useMemo(() => ({
|
||||
page,
|
||||
courseId,
|
||||
postId,
|
||||
topicId,
|
||||
enableInContextSidebar,
|
||||
category,
|
||||
learnerUsername,
|
||||
}), [page, courseId, postId, topicId, enableInContextSidebar, category, learnerUsername]);
|
||||
useCourseDiscussionData(courseId);
|
||||
useRedirectToThread(courseId, enableInContextSidebar);
|
||||
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. */
|
||||
const displayContentArea = (postId || postEditorVisible || (learnerUsername && postId));
|
||||
if (displayContentArea) { displaySidebar = isOnDesktop; }
|
||||
|
||||
return (
|
||||
<Suspense fallback={(<Spinner />)}>
|
||||
<DiscussionLayout>
|
||||
<DiscussionContext.Provider value={contextValues}>
|
||||
<DiscussionContent />
|
||||
</DiscussionContext.Provider>
|
||||
</DiscussionLayout>
|
||||
<DiscussionContext.Provider value={{
|
||||
page,
|
||||
courseId,
|
||||
postId,
|
||||
topicId,
|
||||
enableInContextSidebar,
|
||||
category,
|
||||
learnerUsername,
|
||||
}}
|
||||
>
|
||||
{!enableInContextSidebar && (
|
||||
<Header courseOrg={org} courseNumber={courseNumber} courseTitle={courseTitle} />
|
||||
)}
|
||||
<main className="container-fluid d-flex flex-column p-0 w-100" id="main" tabIndex="-1">
|
||||
{!enableInContextSidebar && <CourseTabsNavigation activeTab="discussion" courseId={courseId} />}
|
||||
<div
|
||||
className={classNames('header-action-bar', {
|
||||
'shadow-none border-light-300 border-bottom': enableInContextSidebar,
|
||||
})}
|
||||
ref={postActionBarRef}
|
||||
>
|
||||
<div
|
||||
className={classNames('d-flex flex-row justify-content-between navbar fixed-top', {
|
||||
'pl-4 pr-3 py-0': enableInContextSidebar,
|
||||
})}
|
||||
>
|
||||
{!enableInContextSidebar && (
|
||||
<NavigationBar />
|
||||
)}
|
||||
<PostActionsBar />
|
||||
</div>
|
||||
{isFeedbackBannerVisible && <InformationBanner />}
|
||||
<BlackoutInformationBanner />
|
||||
</div>
|
||||
{provider === DiscussionProvider.LEGACY && (
|
||||
<Suspense fallback={(<Spinner />)}>
|
||||
<Route
|
||||
path={[Routes.POSTS.PATH, Routes.TOPICS.CATEGORY]}
|
||||
component={LegacyBreadcrumbMenu}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
<div className="d-flex flex-row position-relative">
|
||||
<Suspense fallback={(<Spinner />)}>
|
||||
<DiscussionSidebar displaySidebar={displaySidebar} postActionBarRef={postActionBarRef} />
|
||||
</Suspense>
|
||||
{displayContentArea && (
|
||||
<Suspense fallback={(<Spinner />)}>
|
||||
<DiscussionContent />
|
||||
</Suspense>
|
||||
)}
|
||||
{!displayContentArea && (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
{!enableInContextSidebar && (
|
||||
<DiscussionsProductTour />
|
||||
)}
|
||||
</main>
|
||||
{!enableInContextSidebar && <Footer />}
|
||||
</DiscussionContext.Provider>
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -127,7 +127,7 @@ describe('DiscussionsHome', () => {
|
||||
|
||||
test('header, course navigation bar and footer are only visible in Discussions MFE', async () => {
|
||||
renderComponent();
|
||||
waitFor(() => expect(screen.queryByRole('banner')).toBeInTheDocument());
|
||||
expect(screen.queryByRole('banner')).toBeInTheDocument();
|
||||
expect(document.getElementById('courseTabsNavigation')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('contentinfo')).toBeInTheDocument();
|
||||
});
|
||||
@@ -172,8 +172,7 @@ 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,
|
||||
@@ -194,8 +193,7 @@ 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, {
|
||||
|
||||
@@ -2,12 +2,11 @@ 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,
|
||||
@@ -19,13 +18,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 = getConfig().LEARNER_FEEDBACK_URL;
|
||||
let url = '//w.usabilla.com/9e6036348fa1.js';
|
||||
if (isStaff || isUserGroupTA || isCourseAdmin || isCourseStaff) {
|
||||
url = getConfig().STAFF_FEEDBACK_URL;
|
||||
url = '//w.usabilla.com/767740a06856.js';
|
||||
}
|
||||
try {
|
||||
// eslint-disable-next-line no-undef
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Route, Switch } from 'react-router';
|
||||
|
||||
import { Routes } from '../../data/constants';
|
||||
import { useEnableInContextSidebar, useShowLearnersTab } from '../data/hooks';
|
||||
import { selectEnableInContext } from '../data/selectors';
|
||||
import { EmptyLearners, EmptyPosts, EmptyTopics } from '../empty-posts';
|
||||
import { EmptyTopic as InContextEmptyTopics } from '../in-context-topics/components';
|
||||
import messages from '../messages';
|
||||
|
||||
const InfoPage = () => {
|
||||
const enableInContext = useSelector(selectEnableInContext);
|
||||
const isRedirectToLearners = useShowLearnersTab();
|
||||
const enableInContextSidebar = useEnableInContextSidebar();
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Route
|
||||
path={Routes.TOPICS.PATH}
|
||||
component={(enableInContext || enableInContextSidebar) ? InContextEmptyTopics : EmptyTopics}
|
||||
/>
|
||||
<Route
|
||||
path={Routes.POSTS.MY_POSTS}
|
||||
render={routeProps => <EmptyPosts {...routeProps} subTitleMessage={messages.emptyMyPosts} />}
|
||||
/>
|
||||
<Route
|
||||
path={[Routes.POSTS.PATH, Routes.POSTS.ALL_POSTS, Routes.LEARNERS.POSTS]}
|
||||
render={routeProps => <EmptyPosts {...routeProps} subTitleMessage={messages.emptyAllPosts} />}
|
||||
/>
|
||||
{isRedirectToLearners && <Route path={Routes.LEARNERS.PATH} component={EmptyLearners} />}
|
||||
</Switch>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(InfoPage);
|
||||
63
src/discussions/discussions-home/InformationBanner.jsx
Normal file
63
src/discussions/discussions-home/InformationBanner.jsx
Normal file
@@ -0,0 +1,63 @@
|
||||
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;
|
||||
136
src/discussions/discussions-home/InformationBanner.test.jsx
Normal file
136
src/discussions/discussions-home/InformationBanner.test.jsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { IntlProvider } from 'react-intl';
|
||||
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
|
||||
import { initializeStore } from '../../store';
|
||||
import { DiscussionContext } from '../common/context';
|
||||
import { fetchConfigSuccess } from '../data/slices';
|
||||
import messages from '../messages';
|
||||
import InformationBanner from './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);
|
||||
});
|
||||
});
|
||||
@@ -1,40 +0,0 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import {
|
||||
useIsOnDesktop, useLearnerUsername, usePostId, useSidebarVisible,
|
||||
} from '../data/hooks';
|
||||
import { selectPostEditorVisible } from '../posts/data/selectors';
|
||||
|
||||
const LayoutSwitcher = ({ children, sidebar, infoPage }) => {
|
||||
const postId = usePostId();
|
||||
const learnerUsername = useLearnerUsername();
|
||||
const isOnDesktop = useIsOnDesktop();
|
||||
let displaySidebar = useSidebarVisible();
|
||||
const postEditorVisible = useSelector(selectPostEditorVisible);
|
||||
|
||||
const displayContentArea = useMemo(() => {
|
||||
const isContentVisible = postId || postEditorVisible || (learnerUsername && postId);
|
||||
if (isContentVisible) {
|
||||
displaySidebar = isOnDesktop;
|
||||
}
|
||||
return isContentVisible;
|
||||
}, [postId, postEditorVisible, learnerUsername, isOnDesktop]);
|
||||
|
||||
return (
|
||||
<div className="d-flex flex-row position-relative">
|
||||
{displaySidebar && sidebar }
|
||||
{displayContentArea ? children : infoPage }
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
LayoutSwitcher.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
sidebar: PropTypes.node.isRequired,
|
||||
infoPage: PropTypes.node.isRequired,
|
||||
};
|
||||
|
||||
export default React.memo(LayoutSwitcher);
|
||||
@@ -1,23 +0,0 @@
|
||||
import React, { memo } from 'react';
|
||||
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Route } from 'react-router-dom';
|
||||
|
||||
import { DiscussionProvider, Routes } from '../../data/constants';
|
||||
import { selectDiscussionProvider } from '../data/selectors';
|
||||
import LegacyBreadcrumbMenu from '../navigation/breadcrumb-menu/LegacyBreadcrumbMenu';
|
||||
|
||||
const LegacyBreadcrumb = () => {
|
||||
const provider = useSelector(selectDiscussionProvider);
|
||||
|
||||
return (
|
||||
provider === DiscussionProvider.LEGACY && (
|
||||
<Route
|
||||
path={[Routes.POSTS.PATH, Routes.TOPICS.CATEGORY]}
|
||||
component={LegacyBreadcrumbMenu}
|
||||
/>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(LegacyBreadcrumb);
|
||||
@@ -1,54 +0,0 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { useWindowSize } from '@edx/paragon';
|
||||
|
||||
import {
|
||||
useContainerSize, useEnableInContextSidebar, useIsOnDesktop, useIsOnXLDesktop,
|
||||
} from '../data/hooks';
|
||||
|
||||
const ResizableSidebar = ({ children, postActionBarRef }) => {
|
||||
const sidebarRef = useRef(null);
|
||||
const isOnDesktop = useIsOnDesktop();
|
||||
const isOnXLDesktop = useIsOnXLDesktop();
|
||||
const enableInContextSidebar = useEnableInContextSidebar();
|
||||
const postActionBarHeight = useContainerSize(postActionBarRef);
|
||||
const { height: windowHeight } = useWindowSize();
|
||||
|
||||
useEffect(() => {
|
||||
if (sidebarRef && postActionBarHeight && !enableInContextSidebar) {
|
||||
if (isOnDesktop) {
|
||||
sidebarRef.current.style.maxHeight = `${windowHeight - postActionBarHeight}px`;
|
||||
}
|
||||
sidebarRef.current.style.minHeight = `${windowHeight - postActionBarHeight}px`;
|
||||
sidebarRef.current.style.top = `${postActionBarHeight}px`;
|
||||
}
|
||||
}, [sidebarRef, postActionBarHeight, enableInContextSidebar]);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="sidebar"
|
||||
ref={sidebarRef}
|
||||
className={classNames('flex-column position-sticky d-flex overflow-auto box-shadow-centered-1', {
|
||||
'w-100': !isOnDesktop,
|
||||
'sidebar-desktop-width': isOnDesktop && !isOnXLDesktop,
|
||||
'w-25 sidebar-XL-width': isOnXLDesktop,
|
||||
'min-content-height': !enableInContextSidebar,
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ResizableSidebar.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
postActionBarRef: PropTypes.oneOfType([
|
||||
PropTypes.func,
|
||||
PropTypes.shape({ current: PropTypes.instanceOf(Element) }),
|
||||
]).isRequired,
|
||||
};
|
||||
|
||||
export default React.memo(ResizableSidebar);
|
||||
@@ -8,7 +8,6 @@ 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';
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ describe('EmptyPage', () => {
|
||||
store = initializeStore();
|
||||
});
|
||||
|
||||
test('"posts you\'ve interacted with" message shown when no posts in system', async () => {
|
||||
test('"posts youve interacted with" message shown when no posts in system', async () => {
|
||||
renderComponent(`/${courseId}/my-posts/`);
|
||||
expect(
|
||||
screen.queryByText(messages.emptyMyPosts.defaultMessage),
|
||||
|
||||
@@ -9,7 +9,6 @@ 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';
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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';
|
||||
|
||||
@@ -92,6 +92,7 @@ describe('InContext Topic Posts View', () => {
|
||||
enableInContext: true,
|
||||
provider: 'openedx',
|
||||
hasModerationPrivileges: true,
|
||||
blackouts: [],
|
||||
},
|
||||
});
|
||||
Factory.resetAll();
|
||||
@@ -205,8 +206,7 @@ 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,8 +226,7 @@ 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();
|
||||
@@ -254,8 +253,7 @@ 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' });
|
||||
@@ -263,8 +261,7 @@ 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();
|
||||
|
||||
@@ -9,9 +9,9 @@ import { ArrowBack } from '@edx/paragon/icons';
|
||||
|
||||
import messages from '../messages';
|
||||
|
||||
const BackButton = ({
|
||||
function BackButton({
|
||||
intl, path, title, loading,
|
||||
}) => {
|
||||
}) {
|
||||
const history = useHistory();
|
||||
|
||||
return (
|
||||
@@ -32,7 +32,7 @@ const BackButton = ({
|
||||
<div className="border-bottom border-light-400" />
|
||||
</>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
BackButton.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
|
||||
@@ -20,21 +20,12 @@ 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) => {
|
||||
@@ -60,21 +51,12 @@ 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');
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { memo, useCallback, useEffect } from 'react';
|
||||
import React, { useCallback, useContext, useEffect } from 'react';
|
||||
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
@@ -6,15 +6,14 @@ import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Icon, SearchField } from '@edx/paragon';
|
||||
import { Search as SearchIcon } from '@edx/paragon/icons';
|
||||
|
||||
import { useCurrentPage } from '../../data/hooks';
|
||||
import { DiscussionContext } from '../../common/context';
|
||||
import postsMessages from '../../posts/post-actions-bar/messages';
|
||||
import { setFilter as setTopicFilter } from '../data/slices';
|
||||
|
||||
const TopicSearchBar = () => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
const page = useCurrentPage();
|
||||
|
||||
const { page } = useContext(DiscussionContext);
|
||||
const topicSearch = useSelector(({ inContextTopics }) => inContextTopics.filter);
|
||||
let searchValue = '';
|
||||
|
||||
@@ -58,4 +57,4 @@ const TopicSearchBar = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(TopicSearchBar);
|
||||
export default TopicSearchBar;
|
||||
|
||||
@@ -8,7 +8,7 @@ import { SearchField } from '@edx/paragon';
|
||||
import { setFilter } from '../data';
|
||||
import messages from '../messages';
|
||||
|
||||
const TopicSearchResultBar = ({ intl }) => {
|
||||
function TopicSearchResultBar({ intl }) {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
return (
|
||||
@@ -21,7 +21,7 @@ const TopicSearchResultBar = ({ intl }) => {
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
TopicSearchResultBar.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable react/prop-types */
|
||||
/* eslint-disable no-unused-vars, react/forbid-prop-types */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
@@ -110,8 +110,7 @@ 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();
|
||||
|
||||
@@ -123,8 +122,7 @@ 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();
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Button, Spinner } from '@edx/paragon';
|
||||
|
||||
import SearchInfo from '../../components/SearchInfo';
|
||||
import { RequestStatus, Routes } from '../../data/constants';
|
||||
import { selectConfigLoadingStatus, selectLearnersTabEnabled } from '../data/selectors';
|
||||
import { selectconfigLoadingStatus, selectLearnersTabEnabled } from '../data/selectors';
|
||||
import NoResults from '../posts/NoResults';
|
||||
import {
|
||||
learnersLoadingStatus,
|
||||
@@ -33,7 +33,7 @@ const LearnersView = () => {
|
||||
const nextPage = useSelector(selectLearnerNextPage());
|
||||
const loadingStatus = useSelector(learnersLoadingStatus());
|
||||
const usernameSearch = useSelector(selectUsernameSearch());
|
||||
const courseConfigLoadingStatus = useSelector(selectConfigLoadingStatus);
|
||||
const courseConfigLoadingStatus = useSelector(selectconfigLoadingStatus);
|
||||
const learnersTabEnabled = useSelector(selectLearnersTabEnabled);
|
||||
const learners = useSelector(selectAllLearners);
|
||||
|
||||
@@ -66,7 +66,6 @@ const LearnersView = () => {
|
||||
courseConfigLoadingStatus === RequestStatus.SUCCESSFUL && learnersTabEnabled && learners.map((learner) => (
|
||||
<LearnerCard learner={learner} key={learner.username} />
|
||||
))
|
||||
// eslint-disable-next-line react/jsx-no-useless-fragment
|
||||
) || <></>
|
||||
), [courseConfigLoadingStatus, learnersTabEnabled, learners]);
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable default-param-last */
|
||||
import React from 'react';
|
||||
|
||||
import {
|
||||
@@ -202,8 +201,7 @@ 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,
|
||||
}) => {
|
||||
@@ -229,8 +227,7 @@ 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();
|
||||
@@ -260,8 +257,7 @@ 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);
|
||||
@@ -278,8 +274,7 @@ 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,8 +29,7 @@ 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();
|
||||
@@ -39,23 +38,20 @@ 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,8 +37,7 @@ 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();
|
||||
|
||||
@@ -56,8 +55,7 @@ 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];
|
||||
@@ -67,11 +65,9 @@ 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();
|
||||
|
||||
@@ -79,8 +75,7 @@ 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();
|
||||
@@ -111,8 +106,7 @@ 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();
|
||||
|
||||
@@ -122,6 +116,5 @@ 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';
|
||||
|
||||
const LearnerPostFilterBar = () => {
|
||||
function LearnerPostFilterBar() {
|
||||
const dispatch = useDispatch();
|
||||
const { courseId } = useParams();
|
||||
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
|
||||
@@ -98,6 +98,6 @@ const LearnerPostFilterBar = () => {
|
||||
showCohortsFilter={userHasModerationPrivileges || userIsGroupTa}
|
||||
/>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export default LearnerPostFilterBar;
|
||||
|
||||
@@ -168,9 +168,24 @@ 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 disabled by the course team',
|
||||
defaultMessage: 'Posting in discussions is temporarily disabled by the course team',
|
||||
description: 'Informative text when discussion posting is disabled',
|
||||
},
|
||||
imageWarningMessage: {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { memo, useMemo } from 'react';
|
||||
import React, { useContext, useMemo } from 'react';
|
||||
|
||||
import { matchPath } from 'react-router';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
@@ -7,14 +7,14 @@ import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Nav } from '@edx/paragon';
|
||||
|
||||
import { Routes } from '../../../data/constants';
|
||||
import withConditionalInContextRendering from '../../common/withConditionalInContextRendering';
|
||||
import { useCourseId, useShowLearnersTab } from '../../data/hooks';
|
||||
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 = useCourseId();
|
||||
const { courseId } = useContext(DiscussionContext);
|
||||
const showLearnersTab = useShowLearnersTab();
|
||||
|
||||
const navLinks = useMemo(() => ([
|
||||
@@ -41,28 +41,23 @@ const NavigationBar = () => {
|
||||
});
|
||||
}
|
||||
}, [showLearnersTab]);
|
||||
console.log('NavigationBar');
|
||||
|
||||
const navLinksList = useMemo(() => (
|
||||
navLinks.map(link => (
|
||||
<Nav.Item key={link.route}>
|
||||
<Nav.Link
|
||||
key={link.route}
|
||||
as={NavLink}
|
||||
to={discussionsPath(link.route, { courseId })}
|
||||
isActive={link.isActive}
|
||||
>
|
||||
{intl.formatMessage(link.labelMessage)}
|
||||
</Nav.Link>
|
||||
</Nav.Item>
|
||||
))
|
||||
), [navLinks]);
|
||||
|
||||
return (
|
||||
<Nav variant="pills" className="py-2 nav-button-group">
|
||||
{navLinksList}
|
||||
{navLinks.map(link => (
|
||||
<Nav.Item key={link.route}>
|
||||
<Nav.Link
|
||||
key={link.route}
|
||||
as={NavLink}
|
||||
to={discussionsPath(link.route, { courseId })}
|
||||
isActive={link.isActive}
|
||||
>
|
||||
{intl.formatMessage(link.labelMessage)}
|
||||
</Nav.Link>
|
||||
</Nav.Item>
|
||||
))}
|
||||
</Nav>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(withConditionalInContextRendering(NavigationBar, false));
|
||||
export default React.memo(NavigationBar);
|
||||
|
||||
@@ -74,7 +74,6 @@ const PostCommentsView = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line react/jsx-no-constructed-context-values
|
||||
<PostCommentsContext.Provider value={{
|
||||
isClosed: closed,
|
||||
postType: type,
|
||||
|
||||
@@ -10,7 +10,6 @@ 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';
|
||||
@@ -21,13 +20,12 @@ 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, removeComment } from './data/thunks';
|
||||
import { fetchCommentResponses, fetchThreadComments, removeComment } from './data/thunks';
|
||||
|
||||
import '../posts/data/__factories__';
|
||||
import './data/__factories__';
|
||||
@@ -41,21 +39,44 @@ const discussionPostId = 'thread-1';
|
||||
const questionPostId = 'thread-2';
|
||||
const closedPostId = 'thread-2';
|
||||
const courseId = 'course-v1:edX+TestX+Test_Course';
|
||||
const topicsApiUrl = `${getApiBaseUrl()}/api/discussion/v1/course_topics/${courseId}`;
|
||||
const reverseOrder = true;
|
||||
const enableInContextSidebar = false;
|
||||
let store;
|
||||
let axiosMock;
|
||||
let testLocation;
|
||||
let container;
|
||||
let unmount;
|
||||
|
||||
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 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() {
|
||||
@@ -69,16 +90,13 @@ 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);
|
||||
});
|
||||
@@ -89,30 +107,12 @@ async function getThreadAPIResponse(attr = null) {
|
||||
await executeThunk(fetchThread(discussionPostId), store.dispatch, store.getState);
|
||||
}
|
||||
|
||||
async function setupCourseConfig(reasonCodesEnabled = true) {
|
||||
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`).reply(200, {
|
||||
has_moderation_privileges: true,
|
||||
isPostingEnabled: 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);
|
||||
}
|
||||
|
||||
function renderComponent(postId, isClosed = false) {
|
||||
function renderComponent(postId) {
|
||||
const wrapper = render(
|
||||
<IntlProvider locale="en">
|
||||
<AppProvider store={store}>
|
||||
<DiscussionContext.Provider
|
||||
value={{ courseId, postId, isClosed }}
|
||||
value={{ courseId, postId }}
|
||||
>
|
||||
<MemoryRouter initialEntries={[`/${courseId}/posts/${postId}`]}>
|
||||
<DiscussionContent />
|
||||
@@ -132,45 +132,6 @@ function renderComponent(postId, isClosed = false) {
|
||||
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({
|
||||
@@ -204,12 +165,10 @@ 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();
|
||||
});
|
||||
|
||||
@@ -222,16 +181,6 @@ 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'));
|
||||
@@ -312,7 +261,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-2')).toBeInTheDocument());
|
||||
await waitFor(async () => expect(await screen.findByTestId('reply-comment-7')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('should allow editing an existing comment', async () => {
|
||||
@@ -330,6 +279,23 @@ 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));
|
||||
@@ -359,39 +325,6 @@ describe('ThreadView', () => {
|
||||
assertLastUpdateData({ closed: true, close_reason_code: 'reason-1' });
|
||||
});
|
||||
|
||||
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' });
|
||||
});
|
||||
|
||||
it('should close the post directly if reason codes are not enabled', async () => {
|
||||
await setupCourseConfig(false);
|
||||
await waitFor(() => renderComponent(discussionPostId));
|
||||
@@ -550,10 +483,8 @@ 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 () => {
|
||||
await waitFor(() => renderComponent('unloaded-id'));
|
||||
renderComponent('unloaded-id');
|
||||
expect(await screen.findByText('Thread not found', { exact: true }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
@@ -566,70 +497,6 @@ 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', () => {
|
||||
@@ -638,8 +505,8 @@ describe('ThreadView', () => {
|
||||
it('initially loads only the first page', async () => {
|
||||
await waitFor(() => renderComponent(discussionPostId));
|
||||
|
||||
await waitFor(() => screen.findByTestId('reply-comment-2'));
|
||||
expect(screen.queryByTestId('reply-comment-3')).not.toBeInTheDocument();
|
||||
await waitFor(() => screen.findByTestId('reply-comment-7'));
|
||||
expect(screen.queryByTestId('reply-comment-8')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('pressing load more button will load next page of responses', async () => {
|
||||
@@ -649,7 +516,8 @@ describe('ThreadView', () => {
|
||||
await act(async () => {
|
||||
fireEvent.click(loadMoreButton);
|
||||
});
|
||||
await screen.findByTestId('reply-comment-3');
|
||||
|
||||
await screen.findByTestId('reply-comment-8');
|
||||
});
|
||||
|
||||
it('newly loaded responses are appended to the old ones', async () => {
|
||||
@@ -660,9 +528,9 @@ describe('ThreadView', () => {
|
||||
fireEvent.click(loadMoreButton);
|
||||
});
|
||||
|
||||
await screen.findByTestId('reply-comment-3');
|
||||
await screen.findByTestId('reply-comment-8');
|
||||
// check that comments from the first page are also displayed
|
||||
expect(screen.queryByTestId('reply-comment-2')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('reply-comment-7')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('load more button is hidden when no more responses pages to load', async () => {
|
||||
@@ -673,7 +541,8 @@ describe('ThreadView', () => {
|
||||
fireEvent.click(loadMoreButton);
|
||||
});
|
||||
|
||||
await screen.findByTestId('reply-comment-3');
|
||||
await screen.findByTestId('reply-comment-8');
|
||||
await expect(findLoadMoreCommentsResponsesButton()).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -707,21 +576,21 @@ describe('ThreadView', () => {
|
||||
it('shows action dropdown for replies', async () => {
|
||||
await waitFor(() => renderComponent(discussionPostId));
|
||||
|
||||
const reply = await waitFor(() => screen.findByTestId('reply-comment-2'));
|
||||
const reply = await waitFor(() => screen.findByTestId('reply-comment-7'));
|
||||
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-2'));
|
||||
expect(within(reply).queryByTestId('comment-2')).toBeInTheDocument();
|
||||
const reply = await waitFor(() => screen.findByTestId('reply-comment-7'));
|
||||
expect(within(reply).queryByTestId('comment-7')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows delete confirmation modal', async () => {
|
||||
await waitFor(() => renderComponent(discussionPostId));
|
||||
|
||||
const reply = await waitFor(() => screen.findByTestId('reply-comment-2'));
|
||||
const reply = await waitFor(() => screen.findByTestId('reply-comment-7'));
|
||||
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 { useUserPostingEnabled } from '../../data/hooks';
|
||||
import { useUserCanAddThreadInBlackoutDate } from '../../data/hooks';
|
||||
import { isLastElementOfList } from '../../utils';
|
||||
import { usePostComments } from '../data/hooks';
|
||||
import messages from '../messages';
|
||||
@@ -16,8 +16,7 @@ const CommentsView = ({ endorsed }) => {
|
||||
const intl = useIntl();
|
||||
const [addingResponse, setAddingResponse] = useState(false);
|
||||
const { isClosed } = useContext(PostCommentsContext);
|
||||
const isUserPrivilegedInPostingRestriction = useUserPostingEnabled();
|
||||
|
||||
const userCanAddThreadInBlackoutDate = useUserCanAddThreadInBlackoutDate();
|
||||
const {
|
||||
endorsedCommentsIds,
|
||||
unEndorsedCommentsIds,
|
||||
@@ -66,14 +65,13 @@ const CommentsView = ({ endorsed }) => {
|
||||
)}
|
||||
{isLoading && !showLoadMoreResponses && (
|
||||
<div className="mb-2 mt-3 d-flex justify-content-center">
|
||||
<Spinner animation="border" variant="primary" className="spinner-dimensions" />
|
||||
<Spinner animation="border" variant="primary" className="spinner-dimentions" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
), [hasMorePages, isLoading, handleLoadMoreResponses]);
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line react/jsx-no-useless-fragment
|
||||
<>
|
||||
{((hasMorePages && isLoading) || !isLoading) && (
|
||||
<>
|
||||
@@ -90,7 +88,7 @@ const CommentsView = ({ endorsed }) => {
|
||||
{handleDefinition(messages.responseCount, unEndorsedCommentsIds.length)}
|
||||
{unEndorsedCommentsIds.length === 0 && <br />}
|
||||
{handleComments(unEndorsedCommentsIds, false)}
|
||||
{(isUserPrivilegedInPostingRestriction && !!unEndorsedCommentsIds.length && !isClosed) && (
|
||||
{(userCanAddThreadInBlackoutDate && !!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 { useUserPostingEnabled } from '../../../data/hooks';
|
||||
import { useUserCanAddThreadInBlackoutDate } 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 isUserPrivilegedInPostingRestriction = useUserPostingEnabled();
|
||||
const userCanAddThreadInBlackoutDate = useUserCanAddThreadInBlackoutDate();
|
||||
|
||||
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 (isUserPrivilegedInPostingRestriction) {
|
||||
if (userCanAddThreadInBlackoutDate) {
|
||||
setReplying(true);
|
||||
}
|
||||
}, [isUserPrivilegedInPostingRestriction]);
|
||||
}, [userCanAddThreadInBlackoutDate]);
|
||||
|
||||
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}
|
||||
confirmAction={handleDeleteConfirmation}
|
||||
closeButtonVariant="tertiary"
|
||||
comfirmAction={handleDeleteConfirmation}
|
||||
closeButtonVaraint="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}
|
||||
confirmAction={handleReportConfirmation}
|
||||
comfirmAction={handleReportConfirmation}
|
||||
confirmButtonVariant="danger"
|
||||
/>
|
||||
)}
|
||||
@@ -265,7 +265,7 @@ const Comment = ({
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
!isClosed && isUserPrivilegedInPostingRestriction && (inlineReplies.length >= 5) && (
|
||||
!isClosed && userCanAddThreadInBlackoutDate && (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';
|
||||
|
||||
const CommentEditor = ({
|
||||
function CommentEditor({
|
||||
comment,
|
||||
edit,
|
||||
formClasses,
|
||||
onCloseEditor,
|
||||
}) => {
|
||||
}) {
|
||||
const {
|
||||
id, threadId, parentId, rawBody, author, lastEdit,
|
||||
} = comment;
|
||||
@@ -60,8 +60,7 @@ const CommentEditor = ({
|
||||
|
||||
const initialValues = {
|
||||
comment: rawBody,
|
||||
// eslint-disable-next-line react/prop-types
|
||||
editReasonCode: lastEdit?.reasonCode || (userIsStaff && canDisplayEditReason ? 'violates-guidelines' : undefined),
|
||||
editReasonCode: lastEdit?.reasonCode || (userIsStaff ? 'violates-guidelines' : ''),
|
||||
};
|
||||
|
||||
const handleCloseEditor = useCallback((resetForm) => {
|
||||
@@ -177,13 +176,13 @@ const CommentEditor = ({
|
||||
)}
|
||||
</Formik>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
CommentEditor.propTypes = {
|
||||
comment: PropTypes.shape({
|
||||
author: PropTypes.string,
|
||||
id: PropTypes.string,
|
||||
lastEdit: PropTypes.shape({}),
|
||||
lastEdit: PropTypes.object,
|
||||
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}
|
||||
confirmAction={handleDeleteConfirmation}
|
||||
closeButtonVariant="tertiary"
|
||||
comfirmAction={handleDeleteConfirmation}
|
||||
closeButtonVaraint="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}
|
||||
confirmAction={handleReportConfirmation}
|
||||
comfirmAction={handleReportConfirmation}
|
||||
confirmButtonVariant="danger"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -59,7 +59,6 @@ Factory.define('commentsResult')
|
||||
return Factory.buildList('comment', len, {
|
||||
thread_id: threadId,
|
||||
parent_id: parentId,
|
||||
endorsed,
|
||||
}, {
|
||||
endorsedBy: endorsed ? 'staff' : null,
|
||||
childCount,
|
||||
|
||||
@@ -20,14 +20,16 @@ export const getCommentsApiUrl = () => `${getConfig().LMS_BASE_URL}/api/discussi
|
||||
* @param enableInContextSidebar
|
||||
* @returns {Promise<{}>}
|
||||
*/
|
||||
export const getThreadComments = async (threadId, {
|
||||
endorsed,
|
||||
page,
|
||||
pageSize,
|
||||
reverseOrder,
|
||||
enableInContextSidebar = false,
|
||||
signal,
|
||||
} = {}) => {
|
||||
export async function getThreadComments(
|
||||
threadId, {
|
||||
endorsed,
|
||||
page,
|
||||
pageSize,
|
||||
reverseOrder,
|
||||
enableInContextSidebar = false,
|
||||
signal,
|
||||
} = {},
|
||||
) {
|
||||
const params = snakeCaseObject({
|
||||
threadId,
|
||||
endorsed: EndorsementValue[endorsed],
|
||||
@@ -40,7 +42,7 @@ export const getThreadComments = async (threadId, {
|
||||
|
||||
const { data } = await getAuthenticatedHttpClient().get(getCommentsApiUrl(), { params: { ...params, signal } });
|
||||
return data;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a responses to a comment.
|
||||
@@ -49,11 +51,13 @@ export const getThreadComments = async (threadId, {
|
||||
* @param {number=} pageSize
|
||||
* @returns {Promise<{}>}
|
||||
*/
|
||||
export const getCommentResponses = async (commentId, {
|
||||
page,
|
||||
pageSize,
|
||||
reverseOrder,
|
||||
} = {}) => {
|
||||
export async function getCommentResponses(
|
||||
commentId, {
|
||||
page,
|
||||
pageSize,
|
||||
reverseOrder,
|
||||
} = {},
|
||||
) {
|
||||
const url = `${getCommentsApiUrl()}${commentId}/`;
|
||||
const params = snakeCaseObject({
|
||||
page,
|
||||
@@ -64,7 +68,7 @@ export const getCommentResponses = async (commentId, {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(url, { params });
|
||||
return data;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Posts a comment.
|
||||
@@ -74,13 +78,13 @@ export const getCommentResponses = async (commentId, {
|
||||
* @param {boolean} enableInContextSidebar
|
||||
* @returns {Promise<{}>}
|
||||
*/
|
||||
export const postComment = async (comment, threadId, parentId = null, enableInContextSidebar = false) => {
|
||||
export async function postComment(comment, threadId, parentId = null, enableInContextSidebar = false) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getCommentsApiUrl(), snakeCaseObject({
|
||||
threadId, raw_body: comment, parentId, enableInContextSidebar,
|
||||
}));
|
||||
return data;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates existing comment.
|
||||
@@ -92,13 +96,13 @@ export const postComment = async (comment, threadId, parentId = null, enableInCo
|
||||
* @param {string=} editReasonCode The moderation reason code for editing.
|
||||
* @returns {Promise<{}>}
|
||||
*/
|
||||
export const updateComment = async (commentId, {
|
||||
export async function updateComment(commentId, {
|
||||
comment,
|
||||
voted,
|
||||
flagged,
|
||||
endorsed,
|
||||
editReasonCode,
|
||||
}) => {
|
||||
}) {
|
||||
const url = `${getCommentsApiUrl()}${commentId}/`;
|
||||
const postData = snakeCaseObject({
|
||||
raw_body: comment,
|
||||
@@ -111,14 +115,14 @@ export const updateComment = async (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 const deleteComment = async (commentId) => {
|
||||
export async function deleteComment(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,
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import React, { memo } from 'react';
|
||||
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { selectAllThreadsIds } from './data/selectors';
|
||||
import PostsList from './PostsList';
|
||||
|
||||
const AllPostsList = () => {
|
||||
const postsIds = useSelector(selectAllThreadsIds);
|
||||
|
||||
return <PostsList postsIds={postsIds} topicsIds={null} />;
|
||||
};
|
||||
|
||||
export default memo(AllPostsList);
|
||||
@@ -1,22 +0,0 @@
|
||||
import React, { memo } from 'react';
|
||||
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { selectCurrentCategoryGrouping, selectTopicsUnderCategory } from '../../data/selectors';
|
||||
import { useCategory, useEnableInContextSidebar } from '../data/hooks';
|
||||
import { selectAllThreadsIds, selectTopicThreadsIds } from './data/selectors';
|
||||
import PostsList from './PostsList';
|
||||
|
||||
const PostsView = () => {
|
||||
const category = useCategory();
|
||||
const enableInContextSidebar = useEnableInContextSidebar();
|
||||
|
||||
const groupedCategory = useSelector(selectCurrentCategoryGrouping)(category);
|
||||
// If grouping at subsection is enabled, only apply it when browsing discussions in context in the learning MFE.
|
||||
const topicIds = useSelector(selectTopicsUnderCategory)(enableInContextSidebar ? groupedCategory : category);
|
||||
const postsIds = useSelector(enableInContextSidebar ? selectAllThreadsIds : selectTopicThreadsIds(topicIds));
|
||||
|
||||
return <PostsList postsIds={postsIds} topicsIds={topicIds} />;
|
||||
};
|
||||
|
||||
export default memo(PostsView);
|
||||
@@ -1,90 +0,0 @@
|
||||
import React, {
|
||||
useCallback, useContext, useEffect, useMemo,
|
||||
} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { AppContext } from '@edx/frontend-platform/react';
|
||||
import { Button, Spinner } from '@edx/paragon';
|
||||
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import { useCourseId, useCurrentPage } from '../data/hooks';
|
||||
import { selectConfigLoadingStatus, selectUserHasModerationPrivileges, selectUserIsStaff } from '../data/selectors';
|
||||
import { fetchUserPosts } from '../learners/data/thunks';
|
||||
import messages from '../messages';
|
||||
import { usePostList } from './data/hooks';
|
||||
import {
|
||||
selectThreadFilters, selectThreadNextPage, selectThreadSorting, threadsLoadingStatus,
|
||||
} from './data/selectors';
|
||||
import { fetchThreads } from './data/thunks';
|
||||
import NoResults from './NoResults';
|
||||
import { PostLink } from './post';
|
||||
|
||||
const PostsList = ({
|
||||
postsIds, topicsIds, isTopicTab, parentIsLoading,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
const { authenticatedUser } = useContext(AppContext);
|
||||
const page = useCurrentPage();
|
||||
const courseId = useCourseId();
|
||||
const loadingStatus = useSelector(threadsLoadingStatus());
|
||||
const orderBy = useSelector(selectThreadSorting());
|
||||
const filters = useSelector(selectThreadFilters());
|
||||
const nextPage = useSelector(selectThreadNextPage());
|
||||
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
|
||||
const userIsStaff = useSelector(selectUserIsStaff);
|
||||
const configStatus = useSelector(selectConfigLoadingStatus);
|
||||
const sortedPostsIds = usePostList(postsIds);
|
||||
const showOwnPosts = page === 'my-posts';
|
||||
|
||||
const loadThreads = useCallback((topicIds, pageNum = undefined, isFilterChanged = false) => {
|
||||
const params = {
|
||||
orderBy,
|
||||
filters,
|
||||
page: pageNum,
|
||||
author: showOwnPosts ? authenticatedUser.username : null,
|
||||
countFlagged: (userHasModerationPrivileges || userIsStaff) || undefined,
|
||||
topicIds,
|
||||
isFilterChanged,
|
||||
};
|
||||
|
||||
if (showOwnPosts && filters.search === '') {
|
||||
dispatch(fetchUserPosts(courseId, params));
|
||||
} else {
|
||||
dispatch(fetchThreads(courseId, params));
|
||||
}
|
||||
}, [courseId, orderBy, filters, showOwnPosts, authenticatedUser.username, userHasModerationPrivileges, userIsStaff]);
|
||||
|
||||
return (
|
||||
loadingStatus === RequestStatus.IN_PROGRESS || parentIsLoading ? (
|
||||
<div className="d-flex justify-content-center p-4 mx-auto my-auto">
|
||||
<Spinner animation="border" variant="primary" size="lg" />
|
||||
</div>
|
||||
) : (
|
||||
nextPage && loadingStatus === RequestStatus.SUCCESSFUL && (
|
||||
<Button onClick={() => loadThreads(topicsIds, nextPage)} variant="primary" size="md">
|
||||
{intl.formatMessage(messages.loadMorePosts)}
|
||||
</Button>
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
PostsList.propTypes = {
|
||||
postsIds: PropTypes.arrayOf(PropTypes.string),
|
||||
topicsIds: PropTypes.arrayOf(PropTypes.string),
|
||||
isTopicTab: PropTypes.bool,
|
||||
parentIsLoading: PropTypes.bool,
|
||||
};
|
||||
|
||||
PostsList.defaultProps = {
|
||||
postsIds: [],
|
||||
topicsIds: undefined,
|
||||
isTopicTab: false,
|
||||
parentIsLoading: undefined,
|
||||
};
|
||||
|
||||
export default React.memo(PostsList);
|
||||
@@ -10,8 +10,8 @@ import { AppContext } from '@edx/frontend-platform/react';
|
||||
import { Button, Spinner } from '@edx/paragon';
|
||||
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import { useCourseId, useCurrentPage } from '../data/hooks';
|
||||
import { selectConfigLoadingStatus, selectUserHasModerationPrivileges, selectUserIsStaff } from '../data/selectors';
|
||||
import { DiscussionContext } from '../common/context';
|
||||
import { selectconfigLoadingStatus, selectUserHasModerationPrivileges, selectUserIsStaff } from '../data/selectors';
|
||||
import { fetchUserPosts } from '../learners/data/thunks';
|
||||
import messages from '../messages';
|
||||
import { usePostList } from './data/hooks';
|
||||
@@ -28,15 +28,14 @@ const PostsList = ({
|
||||
const intl = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
const { authenticatedUser } = useContext(AppContext);
|
||||
const page = useCurrentPage();
|
||||
const courseId = useCourseId();
|
||||
const { courseId, page } = useContext(DiscussionContext);
|
||||
const loadingStatus = useSelector(threadsLoadingStatus());
|
||||
const orderBy = useSelector(selectThreadSorting());
|
||||
const filters = useSelector(selectThreadFilters());
|
||||
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';
|
||||
|
||||
@@ -81,8 +80,6 @@ const PostsList = ({
|
||||
))
|
||||
), [sortedPostsIds]);
|
||||
|
||||
console.log('sortedPostsIds', sortedPostsIds, loadingStatus === RequestStatus.IN_PROGRESS || parentIsLoading);
|
||||
|
||||
return (
|
||||
<>
|
||||
{!parentIsLoading && postInstances}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import React, { memo, useCallback } from 'react';
|
||||
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import SearchInfo from '../../components/SearchInfo';
|
||||
import { setSearchQuery } from './data/slices';
|
||||
|
||||
const PostsSearchInfo = () => {
|
||||
const dispatch = useDispatch();
|
||||
const searchString = useSelector(({ threads }) => threads.filters.search);
|
||||
const resultsFound = useSelector(({ threads }) => threads.totalThreads);
|
||||
const textSearchRewrite = useSelector(({ threads }) => threads.textSearchRewrite);
|
||||
const loadingStatus = useSelector(({ threads }) => threads.status);
|
||||
|
||||
const handleOnClear = useCallback(() => {
|
||||
dispatch(setSearchQuery(''));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
searchString && (
|
||||
<SearchInfo
|
||||
count={resultsFound}
|
||||
text={searchString}
|
||||
loadingStatus={loadingStatus}
|
||||
onClear={handleOnClear}
|
||||
textSearchRewrite={textSearchRewrite}
|
||||
/>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(PostsSearchInfo);
|
||||
@@ -1,30 +1,103 @@
|
||||
import React, { memo, useMemo } from 'react';
|
||||
import React, {
|
||||
useCallback, useContext, useEffect, useMemo,
|
||||
} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { useCategory, useTopicId } from '../data/hooks';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import SearchInfo from '../../components/SearchInfo';
|
||||
import { selectCurrentCategoryGrouping, selectTopicsUnderCategory } from '../../data/selectors';
|
||||
import { DiscussionContext } from '../common/context';
|
||||
import { selectEnableInContext } from '../data/selectors';
|
||||
import { selectTopics as selectInContextTopics } from '../in-context-topics/data/selectors';
|
||||
import { fetchCourseTopicsV3 } from '../in-context-topics/data/thunks';
|
||||
import { selectTopics } from '../topics/data/selectors';
|
||||
import { fetchCourseTopics } from '../topics/data/thunks';
|
||||
import { handleKeyDown } from '../utils';
|
||||
import { selectAllThreadsIds, selectTopicThreadsIds } from './data/selectors';
|
||||
import { setSearchQuery } from './data/slices';
|
||||
import PostFilterBar from './post-filter-bar/PostFilterBar';
|
||||
import AllPostsList from './AllPostsList';
|
||||
import CategoryPostsList from './CategoryPostsList';
|
||||
import PostsSearchInfo from './PostsSearchInfo';
|
||||
import TopicPostsList from './TopicPostsList';
|
||||
import PostsList from './PostsList';
|
||||
|
||||
const AllPostsList = () => {
|
||||
const postsIds = useSelector(selectAllThreadsIds);
|
||||
|
||||
return <PostsList postsIds={postsIds} topicsIds={null} />;
|
||||
};
|
||||
|
||||
const TopicPostsList = React.memo(({ topicId }) => {
|
||||
const postsIds = useSelector(selectTopicThreadsIds([topicId]));
|
||||
|
||||
return <PostsList postsIds={postsIds} topicsIds={[topicId]} isTopicTab />;
|
||||
});
|
||||
|
||||
TopicPostsList.propTypes = {
|
||||
topicId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
const CategoryPostsList = React.memo(({ category }) => {
|
||||
const { enableInContextSidebar } = useContext(DiscussionContext);
|
||||
const groupedCategory = useSelector(selectCurrentCategoryGrouping)(category);
|
||||
// If grouping at subsection is enabled, only apply it when browsing discussions in context in the learning MFE.
|
||||
const topicIds = useSelector(selectTopicsUnderCategory)(enableInContextSidebar ? groupedCategory : category);
|
||||
const postsIds = useSelector(enableInContextSidebar ? selectAllThreadsIds : selectTopicThreadsIds(topicIds));
|
||||
|
||||
return <PostsList postsIds={postsIds} topicsIds={topicIds} />;
|
||||
});
|
||||
|
||||
CategoryPostsList.propTypes = {
|
||||
category: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
const PostsView = () => {
|
||||
const topicId = useTopicId();
|
||||
const category = useCategory();
|
||||
const {
|
||||
topicId,
|
||||
category,
|
||||
courseId,
|
||||
enableInContextSidebar,
|
||||
} = useContext(DiscussionContext);
|
||||
const dispatch = useDispatch();
|
||||
const enableInContext = useSelector(selectEnableInContext);
|
||||
const searchString = useSelector(({ threads }) => threads.filters.search);
|
||||
const resultsFound = useSelector(({ threads }) => threads.totalThreads);
|
||||
const textSearchRewrite = useSelector(({ threads }) => threads.textSearchRewrite);
|
||||
const loadingStatus = useSelector(({ threads }) => threads.status);
|
||||
const topics = useSelector(enableInContext ? selectInContextTopics : selectTopics);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEmpty(topics)) {
|
||||
dispatch((enableInContext || enableInContextSidebar)
|
||||
? fetchCourseTopicsV3(courseId)
|
||||
: fetchCourseTopics(courseId));
|
||||
}
|
||||
}, [topics]);
|
||||
|
||||
const handleOnClear = useCallback(() => {
|
||||
dispatch(setSearchQuery(''));
|
||||
}, []);
|
||||
|
||||
const postsListComponent = useMemo(() => {
|
||||
if (topicId) {
|
||||
return <TopicPostsList />;
|
||||
return <TopicPostsList topicId={topicId} />;
|
||||
}
|
||||
if (category) {
|
||||
return <CategoryPostsList />;
|
||||
return <CategoryPostsList category={category} />;
|
||||
}
|
||||
return <AllPostsList />;
|
||||
}, [topicId, category]);
|
||||
|
||||
return (
|
||||
<div className="discussion-posts d-flex flex-column h-100">
|
||||
<PostsSearchInfo />
|
||||
{searchString && (
|
||||
<SearchInfo
|
||||
count={resultsFound}
|
||||
text={searchString}
|
||||
loadingStatus={loadingStatus}
|
||||
onClear={handleOnClear}
|
||||
textSearchRewrite={textSearchRewrite}
|
||||
/>
|
||||
)}
|
||||
<PostFilterBar />
|
||||
<div className="border-bottom border-light-400" />
|
||||
<div className="list-group list-group-flush flex-fill" role="list" onKeyDown={e => handleKeyDown(e)}>
|
||||
@@ -34,4 +107,4 @@ const PostsView = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(PostsView);
|
||||
export default PostsView;
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import React, { memo, useEffect } from 'react';
|
||||
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { useCourseId, useEnableInContextSidebar, useTopicId } from '../data/hooks';
|
||||
import { selectEnableInContext } from '../data/selectors';
|
||||
import { selectTopics as selectInContextTopics } from '../in-context-topics/data/selectors';
|
||||
import { fetchCourseTopicsV3 } from '../in-context-topics/data/thunks';
|
||||
import { selectTopics } from '../topics/data/selectors';
|
||||
import { fetchCourseTopics } from '../topics/data/thunks';
|
||||
import { selectTopicThreadsIds } from './data/selectors';
|
||||
import PostsList from './PostsList';
|
||||
|
||||
const TopicPostsList = () => {
|
||||
const dispatch = useDispatch();
|
||||
const topicId = useTopicId();
|
||||
const courseId = useCourseId();
|
||||
const enableInContextSidebar = useEnableInContextSidebar();
|
||||
const enableInContext = useSelector(selectEnableInContext);
|
||||
const postsIds = useSelector(selectTopicThreadsIds([topicId]));
|
||||
const topics = useSelector(enableInContext ? selectInContextTopics : selectTopics);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEmpty(topics)) {
|
||||
dispatch((enableInContext || enableInContextSidebar)
|
||||
? fetchCourseTopicsV3(courseId)
|
||||
: fetchCourseTopics(courseId));
|
||||
}
|
||||
}, [courseId, topics]);
|
||||
|
||||
return <PostsList postsIds={postsIds} topicsIds={[topicId]} isTopicTab />;
|
||||
};
|
||||
|
||||
export default memo(TopicPostsList);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user