Compare commits

..

1 Commits

Author SHA1 Message Date
adeel.tajamul
ddb7a2c13b fix: switch to use PUBLIC_PATH for routes 2023-05-23 12:10:48 +05:00
150 changed files with 31082 additions and 10502 deletions

5
.env
View File

@@ -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'

View File

@@ -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'

View File

@@ -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'

View File

@@ -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',
},
},
}
}
);

View File

@@ -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

View File

@@ -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
View File

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

2
.nvmrc
View File

@@ -1 +1 @@
18
16

View File

@@ -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:

View File

@@ -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

View File

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

34098
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,7 +11,7 @@
],
"scripts": {
"build": "fedx-scripts webpack",
"i18n_extract": "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",

View File

@@ -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
}

View File

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

View File

@@ -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);

View File

@@ -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');

View File

@@ -1,3 +1,3 @@
const selectCourseTabs = state => state.courseTabs;
/* eslint-disable import/prefer-default-export */
export default selectCourseTabs;
export const selectCourseTabs = state => state.courseTabs;

View File

@@ -0,0 +1,2 @@
/* eslint-disable import/prefer-default-export */
export { default as CourseTabsNavigation } from './CourseTabsNavigation';

View File

@@ -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;

View File

@@ -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;

View File

@@ -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();

View File

@@ -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);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: {

View File

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

View File

@@ -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) => {

View File

@@ -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,
})}

View File

@@ -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 });
}
},
);
});
});
});

View File

@@ -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: '',
};

View File

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

View File

@@ -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([
{

View File

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

View File

@@ -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();
});

View File

@@ -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;

View File

@@ -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]);
}

View File

@@ -11,7 +11,7 @@ import { initializeStore } from '../../store';
import { executeThunk } from '../../test-utils';
import { DiscussionContext } from '../common/context';
import { getCourseConfigApiUrl } from './api';
import { useCurrentDiscussionTopic, 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();
});

View File

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

View File

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

View File

@@ -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;

View File

@@ -8,7 +8,7 @@ import { initializeStore } from '../../store';
import { DiscussionContext } from '../common/context';
import { fetchConfigSuccess } from '../data/slices';
import messages from '../messages';
import 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');

View File

@@ -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));

View File

@@ -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);

View File

@@ -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));

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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>
);
};

View File

@@ -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, {

View File

@@ -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

View File

@@ -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);

View 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;

View 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);
});
});

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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';

View File

@@ -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),

View File

@@ -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';

View File

@@ -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';

View File

@@ -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();

View File

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

View File

@@ -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');

View File

@@ -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;

View File

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

View File

@@ -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';

View File

@@ -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();

View File

@@ -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]);

View File

@@ -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();

View File

@@ -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 });

View File

@@ -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');
},
);
});
});

View File

@@ -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;

View File

@@ -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: {

View File

@@ -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);

View File

@@ -74,7 +74,6 @@ const PostCommentsView = () => {
}
return (
// eslint-disable-next-line react/jsx-no-constructed-context-values
<PostCommentsContext.Provider value={{
isClosed: closed,
postType: type,

View File

@@ -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 })); });

View File

@@ -5,7 +5,7 @@ import { useIntl } from '@edx/frontend-platform/i18n';
import { Button, Spinner } from '@edx/paragon';
import { EndorsementStatus } from '../../../data/constants';
import { 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

View File

@@ -15,7 +15,7 @@ import { AlertBanner, Confirmation, EndorsedAlertBanner } from '../../../common'
import { DiscussionContext } from '../../../common/context';
import HoverCard from '../../../common/HoverCard';
import { ContentTypes } from '../../../data/constants';
import { 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"

View File

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

View File

@@ -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"
/>
)}

View File

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

View File

@@ -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);
};
}

View File

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

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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}

View File

@@ -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);

View File

@@ -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;

View File

@@ -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