Compare commits
63 Commits
bilalqamar
...
sundas/rec
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86f050eaf2 | ||
|
|
7db3f4a21a | ||
|
|
5f11390979 | ||
|
|
af3ef4f491 | ||
|
|
547f9cd185 | ||
|
|
1af495a84c | ||
|
|
d8e63602d3 | ||
|
|
79fb8ecd02 | ||
|
|
90486b7454 | ||
|
|
f67ead3c0b | ||
|
|
a6b14740ea | ||
|
|
258a9b51b3 | ||
|
|
1d162d3109 | ||
|
|
eaf1e37c11 | ||
|
|
8618e8cfe9 | ||
|
|
3b7239d72c | ||
|
|
7ebdf1be3e | ||
|
|
5d75e0361d | ||
|
|
edd3f73211 | ||
|
|
33375a51e0 | ||
|
|
ac471e2dd7 | ||
|
|
f04429f6f7 | ||
|
|
bad12462f5 | ||
|
|
ec915f622b | ||
|
|
60da5eafc4 | ||
|
|
05cf174335 | ||
|
|
ff72dab001 | ||
|
|
c38887ec2b | ||
|
|
58aa512f47 | ||
|
|
62a5c11f52 | ||
|
|
3ef8515891 | ||
|
|
3cc39d83c4 | ||
|
|
af6cd1853c | ||
|
|
79a2fa404b | ||
|
|
472bbe2d96 | ||
|
|
dc5f097736 | ||
|
|
5e8c8254b4 | ||
|
|
0d6692cf8c | ||
|
|
3391e966f3 | ||
|
|
4297a96102 | ||
|
|
db883ca7cd | ||
|
|
422fbf6173 | ||
|
|
e862ee6fb1 | ||
|
|
eeae6d45ce | ||
|
|
71b88bcea3 | ||
|
|
c808069fe1 | ||
|
|
b9543c6d9c | ||
|
|
a545d0b9f6 | ||
|
|
8d86e6dcc0 | ||
|
|
37781566f5 | ||
|
|
50948acfeb | ||
|
|
4de1011780 | ||
|
|
d7474782b4 | ||
|
|
e1c78dda6e | ||
|
|
f282da52c1 | ||
|
|
d7fcc86847 | ||
|
|
c0873df575 | ||
|
|
12fbe7eebd | ||
|
|
7db4fde252 | ||
|
|
4914f51b6e | ||
|
|
80073e3f83 | ||
|
|
3aacdda7a1 | ||
|
|
1a2068d52f |
2
.env
2
.env
@@ -22,3 +22,5 @@ USER_INFO_COOKIE_NAME=''
|
||||
SUPPORT_URL=''
|
||||
LEARNER_FEEDBACK_URL=''
|
||||
STAFF_FEEDBACK_URL=''
|
||||
# Fallback in local style files
|
||||
PARAGON_THEME_URLS={}
|
||||
|
||||
@@ -23,3 +23,5 @@ USER_INFO_COOKIE_NAME='edx-user-info'
|
||||
SUPPORT_URL='https://support.edx.org'
|
||||
LEARNER_FEEDBACK_URL=''
|
||||
STAFF_FEEDBACK_URL=''
|
||||
# Fallback in local style files
|
||||
PARAGON_THEME_URLS={}
|
||||
|
||||
@@ -2,3 +2,4 @@ coverage/*
|
||||
dist/
|
||||
node_modules/
|
||||
jest.config.js
|
||||
src/i18n/messages/
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const { createConfig } = require('@edx/frontend-build');
|
||||
const { createConfig } = require('@openedx/frontend-build');
|
||||
|
||||
module.exports = createConfig(
|
||||
'eslint',
|
||||
|
||||
10
.github/workflows/ci.yml
vendored
10
.github/workflows/ci.yml
vendored
@@ -9,17 +9,16 @@ on:
|
||||
jobs:
|
||||
tests:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Setup Nodejs Env
|
||||
run: echo "NODE_VER=`cat .nvmrc`" >> $GITHUB_ENV
|
||||
- name: Setup Nodejs
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VER }}
|
||||
node-version-file: '.nvmrc'
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Validate package-lock.json changes
|
||||
@@ -33,4 +32,7 @@ jobs:
|
||||
- name: i18n_extract
|
||||
run: npm run i18n_extract
|
||||
- name: Coverage
|
||||
uses: codecov/codecov-action@v3
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
fail_ci_if_error: true
|
||||
|
||||
2
.github/workflows/lockfileversion-check.yml
vendored
2
.github/workflows/lockfileversion-check.yml
vendored
@@ -10,4 +10,4 @@ on:
|
||||
|
||||
jobs:
|
||||
version-check:
|
||||
uses: openedx/.github/.github/workflows/lockfile-check.yml@master
|
||||
uses: openedx/.github/.github/workflows/lockfileversion-check-v3.yml@master
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -6,6 +6,7 @@ node_modules
|
||||
npm-debug.log
|
||||
coverage
|
||||
module.config.js
|
||||
env.config.*
|
||||
|
||||
dist/
|
||||
src/i18n/transifex_input.json
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
[main]
|
||||
host = https://www.transifex.com
|
||||
|
||||
[o:open-edx:p:edx-platform:r:frontend-app-discussions]
|
||||
file_filter = src/i18n/messages/<lang>.json
|
||||
source_file = src/i18n/transifex_input.json
|
||||
source_lang = en
|
||||
type = KEYVALUEJSON
|
||||
14
Makefile
14
Makefile
@@ -1,7 +1,3 @@
|
||||
export TRANSIFEX_RESOURCE = frontend-app-discussions
|
||||
transifex_resource = frontend-app-discussions
|
||||
transifex_langs = "ar,cs,de_DE,es_419,es_AR,es_ES,fa_IR,fr,fr_CA,fr_FR,hi,it_IT,pl,pt_PT,tr_TR,uk,ru,zh_CN"
|
||||
|
||||
intl_imports = ./node_modules/.bin/intl-imports.js
|
||||
transifex_utils = ./node_modules/.bin/transifex-utils.js
|
||||
i18n = ./src/i18n
|
||||
@@ -56,12 +52,6 @@ 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
|
||||
@@ -74,9 +64,9 @@ pull_translations:
|
||||
translations/frontend-app-discussions/src/i18n/messages:frontend-app-discussions
|
||||
|
||||
$(intl_imports) frontend-component-header frontend-component-footer frontend-platform paragon frontend-app-discussions
|
||||
endif
|
||||
# endif
|
||||
|
||||
# This target is used by Travis.
|
||||
validate-no-uncommitted-package-lock-changes:
|
||||
# Checking for package-lock.json changes...
|
||||
git diff --exit-code package-lock.json
|
||||
git diff --exit-code package-lock.json
|
||||
|
||||
10
README.rst
10
README.rst
@@ -52,6 +52,12 @@ Cloning and Startup
|
||||
|
||||
The dev server is running at `http://localhost:2002 <http://localhost:2002>`_.
|
||||
|
||||
Plugins
|
||||
=======
|
||||
This MFE can be customized using `Frontend Plugin Framework <https://github.com/openedx/frontend-plugin-framework>`_.
|
||||
|
||||
The parts of this MFE that can be customized in that manner are documented `here </src/plugin-slots>`_.
|
||||
|
||||
Getting Help
|
||||
============
|
||||
Please tag **@openedx/edx-infinity ** on any PRs or issues. Thanks.
|
||||
@@ -70,7 +76,7 @@ How to Contribute
|
||||
|
||||
Details about how to become a contributor to the Open edX project may be found in the wiki at `How to contribute`_
|
||||
|
||||
.. _How to contribute: https://edx.readthedocs.io/projects/edx-developer-guide/en/latest/process/index.html
|
||||
.. _How to contribute: https://docs.openedx.org/en/latest/developers/references/developer_guide/process/index.html
|
||||
|
||||
PR description template should be automatically applied if you are sending PR from github interface; otherwise you
|
||||
can find it it at `PULL_REQUEST_TEMPLATE.md <https://github.com/openedx/frontend-app-discussions/blob/master/.github/pull_request_template.md>`_
|
||||
@@ -119,4 +125,4 @@ Please see `edx/frontend-platform's i18n module <https://edx.github.io/frontend-
|
||||
Reporting Security Issues
|
||||
=========================
|
||||
|
||||
Please do not report security issues in public. Please email security@openedx.org.
|
||||
Please do not report security issues in public. Please email security@openedx.org.
|
||||
|
||||
@@ -12,6 +12,7 @@ metadata:
|
||||
icon: "Web"
|
||||
annotations:
|
||||
openedx.org/arch-interest-groups: ""
|
||||
openedx.org/release: "master"
|
||||
spec:
|
||||
owner: group:edx-infinity
|
||||
type: 'website'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const { createConfig } = require('@edx/frontend-build');
|
||||
const { createConfig } = require('@openedx/frontend-build');
|
||||
|
||||
module.exports = createConfig('jest', {
|
||||
// setupFilesAfterEnv is used after the jest environment has been loaded. In general this is what you want.
|
||||
|
||||
11
openedx.yaml
11
openedx.yaml
@@ -1,11 +0,0 @@
|
||||
# This file describes this Open edX repo, as described in OEP-2:
|
||||
# http://open-edx-proposals.readthedocs.io/en/latest/oeps/oep-0002.html#specification
|
||||
|
||||
nick: tmpa
|
||||
oeps: {}
|
||||
owner: edx/arch-team
|
||||
openedx-release:
|
||||
# The openedx-release key is described in OEP-10:
|
||||
# https://open-edx-proposals.readthedocs.io/en/latest/oep-0010-proc-openedx-releases.html
|
||||
# The FAQ might also be helpful: https://openedx.atlassian.net/wiki/spaces/COMM/pages/1331268879/Open+edX+Release+FAQ
|
||||
ref: master
|
||||
29503
package-lock.json
generated
29503
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
30
package.json
30
package.json
@@ -16,13 +16,9 @@
|
||||
"lint:fix": "fedx-scripts eslint --ext .js --ext .jsx . --fix",
|
||||
"snapshot": "fedx-scripts jest --updateSnapshot",
|
||||
"start": "fedx-scripts webpack-dev-server --progress",
|
||||
"dev": "PUBLIC_PATH=/discussions/ MFE_CONFIG_API_URL='http://localhost:8000/api/mfe_config/v1' fedx-scripts webpack-dev-server --progress --host apps.local.openedx.io",
|
||||
"test": "fedx-scripts jest --coverage --passWithNoTests"
|
||||
},
|
||||
"husky": {
|
||||
"hooks": {
|
||||
"pre-commit": "npm run lint"
|
||||
}
|
||||
},
|
||||
"author": "edX",
|
||||
"license": "AGPL-3.0",
|
||||
"homepage": "https://github.com/openedx/frontend-app-discussions#readme",
|
||||
@@ -34,13 +30,13 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
|
||||
"@edx/frontend-component-footer": "12.6.1",
|
||||
"@edx/frontend-component-header": "4.10.1",
|
||||
"@edx/frontend-platform": "5.6.1",
|
||||
"@edx/frontend-component-footer": "^14.6.0",
|
||||
"@edx/frontend-component-header": "^6.2.0",
|
||||
"@edx/frontend-platform": "^8.3.3",
|
||||
"@edx/openedx-atlas": "^0.6.0",
|
||||
"@edx/paragon": "20.46.3",
|
||||
"@openedx/paragon": "^23.4.5",
|
||||
"@reduxjs/toolkit": "1.9.7",
|
||||
"@tinymce/tinymce-react": "3.13.1",
|
||||
"@tinymce/tinymce-react": "5.1.1",
|
||||
"babel-polyfill": "6.26.0",
|
||||
"classnames": "2.5.1",
|
||||
"core-js": "3.21.1",
|
||||
@@ -49,8 +45,10 @@
|
||||
"lodash.snakecase": "4.1.1",
|
||||
"prop-types": "15.8.1",
|
||||
"raw-loader": "4.0.2",
|
||||
"react": "17.0.2",
|
||||
"react-dom": "17.0.2",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-google-recaptcha": "^3.1.0",
|
||||
"react-helmet": "6.1.0",
|
||||
"react-redux": "7.2.9",
|
||||
"react-router": "6.18.0",
|
||||
"react-router-dom": "6.18.0",
|
||||
@@ -62,17 +60,17 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@edx/browserslist-config": "1.2.0",
|
||||
"@edx/frontend-build": "13.0.14",
|
||||
"@edx/reactifex": "1.1.0",
|
||||
"@openedx/frontend-build": "^14.3.3",
|
||||
"@testing-library/jest-dom": "5.17.0",
|
||||
"@testing-library/react": "12.1.5",
|
||||
"@testing-library/react": "14.3.1",
|
||||
"@testing-library/user-event": "13.5.0",
|
||||
"axios": "^0.28.0",
|
||||
"axios-mock-adapter": "1.22.0",
|
||||
"babel-plugin-react-intl": "8.2.25",
|
||||
"eslint-plugin-simple-import-sort": "7.0.0",
|
||||
"glob": "7.2.0",
|
||||
"husky": "7.0.4",
|
||||
"jest": "27.5.1",
|
||||
"jest": "29.7.0",
|
||||
"rosie": "2.1.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import {
|
||||
Collapsible, Form, Icon, Spinner,
|
||||
} from '@openedx/paragon';
|
||||
import { Tune } from '@openedx/paragon/icons';
|
||||
import { capitalize, toString } from 'lodash';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Collapsible, Form, Icon, Spinner,
|
||||
} from '@edx/paragon';
|
||||
import { Tune } from '@edx/paragon/icons';
|
||||
|
||||
import {
|
||||
PostsStatusFilter, RequestStatus,
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Form, TransitionReplace } from '@openedx/paragon';
|
||||
import { getIn, useFormikContext } from 'formik';
|
||||
|
||||
import { Form, TransitionReplace } from '@edx/paragon';
|
||||
|
||||
const FormikErrorFeedback = ({ name }) => {
|
||||
const { touched, errors } = useFormikContext();
|
||||
const fieldTouched = getIn(touched, name);
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useDebounce } from '../discussions/data/hooks';
|
||||
|
||||
const defaultSanitizeOptions = {
|
||||
USE_PROFILES: { html: true },
|
||||
ADD_ATTR: ['columnalign'],
|
||||
ADD_ATTR: ['columnalign', 'target'],
|
||||
};
|
||||
|
||||
const HTMLLoader = ({
|
||||
|
||||
23
src/components/Head/Head.jsx
Normal file
23
src/components/Head/Head.jsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
const Head = ({ intl }) => (
|
||||
<Helmet>
|
||||
<title>
|
||||
{intl.formatMessage(messages['discussions.page.title'], { siteName: getConfig().SITE_NAME })}
|
||||
</title>
|
||||
<link rel="shortcut icon" href={getConfig().FAVICON_URL} type="image/x-icon" />
|
||||
</Helmet>
|
||||
);
|
||||
|
||||
Head.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(Head);
|
||||
20
src/components/Head/Head.test.jsx
Normal file
20
src/components/Head/Head.test.jsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
|
||||
import { render } from '@testing-library/react';
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import Head from './Head';
|
||||
|
||||
describe('Head', () => {
|
||||
const props = {};
|
||||
it('should match render title tag and favicon with the site configuration values', () => {
|
||||
render(<IntlProvider locale="en"><Head {...props} /></IntlProvider>);
|
||||
const helmet = Helmet.peek();
|
||||
expect(helmet.title).toEqual(`Discussions | ${getConfig().SITE_NAME}`);
|
||||
expect(helmet.linkTags[0].rel).toEqual('shortcut icon');
|
||||
expect(helmet.linkTags[0].href).toEqual(getConfig().FAVICON_URL);
|
||||
});
|
||||
});
|
||||
11
src/components/Head/messages.js
Normal file
11
src/components/Head/messages.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
'discussions.page.title': {
|
||||
id: 'discussions.page.title',
|
||||
defaultMessage: 'Discussions | {siteName}',
|
||||
description: 'Title tag',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -1,24 +1,16 @@
|
||||
@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;
|
||||
|
||||
.nav a,
|
||||
.nav button {
|
||||
&:hover {
|
||||
background-color: $light-400;
|
||||
background-color: var(--pgn-color-light-400);
|
||||
}
|
||||
}
|
||||
|
||||
.nav a {
|
||||
&:not(.active):hover {
|
||||
background-color: $light-400;
|
||||
background-color: var(--pgn-color-light-400);
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
@@ -30,7 +22,7 @@ $fa-font-path: "~font-awesome/fonts";
|
||||
.nav-link {
|
||||
border-bottom: 4px solid transparent;
|
||||
border-top: 4px solid transparent;
|
||||
color: $gray-700;
|
||||
color: var(--pgn-color-gray-700);
|
||||
|
||||
// temporary until we can remove .btn class from dropdowns
|
||||
border-left: 0;
|
||||
@@ -40,9 +32,9 @@ $fa-font-path: "~font-awesome/fonts";
|
||||
&:hover,
|
||||
&:focus,
|
||||
&.active {
|
||||
font-weight: $font-weight-normal;
|
||||
color: $primary-500;
|
||||
border-bottom-color: $primary-500;
|
||||
font-weight: var(--pgn-typography-font-weight-normal);
|
||||
color: var(--pgn-color-primary-500);
|
||||
border-bottom-color: var(--pgn-color-primary-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Dropdown } from '@openedx/paragon';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import { Dropdown } from '@edx/paragon';
|
||||
|
||||
import useIndexOfLastVisibleChild from './useIndexOfLastVisibleChild';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useLayoutEffect, useRef, useState } from 'react';
|
||||
|
||||
import { useWindowSize } from '@edx/paragon';
|
||||
import { useWindowSize } from '@openedx/paragon';
|
||||
|
||||
const invisibleStyle = {
|
||||
position: 'absolute',
|
||||
|
||||
74
src/components/PostHelpPanel.jsx
Normal file
74
src/components/PostHelpPanel.jsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import {
|
||||
Hyperlink, Icon, IconButton, IconButtonWithTooltip,
|
||||
} from '@openedx/paragon';
|
||||
import { Close, HelpOutline } from '@openedx/paragon/icons';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import messages from '../discussions/posts/post-editor/messages';
|
||||
|
||||
const PostHelpPanel = () => {
|
||||
const intl = useIntl();
|
||||
const [showHelpPane, setShowHelpPane] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="d-flex justify-content-end">
|
||||
<IconButtonWithTooltip
|
||||
onClick={() => setShowHelpPane(true)}
|
||||
alt={intl.formatMessage(messages.showHelpIcon)}
|
||||
tooltipContent={<div>{intl.formatMessage(messages.discussionHelpTooltip)}</div>}
|
||||
src={HelpOutline}
|
||||
iconAs={Icon}
|
||||
size="inline"
|
||||
className="float-right p-3 help-icon"
|
||||
iconClassNames="help-icon-size"
|
||||
data-testid="help-button"
|
||||
invertColors
|
||||
isActive
|
||||
/>
|
||||
</div>
|
||||
{showHelpPane && (
|
||||
<div
|
||||
className="w-100 p-2 bg-light-200 rounded box-shadow-down-1 post-preview overflow-auto my-3"
|
||||
style={{ minHeight: '200px', wordBreak: 'break-word' }}
|
||||
>
|
||||
<IconButton
|
||||
onClick={() => setShowHelpPane(false)}
|
||||
alt={intl.formatMessage(messages.actionsAlt)}
|
||||
src={Close}
|
||||
iconAs={Icon}
|
||||
size="inline"
|
||||
className="float-right p-3"
|
||||
iconClassNames="icon-size"
|
||||
data-testid="hide-help-button"
|
||||
/>
|
||||
<div className="pt-2 px-3">
|
||||
<h4 className="font-weight-bold">{intl.formatMessage(messages.discussionHelpHeader)}</h4>
|
||||
<p className="pt-2">{intl.formatMessage(messages.discussionHelpDescription)}</p>
|
||||
<Hyperlink
|
||||
target="_blank"
|
||||
className="w-100"
|
||||
destination="https://support.edx.org/hc/en-us/sections/115004169687-Participating-in-Course-Discussions"
|
||||
showLaunchIcon={false}
|
||||
>
|
||||
{intl.formatMessage(messages.discussionHelpCourseParticipation)}
|
||||
</Hyperlink>
|
||||
<Hyperlink
|
||||
target="_blank"
|
||||
className="w-100"
|
||||
destination="https://support.edx.org/hc/en-us/articles/360000035267-Entering-math-expressions-in-course-discussions"
|
||||
showLaunchIcon={false}
|
||||
>
|
||||
{intl.formatMessage(messages.discussionHelpMathExpressions)}
|
||||
</Hyperlink>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(PostHelpPanel);
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Button, Icon, IconButton } from '@openedx/paragon';
|
||||
import { Close } from '@openedx/paragon/icons';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Button, Icon, IconButton } from '@edx/paragon';
|
||||
import { Close } from '@edx/paragon/icons';
|
||||
|
||||
import messages from '../discussions/posts/post-editor/messages';
|
||||
import HTMLLoader from './HTMLLoader';
|
||||
|
||||
@@ -2,12 +2,12 @@ import React, {
|
||||
useCallback, useContext, useEffect, useRef, useState,
|
||||
} from 'react';
|
||||
|
||||
import { Icon, SearchField } from '@openedx/paragon';
|
||||
import { Search as SearchIcon } from '@openedx/paragon/icons';
|
||||
import camelCase from 'lodash/camelCase';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Icon, SearchField } from '@edx/paragon';
|
||||
import { Search as SearchIcon } from '@edx/paragon/icons';
|
||||
|
||||
import DiscussionContext from '../discussions/common/context';
|
||||
import { setUsernameSearch } from '../discussions/learners/data';
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Button, Icon } from '@openedx/paragon';
|
||||
import { Search } from '@openedx/paragon/icons';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Button, Icon } from '@edx/paragon';
|
||||
import { Search } from '@edx/paragon/icons';
|
||||
|
||||
import { RequestStatus } from '../data/constants';
|
||||
import messages from '../discussions/posts/post-actions-bar/messages';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Spinner as ParagonSpinner } from '@edx/paragon';
|
||||
import { Spinner as ParagonSpinner } from '@openedx/paragon';
|
||||
|
||||
const Spinner = () => (
|
||||
<div className="spinner-container" data-testid="spinner">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { ActionRow, AlertModal, Button } from '@openedx/paragon';
|
||||
import { Editor } from '@tinymce/tinymce-react';
|
||||
import { useLocation, useParams } from 'react-router-dom';
|
||||
// TinyMCE so the global var exists
|
||||
@@ -7,7 +8,6 @@ import { useLocation, useParams } from 'react-router-dom';
|
||||
import tinymce from 'tinymce/tinymce';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { ActionRow, AlertModal, Button } from '@edx/paragon';
|
||||
|
||||
import { MAX_UPLOAD_FILE_SIZE } from '../data/constants';
|
||||
import messages from '../discussions/messages';
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Icon, OverlayTrigger, Tooltip } from '@openedx/paragon';
|
||||
import { HelpOutline, PostOutline, Report } from '@openedx/paragon/icons';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Icon, OverlayTrigger, Tooltip } from '@edx/paragon';
|
||||
import { HelpOutline, PostOutline, Report } from '@edx/paragon/icons';
|
||||
|
||||
import {
|
||||
selectUserHasModerationPrivileges,
|
||||
|
||||
@@ -3,14 +3,14 @@ import React, {
|
||||
} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import {
|
||||
Button, Dropdown, Icon, IconButton, ModalPopup, useToggle,
|
||||
} from '@openedx/paragon';
|
||||
import { MoreHoriz } from '@openedx/paragon/icons';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
import {
|
||||
Button, Dropdown, Icon, IconButton, ModalPopup, useToggle,
|
||||
} from '@edx/paragon';
|
||||
import { MoreHoriz } from '@edx/paragon/icons';
|
||||
|
||||
import { ContentActions } from '../../data/constants';
|
||||
import { selectIsPostingEnabled } from '../data/selectors';
|
||||
@@ -79,7 +79,7 @@ const ActionsDropdown = ({
|
||||
placement="bottom-end"
|
||||
>
|
||||
<div
|
||||
className="bg-white shadow d-flex flex-column"
|
||||
className="bg-white shadow d-flex flex-column mt-1"
|
||||
data-testid="actions-dropdown-modal-popup"
|
||||
>
|
||||
{actions.map(action => (
|
||||
@@ -94,12 +94,13 @@ const ActionsDropdown = ({
|
||||
handleActions(action.action);
|
||||
}}
|
||||
className="d-flex justify-content-start actions-dropdown-item"
|
||||
data-testId={action.id}
|
||||
>
|
||||
<Icon
|
||||
src={action.icon}
|
||||
className="icon-size-24"
|
||||
/>
|
||||
<span className="font-weight-normal font-xl ml-2">
|
||||
<span className="font-weight-normal ml-2">
|
||||
{intl.formatMessage(action.label)}
|
||||
</span>
|
||||
</Dropdown.Item>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Alert } from '@openedx/paragon';
|
||||
import { Report } from '@openedx/paragon/icons';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Alert } from '@edx/paragon';
|
||||
import { Report } from '@edx/paragon/icons';
|
||||
|
||||
import { AvatarOutlineAndLabelColors } from '../../data/constants';
|
||||
import {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Alert } from '@openedx/paragon';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Alert } from '@edx/paragon';
|
||||
|
||||
import messages from '../post-comments/messages';
|
||||
import AuthorLabel from './AuthorLabel';
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import React, { useContext, useMemo } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Icon, OverlayTrigger, Tooltip } from '@openedx/paragon';
|
||||
import classNames from 'classnames';
|
||||
import { generatePath, Link } from 'react-router-dom';
|
||||
import * as timeago from 'timeago.js';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Icon, OverlayTrigger, Tooltip } from '@edx/paragon';
|
||||
|
||||
import { Routes } from '../../data/constants';
|
||||
import messages from '../messages';
|
||||
@@ -38,7 +38,7 @@ const AuthorLabel = ({
|
||||
|
||||
const authorName = useMemo(() => (
|
||||
<span
|
||||
className={classNames('mr-1.5 font-size-14 font-style font-weight-500 author-name', {
|
||||
className={classNames('mr-1.5 font-style font-weight-500 author-name', {
|
||||
'text-gray-700': isRetiredUser,
|
||||
'text-primary-500': !authorLabelMessage && !isRetiredUser,
|
||||
})}
|
||||
@@ -71,7 +71,7 @@ const AuthorLabel = ({
|
||||
/>
|
||||
{authorLabelMessage && (
|
||||
<span
|
||||
className={classNames('mr-1.5 font-size-14 font-style font-weight-500', {
|
||||
className={classNames('mr-1.5 font-style font-weight-500', {
|
||||
'text-primary-500': showTextPrimary,
|
||||
'text-gray-700': isRetiredUser,
|
||||
})}
|
||||
@@ -85,7 +85,7 @@ const AuthorLabel = ({
|
||||
{postCreatedAt && (
|
||||
<span
|
||||
title={postCreatedAt}
|
||||
className={classNames('align-content-center', {
|
||||
className={classNames('align-content-center post-summary-timestamp', {
|
||||
'text-white': alert,
|
||||
'text-gray-500': !alert,
|
||||
})}
|
||||
@@ -99,7 +99,7 @@ const AuthorLabel = ({
|
||||
|
||||
return showUserNameAsLink
|
||||
? (
|
||||
<div className={className}>
|
||||
<div className={`${className} flex-wrap`}>
|
||||
<Link
|
||||
data-testid="learner-posts-link"
|
||||
id="learner-posts-link"
|
||||
@@ -112,7 +112,7 @@ const AuthorLabel = ({
|
||||
{labelContents}
|
||||
</div>
|
||||
)
|
||||
: <div className={className}>{authorName}{labelContents}</div>;
|
||||
: <div className={`${className} flex-wrap`}>{authorName}{labelContents}</div>;
|
||||
};
|
||||
|
||||
AuthorLabel.propTypes = {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { ActionRow, Button, ModalDialog } from '@openedx/paragon';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { ActionRow, Button, ModalDialog } from '@edx/paragon';
|
||||
|
||||
import messages from '../messages';
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import React, { useContext } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Alert, Icon } from '@openedx/paragon';
|
||||
import { CheckCircle, Verified } from '@openedx/paragon/icons';
|
||||
import * as timeago from 'timeago.js';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Alert, Icon } from '@edx/paragon';
|
||||
import { CheckCircle, Verified } from '@edx/paragon/icons';
|
||||
|
||||
import { ThreadType } from '../../data/constants';
|
||||
import messages from '../post-comments/messages';
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import React, { useContext } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import {
|
||||
Button, Icon, IconButton, OverlayTrigger, Tooltip,
|
||||
} from '@openedx/paragon';
|
||||
import {
|
||||
StarFilled, StarOutline, ThumbUpFilled, ThumbUpOutline,
|
||||
} from '@openedx/paragon/icons';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Button, Icon, IconButton, OverlayTrigger, Tooltip,
|
||||
} from '@edx/paragon';
|
||||
import {
|
||||
StarFilled, StarOutline, ThumbUpFilled, ThumbUpOutline,
|
||||
} from '@edx/paragon/icons';
|
||||
|
||||
import { ThreadType } from '../../data/constants';
|
||||
import { useUserPostingEnabled } from '../data/hooks';
|
||||
import { useHasLikePermission, useUserPostingEnabled } from '../data/hooks';
|
||||
import PostCommentsContext from '../post-comments/postCommentsContext';
|
||||
import ActionsDropdown from './ActionsDropdown';
|
||||
import DiscussionContext from './context';
|
||||
@@ -33,6 +33,7 @@ const HoverCard = ({
|
||||
const { enableInContextSidebar } = useContext(DiscussionContext);
|
||||
const { isClosed } = useContext(PostCommentsContext);
|
||||
const isUserPrivilegedInPostingRestriction = useUserPostingEnabled();
|
||||
const userHasLikePermission = useHasLikePermission(contentType, id);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -45,7 +46,7 @@ const HoverCard = ({
|
||||
<Button
|
||||
variant="tertiary"
|
||||
className={classNames(
|
||||
'px-2.5 py-2 border-0 font-style text-gray-700 font-size-12',
|
||||
'px-2.5 py-2 border-0 font-style text-gray-700',
|
||||
{ 'w-100': enableInContextSidebar },
|
||||
)}
|
||||
onClick={() => handleResponseCommentButton()}
|
||||
@@ -86,6 +87,7 @@ const HoverCard = ({
|
||||
iconAs={Icon}
|
||||
size="sm"
|
||||
alt="Like"
|
||||
disabled={!userHasLikePermission}
|
||||
iconClassNames="like-icon-dimensions"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -106,8 +106,8 @@ describe('HoverCard', () => {
|
||||
});
|
||||
|
||||
test('it should have hover card on post', async () => {
|
||||
await waitFor(() => renderComponent(discussionPostId));
|
||||
const post = screen.getByTestId('post-thread-1');
|
||||
renderComponent(discussionPostId);
|
||||
const post = await screen.findByTestId('post-thread-1');
|
||||
expect(within(post).getByTestId('hover-card-thread-1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import propTypes from 'prop-types';
|
||||
|
||||
import { Button } from '@openedx/paragon';
|
||||
import classNames from 'classnames';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Button } from '@edx/paragon';
|
||||
|
||||
import ContentUnavailableIcon from '../../assets/ContentUnavailable';
|
||||
import selectCourseTabs from '../../components/NavigationBar/data/selectors';
|
||||
import { useIsOnDesktop, useIsOnXLDesktop } from '../data/hooks';
|
||||
import { useIsOnTablet, useIsOnXLDesktop } from '../data/hooks';
|
||||
import messages from '../messages';
|
||||
|
||||
const ContentUnavailable = ({ subTitleMessage }) => {
|
||||
const intl = useIntl();
|
||||
const isOnDesktop = useIsOnDesktop();
|
||||
const isOnTabletorDesktop = useIsOnTablet();
|
||||
const isOnXLDesktop = useIsOnXLDesktop();
|
||||
const { courseId } = useSelector(selectCourseTabs);
|
||||
|
||||
@@ -26,8 +26,8 @@ const ContentUnavailable = ({ subTitleMessage }) => {
|
||||
return (
|
||||
<div className="min-content-height justify-content-center align-items-center d-flex w-100 flex-column bg-white">
|
||||
<div className={classNames('d-flex flex-column align-items-center', {
|
||||
'content-unavailable-desktop': isOnDesktop || isOnXLDesktop,
|
||||
'py-0 px-3': !isOnDesktop && !isOnXLDesktop,
|
||||
'content-unavailable-desktop': isOnTabletorDesktop || isOnXLDesktop,
|
||||
'py-0 px-3': !isOnTabletorDesktop && !isOnXLDesktop,
|
||||
})}
|
||||
>
|
||||
<ContentUnavailableIcon />
|
||||
@@ -35,7 +35,7 @@ const ContentUnavailable = ({ subTitleMessage }) => {
|
||||
{intl.formatMessage(messages.contentUnavailableTitle)}
|
||||
</h3>
|
||||
<p className="pb-2 text-gray-500 text-center">{intl.formatMessage(subTitleMessage)}</p>
|
||||
<Button onClick={redirectToDashboard} variant="outline-dark" className="font-size-14 py-2 px-2.5">
|
||||
<Button onClick={redirectToDashboard} variant="outline-dark" className="py-2 px-2.5">
|
||||
{intl.formatMessage(messages.contentUnavailableAction)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -5,8 +5,10 @@ ensureConfig([
|
||||
'LMS_BASE_URL',
|
||||
], 'Posts API service');
|
||||
|
||||
export const getCourseConfigApiUrl = () => `${getConfig().LMS_BASE_URL}/api/discussion/v1/courses/`;
|
||||
export const getCourseConfigApiUrl = () => `${getConfig().LMS_BASE_URL}/api/discussion/v2/courses/`;
|
||||
export const getCourseSettingsApiUrl = () => `${getConfig().LMS_BASE_URL}/api/discussion/v1/courses/`;
|
||||
export const getDiscussionsConfigUrl = (courseId) => `${getCourseConfigApiUrl()}${courseId}/`;
|
||||
export const getDiscussionsSettingsUrl = (courseId) => `${getCourseSettingsApiUrl()}${courseId}/settings`;
|
||||
/**
|
||||
* Get discussions course config
|
||||
* @param {string} courseId
|
||||
@@ -21,7 +23,7 @@ export async function getDiscussionsConfig(courseId) {
|
||||
* @param {string} courseId
|
||||
*/
|
||||
export async function getDiscussionsSettings(courseId) {
|
||||
const url = `${getDiscussionsConfigUrl(courseId)}settings`;
|
||||
const url = `${getDiscussionsSettingsUrl(courseId)}`;
|
||||
const { data } = await getAuthenticatedHttpClient().get(url);
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
useContext, useEffect, useMemo, useRef, useState,
|
||||
} from 'react';
|
||||
|
||||
import { breakpoints, useWindowSize } from '@openedx/paragon';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import {
|
||||
matchPath, useLocation, useMatch, useNavigate,
|
||||
@@ -11,13 +12,15 @@ import {
|
||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { AppContext } from '@edx/frontend-platform/react';
|
||||
import { breakpoints, useWindowSize } from '@edx/paragon';
|
||||
|
||||
import selectCourseTabs from '../../components/NavigationBar/data/selectors';
|
||||
import { LOADED } from '../../components/NavigationBar/data/slice';
|
||||
import fetchTab from '../../components/NavigationBar/data/thunks';
|
||||
import { RequestStatus, Routes } from '../../data/constants';
|
||||
import { ContentActions, RequestStatus, Routes } from '../../data/constants';
|
||||
import { selectTopicsUnderCategory } from '../../data/selectors';
|
||||
import fetchCourseBlocks from '../../data/thunks';
|
||||
import DiscussionContext from '../common/context';
|
||||
import PostCommentsContext from '../post-comments/postCommentsContext';
|
||||
import { clearRedirect } from '../posts/data';
|
||||
import { threadsLoadingStatus } from '../posts/data/selectors';
|
||||
import { selectTopics } from '../topics/data/selectors';
|
||||
@@ -25,17 +28,16 @@ import tourCheckpoints from '../tours/constants';
|
||||
import selectTours from '../tours/data/selectors';
|
||||
import { updateTourShowStatus } from '../tours/data/thunks';
|
||||
import messages from '../tours/messages';
|
||||
import { discussionsPath } from '../utils';
|
||||
import { checkPermissions, discussionsPath } from '../utils';
|
||||
import { ContentSelectors } from './constants';
|
||||
import {
|
||||
selectAreThreadsFiltered,
|
||||
selectEnableInContext,
|
||||
selectIsCourseAdmin,
|
||||
selectIsCourseStaff,
|
||||
selectIsPostingEnabled,
|
||||
selectIsUserLearner,
|
||||
selectPostThreadCount,
|
||||
selectUserHasModerationPrivileges,
|
||||
selectUserIsGroupTa,
|
||||
selectUserIsStaff,
|
||||
} from './selectors';
|
||||
import fetchCourseConfig from './thunks';
|
||||
|
||||
@@ -74,12 +76,10 @@ export const useSidebarVisible = () => {
|
||||
|
||||
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));
|
||||
await dispatch(fetchTab(courseId));
|
||||
}
|
||||
|
||||
@@ -87,6 +87,23 @@ export function useCourseDiscussionData(courseId) {
|
||||
}, [courseId]);
|
||||
}
|
||||
|
||||
export function useCourseBlockData(courseId) {
|
||||
const dispatch = useDispatch();
|
||||
const { authenticatedUser } = useContext(AppContext);
|
||||
const { isEnrolled, courseStatus } = useSelector(selectCourseTabs);
|
||||
const isUserLearner = useSelector(selectIsUserLearner);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchBaseData() {
|
||||
if (courseStatus === LOADED && (!isUserLearner || isEnrolled)) {
|
||||
await dispatch(fetchCourseBlocks(courseId, authenticatedUser.username));
|
||||
}
|
||||
}
|
||||
|
||||
fetchBaseData();
|
||||
}, [courseId, isEnrolled, courseStatus, isUserLearner]);
|
||||
}
|
||||
|
||||
export function useRedirectToThread(courseId, enableInContextSidebar) {
|
||||
const dispatch = useDispatch();
|
||||
const navigate = useNavigate();
|
||||
@@ -113,12 +130,17 @@ export function useRedirectToThread(courseId, enableInContextSidebar) {
|
||||
|
||||
export function useIsOnDesktop() {
|
||||
const windowSize = useWindowSize();
|
||||
return windowSize.width >= breakpoints.medium.minWidth;
|
||||
return windowSize.width >= breakpoints.medium.maxWidth;
|
||||
}
|
||||
|
||||
export function useIsOnTablet() {
|
||||
const windowSize = useWindowSize();
|
||||
return windowSize.width >= breakpoints.small.maxWidth;
|
||||
}
|
||||
|
||||
export function useIsOnXLDesktop() {
|
||||
const windowSize = useWindowSize();
|
||||
return windowSize.width >= breakpoints.extraLarge.minWidth;
|
||||
return windowSize.width >= breakpoints.extraLarge.maxWidth;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -195,20 +217,13 @@ export const useCurrentDiscussionTopic = () => {
|
||||
|
||||
export const useUserPostingEnabled = () => {
|
||||
const isPostingEnabled = useSelector(selectIsPostingEnabled);
|
||||
const isUserAdmin = useSelector(selectUserIsStaff);
|
||||
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
|
||||
const isUserGroupTA = useSelector(selectUserIsGroupTa);
|
||||
const isCourseAdmin = useSelector(selectIsCourseAdmin);
|
||||
const isCourseStaff = useSelector(selectIsCourseStaff);
|
||||
const isPrivileged = isUserAdmin || userHasModerationPrivileges || isUserGroupTA || isCourseAdmin || isCourseStaff;
|
||||
const isPrivileged = userHasModerationPrivileges || isUserGroupTA;
|
||||
|
||||
return (isPostingEnabled || isPrivileged);
|
||||
};
|
||||
|
||||
function camelToConstant(string) {
|
||||
return string.replace(/[A-Z]/g, (match) => `_${match}`).toUpperCase();
|
||||
}
|
||||
|
||||
export const useTourConfiguration = () => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
@@ -233,7 +248,7 @@ export const useTourConfiguration = () => {
|
||||
enabled: tour && Boolean(tour.enabled && tour.showTour && !enableInContextSidebar),
|
||||
onDismiss: () => handleOnDismiss(tour.id),
|
||||
onEnd: () => handleOnEnd(tour.id),
|
||||
checkpoints: tourCheckpoints(intl)[camelToConstant(tour.tourName)],
|
||||
checkpoints: tourCheckpoints(intl)[tour.tourName],
|
||||
}
|
||||
))
|
||||
), [tours, enableInContextSidebar]);
|
||||
@@ -261,3 +276,10 @@ export const useDebounce = (value, delay) => {
|
||||
);
|
||||
return debouncedValue;
|
||||
};
|
||||
|
||||
export const useHasLikePermission = (contentType, id) => {
|
||||
const { postType } = useContext(PostCommentsContext);
|
||||
const content = { ...useSelector(ContentSelectors[contentType](id)), postType };
|
||||
|
||||
return checkPermissions(content, ContentActions.VOTE);
|
||||
};
|
||||
|
||||
@@ -19,11 +19,11 @@ const courseConfigApiUrl = getCourseConfigApiUrl();
|
||||
let store;
|
||||
let axiosMock;
|
||||
|
||||
const generateApiResponse = (isPostingEnabled, isCourseAdmin = false) => ({
|
||||
const generateApiResponse = (isPostingEnabled, hasModerationPrivileges = false) => ({
|
||||
isPostingEnabled,
|
||||
hasModerationPrivileges: false,
|
||||
hasModerationPrivileges,
|
||||
isGroupTa: false,
|
||||
isCourseAdmin,
|
||||
isCourseAdmin: false,
|
||||
isCourseStaff: false,
|
||||
isUserAdmin: false,
|
||||
});
|
||||
@@ -160,7 +160,7 @@ describe('Hooks', () => {
|
||||
expect(queryByText('false')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('when posting is not disabled and Role is not Learner return true', async () => {
|
||||
test('when posting is disabled and Role is not Learner return true', async () => {
|
||||
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`)
|
||||
.reply(200, generateApiResponse(false, true));
|
||||
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
|
||||
import selectCourseTabs from '../../components/NavigationBar/data/selectors';
|
||||
import { PostsStatusFilter, ThreadType } from '../../data/constants';
|
||||
import { isCourseStatusValid } from '../utils';
|
||||
|
||||
export const selectAnonymousPostingConfig = state => ({
|
||||
allowAnonymous: state.config.allowAnonymous,
|
||||
@@ -29,6 +31,10 @@ export const selectEnableInContext = state => state.config.enableInContext;
|
||||
|
||||
export const selectIsPostingEnabled = state => state.config.isPostingEnabled;
|
||||
|
||||
export const selectIsNotifyAllLearnersEnabled = state => state.config.isNotifyAllLearnersEnabled;
|
||||
|
||||
export const selectCaptchaSettings = state => state.config.captchaSettings;
|
||||
|
||||
export const selectModerationSettings = state => ({
|
||||
postCloseReasons: state.config.postCloseReasons,
|
||||
editReasons: state.config.editReasons,
|
||||
@@ -69,12 +75,14 @@ export const selectIsUserLearner = createSelector(
|
||||
selectUserIsStaff,
|
||||
selectIsCourseAdmin,
|
||||
selectIsCourseStaff,
|
||||
selectCourseTabs,
|
||||
(
|
||||
userHasModerationPrivileges,
|
||||
userIsGroupTa,
|
||||
userIsStaff,
|
||||
userIsCourseAdmin,
|
||||
userIsCourseStaff,
|
||||
{ courseStatus },
|
||||
) => (
|
||||
(
|
||||
!userHasModerationPrivileges
|
||||
@@ -82,6 +90,7 @@ export const selectIsUserLearner = createSelector(
|
||||
&& !userIsStaff
|
||||
&& !userIsCourseAdmin
|
||||
&& !userIsCourseStaff
|
||||
&& isCourseStatusValid(courseStatus)
|
||||
) || false
|
||||
),
|
||||
);
|
||||
|
||||
@@ -22,6 +22,10 @@ const configSlice = createSlice({
|
||||
dividedInlineDiscussions: [],
|
||||
dividedCourseWideDiscussions: [],
|
||||
},
|
||||
captchaSettings: {
|
||||
enabled: false,
|
||||
siteKey: '',
|
||||
},
|
||||
editReasons: [],
|
||||
postCloseReasons: [],
|
||||
enableInContext: false,
|
||||
|
||||
@@ -3,18 +3,19 @@ import React, {
|
||||
} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { useWindowSize } from '@openedx/paragon';
|
||||
import classNames from 'classnames';
|
||||
import { useSelector } from 'react-redux';
|
||||
import {
|
||||
Navigate, Route, Routes,
|
||||
} from 'react-router-dom';
|
||||
|
||||
import { useWindowSize } from '@edx/paragon';
|
||||
|
||||
import Spinner from '../../components/Spinner';
|
||||
import { RequestStatus, Routes as ROUTES } from '../../data/constants';
|
||||
import DiscussionContext from '../common/context';
|
||||
import { useContainerSize, useIsOnDesktop, useIsOnXLDesktop } from '../data/hooks';
|
||||
import {
|
||||
useContainerSize, useIsOnDesktop, useIsOnTablet, useIsOnXLDesktop,
|
||||
} from '../data/hooks';
|
||||
import { selectConfigLoadingStatus, selectEnableInContext } from '../data/selectors';
|
||||
|
||||
const TopicPostsView = lazy(() => import('../in-context-topics/TopicPostsView'));
|
||||
@@ -27,6 +28,7 @@ const LegacyTopicsView = lazy(() => import('../topics/TopicsView'));
|
||||
const DiscussionSidebar = ({ displaySidebar, postActionBarRef }) => {
|
||||
const isOnDesktop = useIsOnDesktop();
|
||||
const isOnXLDesktop = useIsOnXLDesktop();
|
||||
const isOnTablet = useIsOnTablet();
|
||||
const { enableInContextSidebar } = useContext(DiscussionContext);
|
||||
const enableInContext = useSelector(selectEnableInContext);
|
||||
const configStatus = useSelector(selectConfigLoadingStatus);
|
||||
@@ -52,6 +54,7 @@ const DiscussionSidebar = ({ displaySidebar, postActionBarRef }) => {
|
||||
'd-flex overflow-auto box-shadow-centered-1': displaySidebar,
|
||||
'w-100': !isOnDesktop,
|
||||
'sidebar-desktop-width': isOnDesktop && !isOnXLDesktop,
|
||||
'sidebar-tablet-width': isOnTablet && !isOnDesktop,
|
||||
'w-25 sidebar-XL-width': isOnXLDesktop,
|
||||
'min-content-height': !enableInContextSidebar,
|
||||
})}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { IntlProvider } from 'react-intl';
|
||||
import { Context as ResponsiveContext } from 'react-responsive';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
@@ -85,7 +84,7 @@ describe('DiscussionSidebar', () => {
|
||||
},
|
||||
})]);
|
||||
renderComponent();
|
||||
await act(async () => expect(await screen.findAllByText('Thread by other users')).toBeTruthy());
|
||||
await screen.findAllByText('Thread by other users');
|
||||
expect(screen.queryByText('Thread by abc123')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -100,7 +99,7 @@ describe('DiscussionSidebar', () => {
|
||||
},
|
||||
})]);
|
||||
renderComponent();
|
||||
await act(async () => expect(await screen.findAllByText('Thread by other users')).toBeTruthy());
|
||||
await screen.findAllByText('Thread by other users');
|
||||
expect(screen.queryByText('Thread by abc123')).not.toBeInTheDocument();
|
||||
expect(container.querySelectorAll('.discussion-post')).toHaveLength(postCount);
|
||||
});
|
||||
|
||||
@@ -12,12 +12,11 @@ import { LearningHeader as Header } from '@edx/frontend-component-header';
|
||||
|
||||
import { Spinner } from '../../components';
|
||||
import selectCourseTabs from '../../components/NavigationBar/data/selectors';
|
||||
import { LOADED } from '../../components/NavigationBar/data/slice';
|
||||
import { ALL_ROUTES, DiscussionProvider, Routes as ROUTES } from '../../data/constants';
|
||||
import DiscussionContext from '../common/context';
|
||||
import ContentUnavailable from '../content-unavailable/ContentUnavailable';
|
||||
import {
|
||||
useCourseDiscussionData, useIsOnDesktop, useRedirectToThread, useSidebarVisible,
|
||||
useCourseBlockData, useCourseDiscussionData, useIsOnTablet, useRedirectToThread, useSidebarVisible,
|
||||
} from '../data/hooks';
|
||||
import { selectDiscussionProvider, selectEnableInContext, selectIsUserLearner } from '../data/selectors';
|
||||
import { EmptyLearners, EmptyTopics } from '../empty-posts';
|
||||
@@ -25,9 +24,10 @@ import EmptyPosts from '../empty-posts/EmptyPosts';
|
||||
import { EmptyTopic as InContextEmptyTopics } from '../in-context-topics/components';
|
||||
import messages from '../messages';
|
||||
import { selectPostEditorVisible } from '../posts/data/selectors';
|
||||
import { isCourseStatusValid } from '../utils';
|
||||
import useFeedbackWrapper from './FeedbackWrapper';
|
||||
|
||||
const Footer = lazy(() => import('@edx/frontend-component-footer'));
|
||||
const FooterSlot = lazy(() => import('@edx/frontend-component-footer').then(module => ({ default: module.FooterSlot })));
|
||||
const PostActionsBar = lazy(() => import('../posts/post-actions-bar/PostActionsBar'));
|
||||
const CourseTabsNavigation = lazy(() => import('../../components/NavigationBar/CourseTabsNavigation'));
|
||||
const LegacyBreadcrumbMenu = lazy(() => import('../navigation/breadcrumb-menu/LegacyBreadcrumbMenu'));
|
||||
@@ -51,7 +51,7 @@ const DiscussionsHome = () => {
|
||||
const page = pageParams?.page || null;
|
||||
const matchPattern = ALL_ROUTES.find((route) => matchPath({ path: route }, location.pathname));
|
||||
const { params } = useMatch(matchPattern);
|
||||
const isOnDesktop = useIsOnDesktop();
|
||||
const isOnTabletorDesktop = useIsOnTablet();
|
||||
let displaySidebar = useSidebarVisible();
|
||||
const enableInContextSidebar = Boolean(new URLSearchParams(location.search).get('inContextSidebar') !== null);
|
||||
const {
|
||||
@@ -60,12 +60,13 @@ const DiscussionsHome = () => {
|
||||
|
||||
useCourseDiscussionData(courseId);
|
||||
useRedirectToThread(courseId, enableInContextSidebar);
|
||||
useCourseBlockData(courseId);
|
||||
useFeedbackWrapper();
|
||||
/* Display the content area if we are currently viewing/editing a post or creating one.
|
||||
If the window is larger than a particular size, show the sidebar for navigating between posts/topics.
|
||||
However, for smaller screens or embeds, only show the sidebar if the content area isn't displayed. */
|
||||
const displayContentArea = (postId || postEditorVisible || (learnerUsername && postId));
|
||||
if (displayContentArea) { displaySidebar = isOnDesktop; }
|
||||
if (displayContentArea) { displaySidebar = isOnTabletorDesktop; }
|
||||
|
||||
const discussionContextValue = useMemo(() => ({
|
||||
page,
|
||||
@@ -81,7 +82,7 @@ const DiscussionsHome = () => {
|
||||
<Suspense fallback={(<Spinner />)}>
|
||||
<DiscussionContext.Provider value={discussionContextValue}>
|
||||
{!enableInContextSidebar && (<Header courseOrg={org} courseNumber={courseNumber} courseTitle={courseTitle} />)}
|
||||
<main className="container-fluid d-flex flex-column p-0 w-100" id="main" tabIndex="-1">
|
||||
<main className="container-fluid d-flex flex-column p-0 w-100 font-size" id="main" tabIndex="-1">
|
||||
{!enableInContextSidebar && <CourseTabsNavigation />}
|
||||
{(isEnrolled || !isUserLearner) && (
|
||||
<div
|
||||
@@ -120,7 +121,7 @@ const DiscussionsHome = () => {
|
||||
</Routes>
|
||||
</Suspense>
|
||||
)}
|
||||
{(courseStatus === LOADED) && (
|
||||
{isCourseStatusValid(courseStatus) && (
|
||||
!isEnrolled && isUserLearner ? (
|
||||
<Suspense fallback={(<Spinner />)}>
|
||||
<Routes>
|
||||
@@ -172,9 +173,9 @@ const DiscussionsHome = () => {
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{!enableInContextSidebar && (<DiscussionsProductTour />)}
|
||||
{!enableInContextSidebar && isEnrolled && (<DiscussionsProductTour />)}
|
||||
</main>
|
||||
{!enableInContextSidebar && <Footer />}
|
||||
{!enableInContextSidebar && <FooterSlot />}
|
||||
</DiscussionContext.Provider>
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -90,8 +90,7 @@ describe('DiscussionsHome', () => {
|
||||
|
||||
test('full view should hide close button', async () => {
|
||||
renderComponent(`/${courseId}/topics`);
|
||||
expect(screen.queryByText(navigationBarMessages.allTopics.defaultMessage))
|
||||
.toBeInTheDocument();
|
||||
await screen.findByText(navigationBarMessages.allTopics.defaultMessage);
|
||||
expect(screen.queryByRole('button', { name: 'Close' }))
|
||||
.not
|
||||
.toBeInTheDocument();
|
||||
@@ -144,9 +143,7 @@ describe('DiscussionsHome', () => {
|
||||
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
|
||||
await renderComponent(`/${courseId}/${searchByEndPoint}`);
|
||||
|
||||
waitFor(() => {
|
||||
expect(screen.queryByText('Add a post')).toBeInTheDocument();
|
||||
});
|
||||
await screen.findByText('Add a post');
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -170,9 +167,7 @@ describe('DiscussionsHome', () => {
|
||||
await executeThunk(fetchThreads(courseId), store.dispatch, store.getState);
|
||||
await renderComponent(`/${courseId}/${searchByEndPoint}`);
|
||||
|
||||
waitFor(() => {
|
||||
expect(screen.queryByText(result)).toBeInTheDocument();
|
||||
});
|
||||
await screen.findByText(result);
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -199,9 +194,7 @@ describe('DiscussionsHome', () => {
|
||||
await executeThunk(fetchCourseTopicsV3(courseId), store.dispatch, store.getState);
|
||||
await renderComponent(`/${courseId}/${searchByEndPoint}`);
|
||||
|
||||
waitFor(() => {
|
||||
expect(screen.queryByText('No topic selected')).toBeInTheDocument();
|
||||
});
|
||||
await screen.findByText('No topic selected');
|
||||
},
|
||||
);
|
||||
|
||||
@@ -210,9 +203,7 @@ describe('DiscussionsHome', () => {
|
||||
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
|
||||
await renderComponent(`/${courseId}/learners`);
|
||||
|
||||
waitFor(() => {
|
||||
expect(screen.queryByText('Nothing here yet')).toBeInTheDocument();
|
||||
});
|
||||
await screen.findByText('Nothing here yet');
|
||||
});
|
||||
|
||||
it('should display post editor form when click on add a post button for posts', async () => {
|
||||
@@ -230,15 +221,15 @@ describe('DiscussionsHome', () => {
|
||||
|
||||
it('should display post editor form when click on add a post button in legacy topics view', async () => {
|
||||
axiosMock.onGet(getDiscussionsConfigUrl(courseId)).reply(200, {
|
||||
enable_in_context: false,
|
||||
enable_in_context: false, hasModerationPrivileges: true,
|
||||
});
|
||||
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
|
||||
await renderComponent(`/${courseId}/topics`);
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('Nothing here yet')).toBeInTheDocument());
|
||||
await screen.findByText('Nothing here yet');
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.queryByText('Add a post'));
|
||||
fireEvent.click((await screen.findAllByText('Add a post'))[0]);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(container.querySelector('.post-form')).toBeInTheDocument());
|
||||
@@ -247,27 +238,27 @@ describe('DiscussionsHome', () => {
|
||||
it('should display Add a post button for legacy topics view', async () => {
|
||||
await renderComponent(`/${courseId}/topics/topic-1`);
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('Add a post')).toBeInTheDocument());
|
||||
await screen.findByText('Add a post');
|
||||
});
|
||||
|
||||
it('should display No post selected for legacy topics view', async () => {
|
||||
await setUpV1TopicsMockResponse();
|
||||
await renderComponent(`/${courseId}/topics/category-1-topic-1`);
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('No post selected')).toBeInTheDocument());
|
||||
await screen.findByText('No post selected');
|
||||
});
|
||||
|
||||
it('should display No topic selected for legacy topics view', async () => {
|
||||
await setUpV1TopicsMockResponse();
|
||||
await renderComponent(`/${courseId}/topics`);
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('No topic selected')).toBeInTheDocument());
|
||||
await screen.findByText('No topic selected');
|
||||
});
|
||||
|
||||
it('should display navigation tabs', async () => {
|
||||
renderComponent(`/${courseId}/topics`);
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('Discussion')).toBeInTheDocument());
|
||||
await screen.findByText('Discussion');
|
||||
});
|
||||
|
||||
it('should display content unavailable message when the user is not enrolled in the course.', async () => {
|
||||
@@ -276,7 +267,7 @@ describe('DiscussionsHome', () => {
|
||||
|
||||
renderComponent();
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('Content unavailable')).toBeInTheDocument());
|
||||
await screen.findByText('Content unavailable');
|
||||
});
|
||||
|
||||
it('should redirect to dashboard when the user clicks on the Enroll button.', async () => {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
|
||||
import { PageBanner } from '@openedx/paragon';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { PageBanner } from '@edx/paragon';
|
||||
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import { selectConfigLoadingStatus, selectIsPostingEnabled } from '../data/selectors';
|
||||
|
||||
@@ -2,15 +2,15 @@ import React from 'react';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { useIsOnDesktop } from '../data/hooks';
|
||||
import { useIsOnTablet } from '../data/hooks';
|
||||
import messages from '../messages';
|
||||
import EmptyPage from './EmptyPage';
|
||||
|
||||
const EmptyLearners = () => {
|
||||
const intl = useIntl();
|
||||
const isOnDesktop = useIsOnDesktop();
|
||||
const isOnTabletorDesktop = useIsOnTablet();
|
||||
|
||||
if (!isOnDesktop) {
|
||||
if (!isOnTabletorDesktop) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import React from 'react';
|
||||
import propTypes from 'prop-types';
|
||||
|
||||
import { Button } from '@openedx/paragon';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { Button } from '@edx/paragon';
|
||||
|
||||
import EmptyIcon from '../../assets/Empty';
|
||||
|
||||
const EmptyPage = ({
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { useIsOnDesktop } from '../data/hooks';
|
||||
import { useIsOnTablet } from '../data/hooks';
|
||||
import { selectAreThreadsFiltered, selectPostThreadCount } from '../data/selectors';
|
||||
import messages from '../messages';
|
||||
import { showPostEditor } from '../posts/data';
|
||||
@@ -15,7 +15,7 @@ import EmptyPage from './EmptyPage';
|
||||
const EmptyPosts = ({ subTitleMessage }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
const isOnDesktop = useIsOnDesktop();
|
||||
const isOnTabletorDesktop = useIsOnTablet();
|
||||
const isFiltered = useSelector(selectAreThreadsFiltered);
|
||||
const totalThreads = useSelector(selectPostThreadCount);
|
||||
|
||||
@@ -31,7 +31,7 @@ const EmptyPosts = ({ subTitleMessage }) => {
|
||||
|
||||
const isEmpty = [0, null].includes(totalThreads) && !isFiltered;
|
||||
|
||||
if (!(isOnDesktop || isEmpty)) {
|
||||
if (!(isOnTabletorDesktop || isEmpty)) {
|
||||
return null;
|
||||
} if (isEmpty) {
|
||||
subTitle = subTitleMessage;
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useParams } from 'react-router-dom';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { useIsOnDesktop, useTotalTopicThreadCount } from '../data/hooks';
|
||||
import { useIsOnTablet, useTotalTopicThreadCount } from '../data/hooks';
|
||||
import { selectTopicThreadCount } from '../data/selectors';
|
||||
import messages from '../messages';
|
||||
import { showPostEditor } from '../posts/data';
|
||||
@@ -16,7 +16,7 @@ const EmptyTopics = () => {
|
||||
const intl = useIntl();
|
||||
const { topicId } = useParams();
|
||||
const dispatch = useDispatch();
|
||||
const isOnDesktop = useIsOnDesktop();
|
||||
const isOnTabletorDesktop = useIsOnTablet();
|
||||
const hasGlobalThreads = useTotalTopicThreadCount() > 0;
|
||||
const topicThreadCount = useSelector(selectTopicThreadCount(topicId));
|
||||
|
||||
@@ -30,7 +30,7 @@ const EmptyTopics = () => {
|
||||
let action;
|
||||
let actionText;
|
||||
|
||||
if (!isOnDesktop) {
|
||||
if (!isOnTabletorDesktop) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -66,15 +66,13 @@ describe('EmptyTopics', () => {
|
||||
|
||||
test('"no topic selected" text shown when viewing topics page', async () => {
|
||||
renderComponent(`/${courseId}/topics/`);
|
||||
expect(screen.queryByText(messages.emptyTitle.defaultMessage))
|
||||
.toBeInTheDocument();
|
||||
await screen.findByText(messages.emptyTitle.defaultMessage);
|
||||
});
|
||||
|
||||
test('"no post selected" text shown when viewing a specific topic', async () => {
|
||||
await setupMockResponse();
|
||||
renderComponent(`/${courseId}/topics/ncwtopic-3/`);
|
||||
|
||||
expect(screen.queryByText(messages.noPostSelected.defaultMessage))
|
||||
.toBeInTheDocument();
|
||||
await screen.findByText(messages.noPostSelected.defaultMessage);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,11 +2,11 @@ import React, {
|
||||
useCallback, useContext, useEffect, useMemo,
|
||||
} from 'react';
|
||||
|
||||
import { Spinner } from '@openedx/paragon';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Spinner } from '@edx/paragon';
|
||||
|
||||
import { RequestStatus, Routes } from '../../data/constants';
|
||||
import DiscussionContext from '../common/context';
|
||||
|
||||
@@ -283,9 +283,9 @@ describe('InContext Topic Posts View', () => {
|
||||
await setupTopicsMockResponse(0, 0, 0);
|
||||
await renderComponent({ topicId: 'test-topic', category: 'test-category' });
|
||||
|
||||
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();
|
||||
await within(container).findByText('Nothing here yet');
|
||||
await within(container).findByText('No topic exists');
|
||||
await within(container).findByText('Unnamed Topic');
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -2,12 +2,11 @@ import React, {
|
||||
useCallback, useContext, useEffect, useMemo,
|
||||
} from 'react';
|
||||
|
||||
import { Spinner } from '@openedx/paragon';
|
||||
import classNames from 'classnames';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { Spinner } from '@edx/paragon';
|
||||
|
||||
import SearchInfo from '../../components/SearchInfo';
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import DiscussionContext from '../common/context';
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
fireEvent, render, screen, waitFor,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { IntlProvider } from 'react-intl';
|
||||
@@ -151,21 +152,24 @@ describe('InContext Topics View', () => {
|
||||
const sectionGroups = await screen.getAllByTestId('section-group');
|
||||
|
||||
coursewareTopics.forEach(async (topic, index) => {
|
||||
const stats = await sectionGroups[index].querySelectorAll('.icon-size:not([data-testid="subsection-group"].icon-size)');
|
||||
const subsectionGroups = await within(sectionGroups[index]).getAllByTestId('subsection-group');
|
||||
await waitFor(async () => {
|
||||
const stats = await sectionGroups[index].querySelectorAll('.icon-size:not([data-testid="subsection-group"].icon-size)');
|
||||
const subsectionGroups = await within(sectionGroups[index]).getAllByTestId('subsection-group');
|
||||
|
||||
expect(within(sectionGroups[index]).queryByText(topic.displayName)).toBeInTheDocument();
|
||||
expect(stats).toHaveLength(0);
|
||||
expect(subsectionGroups).toHaveLength(2);
|
||||
expect(within(sectionGroups[index]).queryByText(topic.displayName)).toBeInTheDocument();
|
||||
expect(stats).toHaveLength(0);
|
||||
expect(subsectionGroups).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('The subsection should have a title name, be clickable, and have the stats', async () => {
|
||||
await setupMockResponse();
|
||||
renderComponent();
|
||||
await screen.findByTestId('redux-provider');
|
||||
const subsectionObject = coursewareTopics[0].children[0];
|
||||
const subSection = await container.querySelector(`[data-subsection-id=${subsectionObject.id}]`);
|
||||
const subSectionTitle = await within(subSection).queryByText(subsectionObject.displayName);
|
||||
const subSectionTitle = await within(subSection).findByText(subsectionObject.displayName);
|
||||
const statsList = await subSection.querySelectorAll('.icon-size');
|
||||
|
||||
expect(subSectionTitle).toBeInTheDocument();
|
||||
@@ -175,11 +179,12 @@ describe('InContext Topics View', () => {
|
||||
it('Subsection names should be clickable and redirected to the units lists', async () => {
|
||||
await setupMockResponse();
|
||||
renderComponent();
|
||||
await screen.findByTestId('redux-provider');
|
||||
|
||||
const subsectionObject = coursewareTopics[0].children[0];
|
||||
const subSection = await container.querySelector(`[data-subsection-id=${subsectionObject.id}]`);
|
||||
|
||||
await act(async () => { fireEvent.click(subSection); });
|
||||
await userEvent.click(subSection);
|
||||
await waitFor(async () => {
|
||||
const backButton = await screen.getByLabelText('Back to topics list');
|
||||
const topicsList = await screen.getByRole('list');
|
||||
@@ -196,9 +201,11 @@ describe('InContext Topics View', () => {
|
||||
it('The number of units should be matched with the actual unit length.', async () => {
|
||||
await setupMockResponse();
|
||||
renderComponent();
|
||||
await screen.findByTestId('redux-provider');
|
||||
|
||||
const subSection = await container.querySelector(`[data-subsection-id=${coursewareTopics[0].children[0].id}]`);
|
||||
|
||||
await act(async () => { fireEvent.click(subSection); });
|
||||
await userEvent.click(subSection);
|
||||
await waitFor(async () => {
|
||||
const units = await container.querySelectorAll('.discussion-topic');
|
||||
|
||||
@@ -209,12 +216,14 @@ describe('InContext Topics View', () => {
|
||||
it('A unit should have a title and should be clickable', async () => {
|
||||
await setupMockResponse();
|
||||
renderComponent();
|
||||
await screen.findByTestId('redux-provider');
|
||||
|
||||
const subSectionObject = coursewareTopics[0].children[0];
|
||||
const unitObject = subSectionObject.children[0];
|
||||
|
||||
const subSection = await container.querySelector(`[data-subsection-id=${subSectionObject.id}]`);
|
||||
|
||||
await act(async () => { fireEvent.click(subSection); });
|
||||
await userEvent.click(subSection);
|
||||
await waitFor(async () => {
|
||||
const unitElement = await screen.findByText(unitObject.name);
|
||||
const unitContainer = await container.querySelector(`[data-topic-id=${unitObject.id}]`);
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Icon, IconButton, Spinner } from '@openedx/paragon';
|
||||
import { ArrowBack } from '@openedx/paragon/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Icon, IconButton, Spinner } from '@edx/paragon';
|
||||
import { ArrowBack } from '@edx/paragon/icons';
|
||||
|
||||
import messages from '../messages';
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useParams } from 'react-router-dom';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import DiscussionContext from '../../common/context';
|
||||
import { useIsOnDesktop } from '../../data/hooks';
|
||||
import { useIsOnTablet } from '../../data/hooks';
|
||||
import { selectPostThreadCount } from '../../data/selectors';
|
||||
import EmptyPage from '../../empty-posts/EmptyPage';
|
||||
import messages from '../../messages';
|
||||
@@ -17,7 +17,7 @@ const EmptyTopics = () => {
|
||||
const intl = useIntl();
|
||||
const { category, topicId } = useParams();
|
||||
const dispatch = useDispatch();
|
||||
const isOnDesktop = useIsOnDesktop();
|
||||
const isOnTabletorDesktop = useIsOnTablet();
|
||||
const { enableInContextSidebar } = useContext(DiscussionContext);
|
||||
const courseWareThreadsCount = useSelector(selectCourseWareThreadsCount(category));
|
||||
const topicThreadsCount = useSelector(selectPostThreadCount);
|
||||
@@ -34,7 +34,7 @@ const EmptyTopics = () => {
|
||||
let action;
|
||||
let actionText;
|
||||
|
||||
if (!isOnDesktop) {
|
||||
if (!isOnTabletorDesktop) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React, { useCallback, useContext, useEffect } from 'react';
|
||||
|
||||
import { Icon, SearchField } from '@openedx/paragon';
|
||||
import { Search as SearchIcon } from '@openedx/paragon/icons';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Icon, SearchField } from '@edx/paragon';
|
||||
import { Search as SearchIcon } from '@edx/paragon/icons';
|
||||
|
||||
import DiscussionContext from '../../common/context';
|
||||
import postsMessages from '../../posts/post-actions-bar/messages';
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React from 'react';
|
||||
|
||||
import { SearchField } from '@openedx/paragon';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { SearchField } from '@edx/paragon';
|
||||
|
||||
import { setFilter } from '../data';
|
||||
import messages from '../messages';
|
||||
|
||||
@@ -2,15 +2,15 @@ import React, {
|
||||
useCallback, useContext, useEffect, useMemo,
|
||||
} from 'react';
|
||||
|
||||
import {
|
||||
Button, Icon, IconButton, Spinner,
|
||||
} from '@openedx/paragon';
|
||||
import { ArrowBack } from '@openedx/paragon/icons';
|
||||
import capitalize from 'lodash/capitalize';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Button, Icon, IconButton, Spinner,
|
||||
} from '@edx/paragon';
|
||||
import { ArrowBack } from '@edx/paragon/icons';
|
||||
|
||||
import {
|
||||
RequestStatus,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React, { useCallback, useEffect, useMemo } from 'react';
|
||||
|
||||
import { Button, Spinner } from '@openedx/paragon';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Button, Spinner } from '@edx/paragon';
|
||||
|
||||
import SearchInfo from '../../components/SearchInfo';
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
|
||||
@@ -12,3 +12,7 @@ export const selectUsernameSearch = () => state => state.learners.usernameSearch
|
||||
export const selectLearnerSorting = () => state => state.learners.sortedBy;
|
||||
|
||||
export const selectLearnerNextPage = () => state => state.learners.nextPage;
|
||||
|
||||
export const selectLearnerAvatar = author => state => (
|
||||
state.learners.learnerProfiles[author]?.profileImage?.imageUrlLarge
|
||||
);
|
||||
|
||||
@@ -1,20 +1,27 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Avatar } from '@edx/paragon';
|
||||
import { Avatar } from '@openedx/paragon';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
const LearnerAvatar = ({ username }) => (
|
||||
<div className="mr-3 mt-1">
|
||||
<Avatar
|
||||
size="sm"
|
||||
alt={username}
|
||||
style={{
|
||||
height: '2rem',
|
||||
width: '2rem',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
import { selectLearnerAvatar } from '../data/selectors';
|
||||
|
||||
const LearnerAvatar = ({ username }) => {
|
||||
const learnerAvatar = useSelector(selectLearnerAvatar(username));
|
||||
return (
|
||||
<div className="mr-3 mt-1">
|
||||
<Avatar
|
||||
size="sm"
|
||||
alt={username}
|
||||
src={learnerAvatar}
|
||||
style={{
|
||||
height: '2rem',
|
||||
width: '2rem',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
LearnerAvatar.propTypes = {
|
||||
username: PropTypes.string.isRequired,
|
||||
|
||||
@@ -37,7 +37,7 @@ const LearnerCard = ({ learner }) => {
|
||||
<div className="d-flex flex-column justify-content-start mw-100 flex-fill">
|
||||
<div className="d-flex align-items-center flex-fill">
|
||||
<div
|
||||
className="text-truncate font-weight-500 font-size-14 text-primary-500 font-style"
|
||||
className="text-truncate font-weight-500 text-primary-500 font-style"
|
||||
>
|
||||
{username}
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Collapsible, Form, Icon } from '@openedx/paragon';
|
||||
import { Check, Tune } from '@openedx/paragon/icons';
|
||||
import classNames from 'classnames';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Collapsible, Form, Icon } from '@edx/paragon';
|
||||
import { Check, Tune } from '@edx/paragon/icons';
|
||||
|
||||
import { LearnersOrdering } from '../../../data/constants';
|
||||
import { selectUserHasModerationPrivileges, selectUserIsGroupTa } from '../../data/selectors';
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Icon, OverlayTrigger, Tooltip } from '@openedx/paragon';
|
||||
import {
|
||||
Edit, QuestionAnswerOutline, Report, ReportGmailerrorred,
|
||||
} from '@openedx/paragon/icons';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Icon, OverlayTrigger, Tooltip } from '@edx/paragon';
|
||||
import {
|
||||
Edit, QuestionAnswerOutline, Report, ReportGmailerrorred,
|
||||
} from '@edx/paragon/icons';
|
||||
|
||||
import { selectUserHasModerationPrivileges, selectUserIsGroupTa } from '../../data/selectors';
|
||||
import messages from '../messages';
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Dropdown, DropdownButton } from '@openedx/paragon';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Dropdown, DropdownButton } from '@edx/paragon';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React, { useContext, useMemo } from 'react';
|
||||
|
||||
import { Nav } from '@openedx/paragon';
|
||||
import { matchPath, NavLink, useLocation } from 'react-router-dom';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Nav } from '@edx/paragon';
|
||||
|
||||
import { Routes } from '../../../data/constants';
|
||||
import DiscussionContext from '../../common/context';
|
||||
|
||||
@@ -2,19 +2,17 @@ import React, {
|
||||
Suspense, useCallback, useContext, useEffect, useMemo, useState,
|
||||
} from 'react';
|
||||
|
||||
import { Button, Icon, IconButton } from '@openedx/paragon';
|
||||
import { ArrowBack } from '@openedx/paragon/icons';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Button, Icon, IconButton } from '@edx/paragon';
|
||||
import { ArrowBack } from '@edx/paragon/icons';
|
||||
|
||||
import Spinner from '../../components/Spinner';
|
||||
import {
|
||||
EndorsementStatus, PostsPages, ThreadType,
|
||||
} from '../../data/constants';
|
||||
import { PostsPages } from '../../data/constants';
|
||||
import useDispatchWithState from '../../data/hooks';
|
||||
import DiscussionContext from '../common/context';
|
||||
import { useIsOnDesktop } from '../data/hooks';
|
||||
import { useIsOnTablet } from '../data/hooks';
|
||||
import { EmptyPage } from '../empty-posts';
|
||||
import { Post } from '../posts';
|
||||
import { fetchThread } from '../posts/data/thunks';
|
||||
@@ -31,7 +29,7 @@ const PostCommentsView = () => {
|
||||
const intl = useIntl();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const isOnDesktop = useIsOnDesktop();
|
||||
const isOnTabletorDesktop = useIsOnTablet();
|
||||
const [addingResponse, setAddingResponse] = useState(false);
|
||||
const [isLoading, submitDispatch] = useDispatchWithState();
|
||||
const {
|
||||
@@ -86,7 +84,7 @@ const PostCommentsView = () => {
|
||||
|
||||
return (
|
||||
<PostCommentsContext.Provider value={postCommentsContextValue}>
|
||||
{!isOnDesktop && (
|
||||
{!isOnTabletorDesktop && (
|
||||
enableInContextSidebar ? (
|
||||
<>
|
||||
<div className="px-4 py-1.5 bg-white">
|
||||
@@ -127,15 +125,7 @@ const PostCommentsView = () => {
|
||||
</div>
|
||||
<Suspense fallback={(<Spinner />)}>
|
||||
{!!commentsCount && <CommentsSort />}
|
||||
{type === ThreadType.DISCUSSION && (
|
||||
<CommentsView endorsed={EndorsementStatus.DISCUSSION} />
|
||||
)}
|
||||
{type === ThreadType.QUESTION && (
|
||||
<>
|
||||
<CommentsView endorsed={EndorsementStatus.ENDORSED} />
|
||||
<CommentsView endorsed={EndorsementStatus.UNENDORSED} />
|
||||
</>
|
||||
)}
|
||||
<CommentsView threadType={type} />
|
||||
</Suspense>
|
||||
</PostCommentsContext.Provider>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import React, { useRef } from 'react';
|
||||
|
||||
import {
|
||||
act, fireEvent, render, screen, waitFor, within,
|
||||
} from '@testing-library/react';
|
||||
@@ -12,17 +14,23 @@ 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 { getApiBaseUrl, ThreadType } from '../../data/constants';
|
||||
import { initializeStore } from '../../store';
|
||||
import executeThunk from '../../test-utils';
|
||||
import { getCohortsApiUrl } from '../cohorts/data/api';
|
||||
import fetchCourseCohorts from '../cohorts/data/thunks';
|
||||
import DiscussionContext from '../common/context';
|
||||
import { getCourseConfigApiUrl } from '../data/api';
|
||||
import { getCourseConfigApiUrl, getCourseSettingsApiUrl } from '../data/api';
|
||||
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 MockReCAPTCHA, {
|
||||
mockOnChange,
|
||||
mockOnError,
|
||||
mockOnExpired,
|
||||
mockReset,
|
||||
} from '../posts/post-editor/mocksData/react-google-recaptcha';
|
||||
import fetchCourseTopics from '../topics/data/thunks';
|
||||
import { getDiscussionTourUrl } from '../tours/data/api';
|
||||
import selectTours from '../tours/data/selectors';
|
||||
@@ -37,6 +45,7 @@ import '../topics/data/__factories__';
|
||||
import '../cohorts/data/__factories__';
|
||||
|
||||
const courseConfigApiUrl = getCourseConfigApiUrl();
|
||||
const courseSettingsApiUrl = getCourseSettingsApiUrl();
|
||||
const commentsApiUrl = getCommentsApiUrl();
|
||||
const threadsApiUrl = getThreadsApiUrl();
|
||||
const discussionPostId = 'thread-1';
|
||||
@@ -50,10 +59,12 @@ let testLocation;
|
||||
let container;
|
||||
let unmount;
|
||||
|
||||
async function mockAxiosReturnPagedComments(threadId, endorsed = false, page = 1, count = 2) {
|
||||
jest.mock('react-google-recaptcha', () => MockReCAPTCHA);
|
||||
|
||||
async function mockAxiosReturnPagedComments(threadId, threadType = ThreadType.DISCUSSION, page = 1, count = 2) {
|
||||
axiosMock.onGet(commentsApiUrl).reply(200, Factory.build('commentsResult', { can_delete: true }, {
|
||||
threadId,
|
||||
endorsed,
|
||||
threadType,
|
||||
pageSize: 1,
|
||||
count,
|
||||
childCount: page === 1 ? 2 : 0,
|
||||
@@ -76,6 +87,7 @@ async function mockAxiosReturnPagedCommentsResponses() {
|
||||
Factory.build('commentsResult', null, {
|
||||
threadId: discussionPostId,
|
||||
parentId,
|
||||
endorsed: false,
|
||||
page,
|
||||
pageSize: 1,
|
||||
count: 2,
|
||||
@@ -104,7 +116,7 @@ async function setupCourseConfig() {
|
||||
{ code: 'reason-2', label: 'reason 2' },
|
||||
],
|
||||
});
|
||||
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/settings`).reply(200, {});
|
||||
axiosMock.onGet(`${courseSettingsApiUrl}${courseId}/settings`).reply(200, {});
|
||||
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
|
||||
}
|
||||
|
||||
@@ -201,6 +213,7 @@ describe('ThreadView', () => {
|
||||
id: commentId,
|
||||
rendered_body: rawBody,
|
||||
raw_body: rawBody,
|
||||
endorsed: false,
|
||||
})];
|
||||
});
|
||||
axiosMock.onPost(commentsApiUrl).reply(({ data }) => {
|
||||
@@ -209,9 +222,16 @@ describe('ThreadView', () => {
|
||||
rendered_body: rawBody,
|
||||
raw_body: rawBody,
|
||||
thread_id: threadId,
|
||||
endorsed: false,
|
||||
})];
|
||||
});
|
||||
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`).reply(200, { isPostingEnabled: true });
|
||||
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`).reply(200, {
|
||||
isPostingEnabled: true,
|
||||
captchaSettings: {
|
||||
enabled: true,
|
||||
siteKey: 'test-key',
|
||||
},
|
||||
});
|
||||
window.HTMLElement.prototype.scrollIntoView = jest.fn();
|
||||
|
||||
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
|
||||
@@ -230,9 +250,9 @@ 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 () => {
|
||||
it('should not allow posting a reply on a closed post', async () => {
|
||||
axiosMock.reset();
|
||||
await mockAxiosReturnPagedComments(closedPostId, true);
|
||||
await mockAxiosReturnPagedComments(closedPostId, ThreadType.QUESTION);
|
||||
await waitFor(() => renderComponent(closedPostId, true));
|
||||
const comments = await waitFor(() => screen.findAllByTestId('comment-comment-4'));
|
||||
const hoverCard = within(comments[0]).getByTestId('hover-card-comment-4');
|
||||
@@ -288,7 +308,30 @@ describe('ThreadView', () => {
|
||||
expect(screen.queryByTestId('tinymce-editor')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should allow posting a response', async () => {
|
||||
it('should allow posting a comment with CAPTCHA', async () => {
|
||||
await waitFor(() => 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: /Add comment/i })); });
|
||||
await act(async () => { fireEvent.change(screen.getByTestId('tinymce-editor'), { target: { value: 'New comment with CAPTCHA' } }); });
|
||||
await act(async () => { fireEvent.click(screen.getByText('Solve CAPTCHA')); });
|
||||
await act(async () => { fireEvent.click(screen.getByText(/submit/i)); });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(axiosMock.history.post).toHaveLength(1);
|
||||
expect(JSON.parse(axiosMock.history.post[0].data)).toMatchObject({
|
||||
captcha_token: 'm',
|
||||
enable_in_context_sidebar: false,
|
||||
parent_id: 'comment-1',
|
||||
raw_body: 'New comment with CAPTCHA',
|
||||
thread_id: 'thread-1',
|
||||
});
|
||||
expect(mockOnChange).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow posting a comment', async () => {
|
||||
await waitFor(() => renderComponent(discussionPostId));
|
||||
|
||||
const post = await screen.findByTestId('post-thread-1');
|
||||
@@ -298,7 +341,6 @@ describe('ThreadView', () => {
|
||||
await act(async () => { fireEvent.change(screen.getByTestId('tinymce-editor'), { target: { value: 'testing123' } }); });
|
||||
await act(async () => { fireEvent.click(screen.getByText(/submit/i)); });
|
||||
|
||||
expect(screen.queryByTestId('tinymce-editor')).not.toBeInTheDocument();
|
||||
await waitFor(async () => expect(await screen.findByTestId('comment-1')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
@@ -319,7 +361,6 @@ describe('ThreadView', () => {
|
||||
await act(async () => { fireEvent.change(screen.getByTestId('tinymce-editor'), { target: { value: 'testing123' } }); });
|
||||
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());
|
||||
});
|
||||
|
||||
@@ -540,8 +581,11 @@ describe('ThreadView', () => {
|
||||
// Wait for the content to load
|
||||
const comment = await waitFor(() => screen.findByTestId('comment-comment-1'));
|
||||
const hoverCard = within(comment).getByTestId('hover-card-comment-1');
|
||||
|
||||
const endorseButton = await waitFor(() => within(hoverCard).getByRole('button', { name: /Endorse/i }));
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(within(hoverCard).getByRole('button', { name: /Endorse/i }));
|
||||
fireEvent.click(endorseButton);
|
||||
});
|
||||
expect(axiosMock.history.patch).toHaveLength(2);
|
||||
expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject({ endorsed: true });
|
||||
@@ -574,6 +618,42 @@ describe('ThreadView', () => {
|
||||
describe('for discussion thread', () => {
|
||||
const findLoadMoreCommentsButton = () => screen.findByTestId('load-more-comments');
|
||||
|
||||
it('renders the mocked ReCAPTCHA.', async () => {
|
||||
await waitFor(() => renderComponent(discussionPostId));
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.queryByText('Add comment'));
|
||||
});
|
||||
expect(screen.getByTestId('mocked-recaptcha')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('successfully calls onTokenChange when Solve CAPTCHA button is clicked', async () => {
|
||||
await waitFor(() => renderComponent(discussionPostId));
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.queryByText('Add comment'));
|
||||
});
|
||||
const solveButton = screen.getByText('Solve CAPTCHA');
|
||||
fireEvent.click(solveButton);
|
||||
expect(mockOnChange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('successfully calls onExpired handler when CAPTCHA expires', async () => {
|
||||
await waitFor(() => renderComponent(discussionPostId));
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.queryByText('Add comment'));
|
||||
});
|
||||
fireEvent.click(screen.getByText('Expire CAPTCHA'));
|
||||
expect(mockOnExpired).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('successfully calls onError handler when CAPTCHA errors', async () => {
|
||||
await waitFor(() => renderComponent(discussionPostId));
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.queryByText('Add comment'));
|
||||
});
|
||||
fireEvent.click(screen.getByText('Error CAPTCHA'));
|
||||
expect(mockOnError).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shown post not found when post id does not belong to course', async () => {
|
||||
await waitFor(() => renderComponent('unloaded-id'));
|
||||
expect(await screen.findByText('Thread not found', { exact: true }))
|
||||
@@ -591,7 +671,7 @@ describe('ThreadView', () => {
|
||||
|
||||
it('pressing load more button will load next page of comments', async () => {
|
||||
await waitFor(() => renderComponent(discussionPostId));
|
||||
await mockAxiosReturnPagedComments(discussionPostId, false, 2);
|
||||
await mockAxiosReturnPagedComments(discussionPostId, ThreadType.DISCUSSION, 2);
|
||||
|
||||
const loadMoreButton = await findLoadMoreCommentsButton();
|
||||
await act(async () => {
|
||||
@@ -604,7 +684,7 @@ describe('ThreadView', () => {
|
||||
|
||||
it('newly loaded comments are appended to the old ones', async () => {
|
||||
await waitFor(() => renderComponent(discussionPostId));
|
||||
await mockAxiosReturnPagedComments(discussionPostId, false, 2);
|
||||
await mockAxiosReturnPagedComments(discussionPostId, ThreadType.DISCUSSION, 2);
|
||||
|
||||
const loadMoreButton = await findLoadMoreCommentsButton();
|
||||
await act(async () => {
|
||||
@@ -622,7 +702,7 @@ describe('ThreadView', () => {
|
||||
const findLoadMoreCommentsButtons = () => screen.findByTestId('load-more-comments');
|
||||
|
||||
it('initially loads only the first page', async () => {
|
||||
await mockAxiosReturnPagedComments(questionPostId);
|
||||
await mockAxiosReturnPagedComments(questionPostId, ThreadType.QUESTION);
|
||||
act(() => renderComponent(questionPostId));
|
||||
|
||||
expect(await screen.findByTestId('comment-comment-4'))
|
||||
@@ -633,7 +713,7 @@ describe('ThreadView', () => {
|
||||
});
|
||||
|
||||
it('pressing load more button will load next page of comments', async () => {
|
||||
await mockAxiosReturnPagedComments(questionPostId);
|
||||
await mockAxiosReturnPagedComments(questionPostId, ThreadType.QUESTION);
|
||||
await waitFor(() => renderComponent(questionPostId));
|
||||
|
||||
const loadMoreButton = await findLoadMoreCommentsButtons();
|
||||
@@ -644,7 +724,7 @@ describe('ThreadView', () => {
|
||||
expect(await screen.queryByTestId('comment-comment-5'))
|
||||
.not
|
||||
.toBeInTheDocument();
|
||||
await mockAxiosReturnPagedComments(questionPostId, false, 2, 1);
|
||||
await mockAxiosReturnPagedComments(questionPostId, ThreadType.QUESTION, 2, 1);
|
||||
await act(async () => {
|
||||
fireEvent.click(loadMoreButton);
|
||||
});
|
||||
@@ -664,7 +744,152 @@ describe('ThreadView', () => {
|
||||
expect(screen.queryByTestId('reply-comment-3')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('pressing load more button will load next page of responses', async () => {
|
||||
it('successfully added comment in the draft.', async () => {
|
||||
await waitFor(() => renderComponent(discussionPostId));
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.queryByText('Add comment'));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
fireEvent.change(screen.queryByTestId('tinymce-editor'), { target: { value: 'Draft comment!' } });
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.queryByText('Cancel'));
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.queryByText('Add comment'));
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Draft comment!')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('successfully updated comment in the draft.', async () => {
|
||||
await waitFor(() => renderComponent(discussionPostId));
|
||||
|
||||
const comment = screen.queryByTestId('reply-comment-2');
|
||||
const actionBtn = comment.querySelector('button[aria-label="Actions menu"]');
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(actionBtn);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.queryByTestId('edit'));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
fireEvent.change(screen.queryByTestId('tinymce-editor'), { target: { value: 'Draft comment!' } });
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.queryByText('Cancel'));
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(actionBtn);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.queryByTestId('edit'));
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.queryByText('Submit'));
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('Draft comment!')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('successfully removed comment from the draft.', async () => {
|
||||
await waitFor(() => renderComponent(discussionPostId));
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.queryByText('Add comment'));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
fireEvent.change(screen.queryByTestId('tinymce-editor'), { target: { value: 'Draft comment 123!' } });
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.queryByText('Submit'));
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.queryAllByText('Add comment')[0]);
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId('tinymce-editor').value).toBe('Draft comment 123!');
|
||||
});
|
||||
|
||||
it('successfully added response in the draft.', async () => {
|
||||
await waitFor(() => renderComponent(discussionPostId));
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.queryByText('Add response'));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
fireEvent.change(screen.queryByTestId('tinymce-editor'), { target: { value: 'Draft Response!' } });
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.queryByText('Cancel'));
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.queryByText('Add response'));
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Draft Response!')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('successfully removed response from the draft.', async () => {
|
||||
await waitFor(() => renderComponent(discussionPostId));
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.queryByText('Add response'));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
fireEvent.change(screen.queryByTestId('tinymce-editor'), { target: { value: 'Draft Response!' } });
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.queryByText('Submit'));
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.queryByText('Add response'));
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId('tinymce-editor').value).toBe('Draft Response!');
|
||||
});
|
||||
|
||||
it('successfully maintain response for the specific post in the draft.', async () => {
|
||||
await waitFor(() => renderComponent(discussionPostId));
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.queryByText('Add response'));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
fireEvent.change(screen.queryByTestId('tinymce-editor'), { target: { value: 'Hello, world!' } });
|
||||
});
|
||||
|
||||
await waitFor(() => renderComponent('thread-2'));
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.queryAllByText('Add response')[0]);
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Hello, world!')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('pressing load more button will load next page of replies', async () => {
|
||||
await waitFor(() => renderComponent(discussionPostId));
|
||||
|
||||
const loadMoreButton = await findLoadMoreCommentsResponsesButton();
|
||||
@@ -674,7 +899,7 @@ describe('ThreadView', () => {
|
||||
await screen.findByTestId('reply-comment-3');
|
||||
});
|
||||
|
||||
it('newly loaded responses are appended to the old ones', async () => {
|
||||
it('newly loaded replies are appended to the old ones', async () => {
|
||||
await waitFor(() => renderComponent(discussionPostId));
|
||||
|
||||
const loadMoreButton = await findLoadMoreCommentsResponsesButton();
|
||||
@@ -687,7 +912,7 @@ describe('ThreadView', () => {
|
||||
expect(screen.queryByTestId('reply-comment-2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('load more button is hidden when no more responses pages to load', async () => {
|
||||
it('load more button is hidden when no more replies pages to load', async () => {
|
||||
await waitFor(() => renderComponent(discussionPostId));
|
||||
|
||||
const loadMoreButton = await findLoadMoreCommentsResponsesButton();
|
||||
@@ -823,3 +1048,61 @@ describe('ThreadView', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('MockReCAPTCHA', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('uses defaultProps when props are not provided', () => {
|
||||
render(<MockReCAPTCHA />);
|
||||
|
||||
expect(screen.getByTestId('mocked-recaptcha')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText('Solve CAPTCHA'));
|
||||
fireEvent.click(screen.getByText('Expire CAPTCHA'));
|
||||
fireEvent.click(screen.getByText('Error CAPTCHA'));
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalled();
|
||||
expect(mockOnExpired).toHaveBeenCalled();
|
||||
expect(mockOnError).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('triggers all callbacks and exposes reset via ref', () => {
|
||||
const onChange = jest.fn();
|
||||
const onExpired = jest.fn();
|
||||
const onError = jest.fn();
|
||||
|
||||
const Wrapper = () => {
|
||||
const recaptchaRef = useRef(null);
|
||||
return (
|
||||
<div>
|
||||
<MockReCAPTCHA
|
||||
ref={recaptchaRef}
|
||||
onChange={onChange}
|
||||
onExpired={onExpired}
|
||||
onError={onError}
|
||||
/>
|
||||
<button onClick={() => recaptchaRef.current.reset()} data-testid="reset-btn" type="button">Reset</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const { getByText, getByTestId } = render(<Wrapper />);
|
||||
|
||||
fireEvent.click(getByText('Solve CAPTCHA'));
|
||||
fireEvent.click(getByText('Expire CAPTCHA'));
|
||||
fireEvent.click(getByText('Error CAPTCHA'));
|
||||
|
||||
fireEvent.click(getByTestId('reset-btn'));
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalled();
|
||||
expect(mockOnExpired).toHaveBeenCalled();
|
||||
expect(mockOnError).toHaveBeenCalled();
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('mock-token');
|
||||
expect(onExpired).toHaveBeenCalled();
|
||||
expect(onError).toHaveBeenCalled();
|
||||
expect(mockReset).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
Button, Dropdown, ModalPopup, useToggle,
|
||||
} from '@openedx/paragon';
|
||||
import { ExpandLess, ExpandMore } from '@openedx/paragon/icons';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Button, Dropdown, ModalPopup, useToggle,
|
||||
} from '@edx/paragon';
|
||||
import { ExpandLess, ExpandMore } from '@edx/paragon/icons';
|
||||
|
||||
import { updateUserDiscussionsTourByName } from '../../tours/data';
|
||||
import { selectCommentSortOrder } from '../data/selectors';
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React, { useCallback, useContext, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Button, Spinner } from '@edx/paragon';
|
||||
import { Button, Spinner } from '@openedx/paragon';
|
||||
|
||||
import { EndorsementStatus } from '../../../data/constants';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { ThreadType } from '../../../data/constants';
|
||||
import { useUserPostingEnabled } from '../../data/hooks';
|
||||
import { isLastElementOfList } from '../../utils';
|
||||
import { usePostComments } from '../data/hooks';
|
||||
@@ -12,7 +13,7 @@ import messages from '../messages';
|
||||
import PostCommentsContext from '../postCommentsContext';
|
||||
import { Comment, ResponseEditor } from './comment';
|
||||
|
||||
const CommentsView = ({ endorsed }) => {
|
||||
const CommentsView = ({ threadType }) => {
|
||||
const intl = useIntl();
|
||||
const [addingResponse, setAddingResponse] = useState(false);
|
||||
const { isClosed } = useContext(PostCommentsContext);
|
||||
@@ -24,7 +25,7 @@ const CommentsView = ({ endorsed }) => {
|
||||
hasMorePages,
|
||||
isLoading,
|
||||
handleLoadMoreResponses,
|
||||
} = usePostComments(endorsed);
|
||||
} = usePostComments(threadType);
|
||||
|
||||
const handleAddResponse = useCallback(() => {
|
||||
setAddingResponse(true);
|
||||
@@ -44,7 +45,7 @@ const CommentsView = ({ endorsed }) => {
|
||||
</div>
|
||||
), []);
|
||||
|
||||
const handleComments = useCallback((postCommentsIds, showLoadMoreResponses = false) => (
|
||||
const handleComments = useCallback((postCommentsIds) => (
|
||||
<div className="mx-4" role="list">
|
||||
{postCommentsIds.map((commentId) => (
|
||||
<Comment
|
||||
@@ -53,72 +54,66 @@ const CommentsView = ({ endorsed }) => {
|
||||
marginBottom={isLastElementOfList(postCommentsIds, commentId)}
|
||||
/>
|
||||
))}
|
||||
{hasMorePages && !isLoading && !showLoadMoreResponses && (
|
||||
<Button
|
||||
onClick={handleLoadMoreResponses}
|
||||
variant="link"
|
||||
block="true"
|
||||
className="px-4 mt-3 border-0 line-height-24 py-0 mb-2 font-style font-weight-500 font-size-14"
|
||||
data-testid="load-more-comments"
|
||||
>
|
||||
{intl.formatMessage(messages.loadMoreResponses)}
|
||||
</Button>
|
||||
)}
|
||||
{isLoading && !showLoadMoreResponses && (
|
||||
<div className="mb-2 mt-3 d-flex justify-content-center">
|
||||
<Spinner animation="border" variant="primary" className="spinner-dimensions" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
), [hasMorePages, isLoading, handleLoadMoreResponses]);
|
||||
|
||||
return (
|
||||
((hasMorePages && isLoading) || !isLoading) && (
|
||||
<>
|
||||
{endorsedCommentsIds.length > 0 && (
|
||||
<>
|
||||
{endorsedCommentsIds.length > 0 && (
|
||||
<>
|
||||
{handleDefinition(messages.endorsedResponseCount, endorsedCommentsIds.length)}
|
||||
{endorsed === EndorsementStatus.DISCUSSION
|
||||
? handleComments(endorsedCommentsIds, true)
|
||||
: handleComments(endorsedCommentsIds, false)}
|
||||
</>
|
||||
)}
|
||||
{endorsed !== EndorsementStatus.ENDORSED && (
|
||||
<>
|
||||
{handleDefinition(messages.responseCount, unEndorsedCommentsIds.length)}
|
||||
{unEndorsedCommentsIds.length === 0 && <br />}
|
||||
{handleComments(unEndorsedCommentsIds, false)}
|
||||
{(isUserPrivilegedInPostingRestriction && !!unEndorsedCommentsIds.length && !isClosed) && (
|
||||
<div className="mx-4">
|
||||
{!addingResponse && (
|
||||
<Button
|
||||
variant="plain"
|
||||
block="true"
|
||||
className="card mb-4 px-0 border-0 py-10px mt-2 font-style font-weight-500
|
||||
line-height-24 font-size-14 text-primary-500"
|
||||
onClick={handleAddResponse}
|
||||
data-testid="add-response"
|
||||
>
|
||||
{intl.formatMessage(messages.addResponse)}
|
||||
</Button>
|
||||
)}
|
||||
<ResponseEditor
|
||||
addWrappingDiv
|
||||
addingResponse={addingResponse}
|
||||
handleCloseEditor={handleCloseResponseEditor}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{handleDefinition(messages.endorsedResponseCount, endorsedCommentsIds.length)}
|
||||
{handleComments(endorsedCommentsIds)}
|
||||
</>
|
||||
)}
|
||||
{handleDefinition(messages.responseCount, unEndorsedCommentsIds.length)}
|
||||
{unEndorsedCommentsIds.length > 0 && handleComments(unEndorsedCommentsIds)}
|
||||
{hasMorePages && !isLoading && (!!unEndorsedCommentsIds.length || !!endorsedCommentsIds.length) && (
|
||||
<Button
|
||||
onClick={handleLoadMoreResponses}
|
||||
variant="link"
|
||||
block="true"
|
||||
className="px-4 mt-3 border-0 line-height-24 py-0 mb-2 font-style font-weight-500"
|
||||
data-testid="load-more-comments"
|
||||
>
|
||||
{intl.formatMessage(messages.loadMoreResponses)}
|
||||
</Button>
|
||||
)}
|
||||
{isLoading && (
|
||||
<div className="mb-2 mt-3 d-flex justify-content-center">
|
||||
<Spinner animation="border" variant="primary" className="spinner-dimensions" />
|
||||
</div>
|
||||
)}
|
||||
{(isUserPrivilegedInPostingRestriction && (!!unEndorsedCommentsIds.length || !!endorsedCommentsIds.length)
|
||||
&& !isClosed) && (
|
||||
<div className="mx-4">
|
||||
{!addingResponse && (
|
||||
<Button
|
||||
variant="plain"
|
||||
block="true"
|
||||
className="card mb-4 px-0 border-0 py-10px mt-2 font-style font-weight-500
|
||||
line-height-24 text-primary-500 bg-white"
|
||||
onClick={handleAddResponse}
|
||||
data-testid="add-response"
|
||||
>
|
||||
{intl.formatMessage(messages.addResponse)}
|
||||
</Button>
|
||||
)}
|
||||
<ResponseEditor
|
||||
addWrappingDiv
|
||||
addingResponse={addingResponse}
|
||||
handleCloseEditor={handleCloseResponseEditor}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
CommentsView.propTypes = {
|
||||
endorsed: PropTypes.oneOf([
|
||||
EndorsementStatus.ENDORSED, EndorsementStatus.UNENDORSED, EndorsementStatus.DISCUSSION,
|
||||
threadType: PropTypes.oneOf([
|
||||
ThreadType.DISCUSSION, ThreadType.QUESTION,
|
||||
]).isRequired,
|
||||
};
|
||||
|
||||
|
||||
@@ -3,11 +3,11 @@ import React, {
|
||||
} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Button, useToggle } from '@openedx/paragon';
|
||||
import classNames from 'classnames';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Button, useToggle } from '@edx/paragon';
|
||||
|
||||
import HTMLLoader from '../../../../components/HTMLLoader';
|
||||
import { ContentActions, EndorsementStatus } from '../../../../data/constants';
|
||||
@@ -82,7 +82,7 @@ const Comment = ({
|
||||
}, []);
|
||||
|
||||
const handleCommentEndorse = useCallback(async () => {
|
||||
await dispatch(editComment(id, { endorsed: !endorsed }, ContentActions.ENDORSE));
|
||||
await dispatch(editComment(id, { endorsed: !endorsed }));
|
||||
await dispatch(fetchThread(threadId, courseId));
|
||||
}, [id, endorsed, threadId]);
|
||||
|
||||
@@ -104,6 +104,10 @@ const Comment = ({
|
||||
hideReportConfirmation();
|
||||
}, [abuseFlagged, id, hideReportConfirmation]);
|
||||
|
||||
const handleCommentLike = useCallback(async () => {
|
||||
await dispatch(editComment(id, { voted: !voted }));
|
||||
}, [id, voted]);
|
||||
|
||||
const actionHandlers = useMemo(() => ({
|
||||
[ContentActions.EDIT_CONTENT]: handleEditContent,
|
||||
[ContentActions.ENDORSE]: handleCommentEndorse,
|
||||
@@ -124,10 +128,6 @@ const Comment = ({
|
||||
}
|
||||
}, [isUserPrivilegedInPostingRestriction]);
|
||||
|
||||
const handleCommentLike = useCallback(async () => {
|
||||
await dispatch(editComment(id, { voted: !voted }));
|
||||
}, [id, voted]);
|
||||
|
||||
const handleCloseEditor = useCallback(() => {
|
||||
setEditing(false);
|
||||
}, []);
|
||||
@@ -249,7 +249,7 @@ const Comment = ({
|
||||
onClick={handleLoadMoreComments}
|
||||
variant="link"
|
||||
block="true"
|
||||
className="font-size-14 line-height-24 font-style pt-10px border-0 font-weight-500 pb-0"
|
||||
className="line-height-24 font-style pt-10px border-0 font-weight-500 pb-0"
|
||||
data-testid="load-more-comments-responses"
|
||||
>
|
||||
{intl.formatMessage(messages.loadMoreComments)}
|
||||
@@ -267,7 +267,7 @@ const Comment = ({
|
||||
) : (
|
||||
!isClosed && isUserPrivilegedInPostingRestriction && (inlineReplies.length >= 5) && (
|
||||
<Button
|
||||
className="d-flex flex-grow mt-2 font-size-14 font-style font-weight-500 text-primary-500 add-comment-btn rounded-0"
|
||||
className="d-flex flex-grow mt-2 font-style font-weight-500 text-primary-500 add-comment-btn rounded-0"
|
||||
variant="plain"
|
||||
style={{ height: '36px' }}
|
||||
onClick={handleAddCommentReply}
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import React, {
|
||||
useCallback, useContext, useEffect, useRef,
|
||||
useCallback, useContext, useEffect, useRef, useState,
|
||||
} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Button, Form, StatefulButton } from '@openedx/paragon';
|
||||
import { Formik } from 'formik';
|
||||
import ReCAPTCHA from 'react-google-recaptcha';
|
||||
import { useSelector } from 'react-redux';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { AppContext } from '@edx/frontend-platform/react';
|
||||
import { Button, Form, StatefulButton } from '@edx/paragon';
|
||||
|
||||
import { TinyMCEEditor } from '../../../../components';
|
||||
import FormikErrorFeedback from '../../../../components/FormikErrorFeedback';
|
||||
@@ -17,12 +18,15 @@ import PostPreviewPanel from '../../../../components/PostPreviewPanel';
|
||||
import useDispatchWithState from '../../../../data/hooks';
|
||||
import DiscussionContext from '../../../common/context';
|
||||
import {
|
||||
selectCaptchaSettings,
|
||||
selectModerationSettings,
|
||||
selectUserHasModerationPrivileges,
|
||||
selectUserIsGroupTa,
|
||||
selectUserIsStaff,
|
||||
} from '../../../data/selectors';
|
||||
import { formikCompatibleHandler, isFormikFieldInvalid } from '../../../utils';
|
||||
import { extractContent, formikCompatibleHandler, isFormikFieldInvalid } from '../../../utils';
|
||||
import { useDraftContent } from '../../data/hooks';
|
||||
import { setDraftComments, setDraftResponses } from '../../data/slices';
|
||||
import { addComment, editComment } from '../../data/thunks';
|
||||
import messages from '../../messages';
|
||||
|
||||
@@ -38,6 +42,7 @@ const CommentEditor = ({
|
||||
const intl = useIntl();
|
||||
const editorRef = useRef(null);
|
||||
const formRef = useRef(null);
|
||||
const recaptchaRef = useRef(null);
|
||||
const { authenticatedUser } = useContext(AppContext);
|
||||
const { enableInContextSidebar } = useContext(DiscussionContext);
|
||||
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
|
||||
@@ -45,6 +50,15 @@ const CommentEditor = ({
|
||||
const userIsStaff = useSelector(selectUserIsStaff);
|
||||
const { editReasons } = useSelector(selectModerationSettings);
|
||||
const [submitting, dispatch] = useDispatchWithState();
|
||||
const [editorContent, setEditorContent] = useState();
|
||||
const { addDraftContent, getDraftContent, removeDraftContent } = useDraftContent();
|
||||
const captchaSettings = useSelector(selectCaptchaSettings);
|
||||
|
||||
const shouldRequireCaptcha = !id && captchaSettings.enabled;
|
||||
|
||||
const captchaValidation = {
|
||||
recaptchaToken: Yup.string().required(intl.formatMessage(messages.captchaVerificationLabel)),
|
||||
};
|
||||
|
||||
const canDisplayEditReason = (edit
|
||||
&& (userHasModerationPrivileges || userIsGroupTa || userIsStaff)
|
||||
@@ -58,19 +72,43 @@ const CommentEditor = ({
|
||||
const validationSchema = Yup.object().shape({
|
||||
comment: Yup.string()
|
||||
.required(),
|
||||
...(shouldRequireCaptcha ? { recaptchaToken: Yup.string().required() } : { }),
|
||||
...editReasonCodeValidation,
|
||||
...(shouldRequireCaptcha ? captchaValidation : {}),
|
||||
});
|
||||
|
||||
const initialValues = {
|
||||
comment: rawBody,
|
||||
comment: editorContent,
|
||||
editReasonCode: lastEdit?.reasonCode || (userIsStaff && canDisplayEditReason ? 'violates-guidelines' : undefined),
|
||||
recaptchaToken: '',
|
||||
};
|
||||
|
||||
const handleCaptchaChange = useCallback((token, setFieldValue) => {
|
||||
setFieldValue('recaptchaToken', token || '');
|
||||
}, []);
|
||||
|
||||
const handleCaptchaExpired = useCallback((setFieldValue) => {
|
||||
setFieldValue('recaptchaToken', '');
|
||||
}, []);
|
||||
|
||||
const handleCloseEditor = useCallback((resetForm) => {
|
||||
resetForm({ values: initialValues });
|
||||
// Reset CAPTCHA when hiding editor
|
||||
if (recaptchaRef.current) {
|
||||
recaptchaRef.current.reset();
|
||||
}
|
||||
onCloseEditor();
|
||||
}, [onCloseEditor, initialValues]);
|
||||
|
||||
const deleteEditorContent = useCallback(async () => {
|
||||
const { updatedResponses, updatedComments } = removeDraftContent(parentId, id, threadId);
|
||||
if (parentId) {
|
||||
await dispatch(setDraftComments(updatedComments));
|
||||
} else {
|
||||
await dispatch(setDraftResponses(updatedResponses));
|
||||
}
|
||||
}, [parentId, id, threadId, setDraftComments, setDraftResponses]);
|
||||
|
||||
const saveUpdatedComment = useCallback(async (values, { resetForm }) => {
|
||||
if (id) {
|
||||
const payload = {
|
||||
@@ -79,14 +117,15 @@ const CommentEditor = ({
|
||||
};
|
||||
await dispatch(editComment(id, payload));
|
||||
} else {
|
||||
await dispatch(addComment(values.comment, threadId, parentId, enableInContextSidebar));
|
||||
await dispatch(addComment(values.comment, threadId, parentId, enableInContextSidebar, shouldRequireCaptcha ? values.recaptchaToken : ''));
|
||||
}
|
||||
/* istanbul ignore if: TinyMCE is mocked so this cannot be easily tested */
|
||||
if (editorRef.current) {
|
||||
editorRef.current.plugins.autosave.removeDraft();
|
||||
}
|
||||
handleCloseEditor(resetForm);
|
||||
}, [id, threadId, parentId, enableInContextSidebar, handleCloseEditor]);
|
||||
deleteEditorContent();
|
||||
}, [id, threadId, parentId, enableInContextSidebar, handleCloseEditor, shouldRequireCaptcha]);
|
||||
// The editorId is used to autosave contents to localstorage. This format means that the autosave is scoped to
|
||||
// the current comment id, or the current comment parent or the curren thread.
|
||||
const editorId = `comment-editor-${id || parentId || threadId}`;
|
||||
@@ -97,11 +136,33 @@ const CommentEditor = ({
|
||||
}
|
||||
}, [formRef]);
|
||||
|
||||
useEffect(() => {
|
||||
const draftHtml = getDraftContent(parentId, threadId, id) || rawBody;
|
||||
setEditorContent(draftHtml);
|
||||
}, [parentId, threadId, id]);
|
||||
|
||||
const saveDraftContent = async (content) => {
|
||||
const draftDataContent = extractContent(content);
|
||||
|
||||
const { updatedResponses, updatedComments } = addDraftContent(
|
||||
draftDataContent,
|
||||
parentId,
|
||||
id,
|
||||
threadId,
|
||||
);
|
||||
if (parentId) {
|
||||
await dispatch(setDraftComments(updatedComments));
|
||||
} else {
|
||||
await dispatch(setDraftResponses(updatedResponses));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Formik
|
||||
initialValues={initialValues}
|
||||
validationSchema={validationSchema}
|
||||
onSubmit={saveUpdatedComment}
|
||||
enableReinitialize
|
||||
>
|
||||
{({
|
||||
values,
|
||||
@@ -111,6 +172,7 @@ const CommentEditor = ({
|
||||
handleBlur,
|
||||
handleChange,
|
||||
resetForm,
|
||||
setFieldValue,
|
||||
}) => (
|
||||
<Form onSubmit={handleSubmit} className={formClasses} ref={formRef}>
|
||||
{canDisplayEditReason && (
|
||||
@@ -151,7 +213,10 @@ const CommentEditor = ({
|
||||
id={editorId}
|
||||
value={values.comment}
|
||||
onEditorChange={formikCompatibleHandler(handleChange, 'comment')}
|
||||
onBlur={formikCompatibleHandler(handleBlur, 'comment')}
|
||||
onBlur={(content) => {
|
||||
formikCompatibleHandler(handleChange, 'comment');
|
||||
saveDraftContent(content);
|
||||
}}
|
||||
/>
|
||||
{isFormikFieldInvalid('comment', {
|
||||
errors,
|
||||
@@ -163,6 +228,32 @@ const CommentEditor = ({
|
||||
</Form.Control.Feedback>
|
||||
)}
|
||||
<PostPreviewPanel htmlNode={values.comment} />
|
||||
{/* CAPTCHA Section - Only show for new posts from non-staff users */}
|
||||
{ shouldRequireCaptcha && captchaSettings.siteKey && (
|
||||
<div className="mb-3">
|
||||
<Form.Group
|
||||
isInvalid={isFormikFieldInvalid('recaptchaToken', {
|
||||
errors,
|
||||
touched,
|
||||
})}
|
||||
>
|
||||
<Form.Label className="h6">
|
||||
{intl.formatMessage(messages.verifyHumanLabel)}
|
||||
</Form.Label>
|
||||
<div className="d-flex justify-content-start">
|
||||
<ReCAPTCHA
|
||||
ref={recaptchaRef}
|
||||
sitekey={captchaSettings.siteKey}
|
||||
onChange={(token) => handleCaptchaChange(token, setFieldValue)}
|
||||
onExpired={() => handleCaptchaExpired(setFieldValue)}
|
||||
onError={() => handleCaptchaExpired(setFieldValue)}
|
||||
/>
|
||||
</div>
|
||||
<FormikErrorFeedback name="recaptchaToken" />
|
||||
</Form.Group>
|
||||
</div>
|
||||
) }
|
||||
|
||||
<div className="d-flex py-2 justify-content-end">
|
||||
<Button
|
||||
variant="outline-primary"
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Avatar } from '@openedx/paragon';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { Avatar } from '@edx/paragon';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { AvatarOutlineAndLabelColors } from '../../../../data/constants';
|
||||
import { AuthorLabel } from '../../../common';
|
||||
import { useAlertBannerVisible } from '../../../data/hooks';
|
||||
import { selectAuthorAvatar } from '../../../posts/data/selectors';
|
||||
|
||||
const CommentHeader = ({
|
||||
author,
|
||||
@@ -24,6 +25,7 @@ const CommentHeader = ({
|
||||
lastEdit,
|
||||
closed,
|
||||
});
|
||||
const authorAvatar = useSelector(selectAuthorAvatar(author));
|
||||
|
||||
return (
|
||||
<div className={classNames('d-flex flex-row justify-content-between', {
|
||||
@@ -34,6 +36,7 @@ const CommentHeader = ({
|
||||
<Avatar
|
||||
className={`border-0 ml-0.5 mr-2.5 ${colorClass ? `outline-${colorClass}` : 'outline-anonymous'}`}
|
||||
alt={author}
|
||||
src={authorAvatar?.imageUrlSmall}
|
||||
style={{
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Avatar, useToggle } from '@openedx/paragon';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import * as timeago from 'timeago.js';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Avatar, useToggle } from '@edx/paragon';
|
||||
|
||||
import HTMLLoader from '../../../../components/HTMLLoader';
|
||||
import { AvatarOutlineAndLabelColors, ContentActions } from '../../../../data/constants';
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import timeLocale from '../../../common/time-locale';
|
||||
import { ContentTypes } from '../../../data/constants';
|
||||
import { useAlertBannerVisible } from '../../../data/hooks';
|
||||
import { selectAuthorAvatar } from '../../../posts/data/selectors';
|
||||
import { selectCommentOrResponseById } from '../../data/selectors';
|
||||
import { editComment, removeComment } from '../../data/thunks';
|
||||
import messages from '../../messages';
|
||||
@@ -38,6 +39,7 @@ const Reply = ({ responseId }) => {
|
||||
lastEdit,
|
||||
closed,
|
||||
});
|
||||
const authorAvatar = useSelector(selectAuthorAvatar(author));
|
||||
|
||||
const handleDeleteConfirmation = useCallback(() => {
|
||||
dispatch(removeComment(id));
|
||||
@@ -54,7 +56,7 @@ const Reply = ({ responseId }) => {
|
||||
}, []);
|
||||
|
||||
const handleReplyEndorse = useCallback(() => {
|
||||
dispatch(editComment(id, { endorsed: !endorsed }, ContentActions.ENDORSE));
|
||||
dispatch(editComment(id, { endorsed: !endorsed }));
|
||||
}, [endorsed, id]);
|
||||
|
||||
const handleAbusedFlag = useCallback(() => {
|
||||
@@ -121,6 +123,7 @@ const Reply = ({ responseId }) => {
|
||||
<Avatar
|
||||
className={`ml-0.5 mt-0.5 border-0 ${colorClass ? `outline-${colorClass}` : 'outline-anonymous'}`}
|
||||
alt={author}
|
||||
src={authorAvatar?.imageUrlSmall}
|
||||
style={{
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
@@ -129,9 +132,9 @@ const Reply = ({ responseId }) => {
|
||||
</div>
|
||||
<div
|
||||
className="bg-light-300 pl-4 pt-2.5 pr-2.5 pb-10px flex-fill"
|
||||
style={{ borderRadius: '0rem 0.375rem 0.375rem' }}
|
||||
style={{ borderRadius: '0rem 0.375rem 0.375rem', maxWidth: 'calc(100% - 50px)' }}
|
||||
>
|
||||
<div className="d-flex flex-row justify-content-between" style={{ height: '24px' }}>
|
||||
<div className="d-flex flex-row justify-content-between">
|
||||
<AuthorLabel
|
||||
author={author}
|
||||
authorLabel={authorLabel}
|
||||
|
||||
@@ -6,7 +6,7 @@ Factory.define('comment')
|
||||
.sequence('rendered_body', ['endorsed'], (idx, endorsed) => `Some contents for <b>${endorsed ? 'endorsed ' : 'unendorsed '}comment number ${idx}</b>.`)
|
||||
.attr('thread_id', null, 'test-thread')
|
||||
.option('endorsedBy', null, null)
|
||||
.attr('endorsed', ['endorsedBy'], (endorsedBy) => !!endorsedBy)
|
||||
.attr('endorsed', ['endorsed'], (endorsed) => endorsed)
|
||||
.attr('endorsed_by', ['endorsedBy'], (endorsedBy) => endorsedBy)
|
||||
.attr('endorsed_by_label', ['endorsedBy'], (endorsedBy) => (endorsedBy ? 'Staff' : null))
|
||||
.attr('endorsed_at', ['endorsedBy'], (endorsedBy) => (endorsedBy ? (new Date()).toISOString() : null))
|
||||
@@ -38,7 +38,7 @@ Factory.define('commentsResult')
|
||||
.option('pageSize', null, 5)
|
||||
.option('threadId', null, 'test-thread')
|
||||
.option('parentId', null, null)
|
||||
.option('endorsed', null, null)
|
||||
.option('endorsed', false, false)
|
||||
.option('childCount', null, 0)
|
||||
.attr('pagination', ['threadId', 'count', 'page', 'pageSize'], (threadId, count, page, pageSize) => {
|
||||
const numPages = Math.ceil(count / pageSize);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ensureConfig, getConfig, snakeCaseObject } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
import { EndorsementValue } from '../../../data/constants';
|
||||
import { ThreadType } from '../../../data/constants';
|
||||
|
||||
ensureConfig([
|
||||
'LMS_BASE_URL',
|
||||
@@ -20,7 +20,7 @@ export const getCommentsApiUrl = () => `${getConfig().LMS_BASE_URL}/api/discussi
|
||||
* @returns {Promise<{}>}
|
||||
*/
|
||||
export const getThreadComments = async (threadId, {
|
||||
endorsed,
|
||||
threadType,
|
||||
page,
|
||||
pageSize,
|
||||
reverseOrder,
|
||||
@@ -29,12 +29,12 @@ export const getThreadComments = async (threadId, {
|
||||
} = {}) => {
|
||||
const params = snakeCaseObject({
|
||||
threadId,
|
||||
endorsed: EndorsementValue[endorsed],
|
||||
page,
|
||||
pageSize,
|
||||
reverseOrder,
|
||||
requestedFields: 'profile_image',
|
||||
enableInContextSidebar,
|
||||
mergeQuestionTypeResponses: threadType === ThreadType.QUESTION ? true : null,
|
||||
});
|
||||
|
||||
const { data } = await getAuthenticatedHttpClient().get(getCommentsApiUrl(), { params: { ...params, signal } });
|
||||
@@ -73,10 +73,16 @@ export const getCommentResponses = async (commentId, {
|
||||
* @param {boolean} enableInContextSidebar
|
||||
* @returns {Promise<{}>}
|
||||
*/
|
||||
export const postComment = async (comment, threadId, parentId = null, enableInContextSidebar = false) => {
|
||||
export const postComment = async (
|
||||
comment,
|
||||
threadId,
|
||||
parentId,
|
||||
enableInContextSidebar,
|
||||
recaptchaToken,
|
||||
) => {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getCommentsApiUrl(), snakeCaseObject({
|
||||
threadId, raw_body: comment, parentId, enableInContextSidebar,
|
||||
threadId, raw_body: comment, parentId, enableInContextSidebar, captchaToken: recaptchaToken,
|
||||
}));
|
||||
return data;
|
||||
};
|
||||
|
||||
@@ -39,7 +39,7 @@ describe('Post comments view api tests', () => {
|
||||
axiosMock.reset();
|
||||
});
|
||||
|
||||
test('successfully get thread comments', async () => {
|
||||
it('successfully get thread comments', async () => {
|
||||
axiosMock.onGet(commentsApiUrl).reply(200, Factory.build('commentsResult'));
|
||||
await executeThunk(fetchThreadComments(threadId, { endorsed: 'discussion' }), store.dispatch, store.getState);
|
||||
|
||||
@@ -92,22 +92,25 @@ describe('Post comments view api tests', () => {
|
||||
thread_id: threadId,
|
||||
raw_body: content,
|
||||
rendered_body: content,
|
||||
parent_id: 'parent_id',
|
||||
enable_in_context_sidebar: true,
|
||||
captcha_token: 'recaptcha-token',
|
||||
}));
|
||||
await executeThunk(addComment(content, threadId, null), store.dispatch, store.getState);
|
||||
await executeThunk(addComment(content, threadId, 'parent_id', true, 'recaptcha-token'), store.dispatch, store.getState);
|
||||
|
||||
expect(store.getState().comments.postStatus).toEqual('successful');
|
||||
});
|
||||
|
||||
it('failed to add comment', async () => {
|
||||
axiosMock.onPost(commentsApiUrl).reply(404);
|
||||
await executeThunk(addComment(content, threadId, null), store.dispatch, store.getState);
|
||||
await executeThunk(addComment(content, threadId, 'parent_id', false, 'recaptcha-token'), store.dispatch, store.getState);
|
||||
|
||||
expect(store.getState().comments.postStatus).toEqual('failed');
|
||||
});
|
||||
|
||||
it('denied to add comment', async () => {
|
||||
axiosMock.onPost(commentsApiUrl).reply(403, {});
|
||||
await executeThunk(addComment(content, threadId, null), store.dispatch, store.getState);
|
||||
await executeThunk(addComment(content, threadId, 'parent_id', false, 'recaptcha-token'), store.dispatch, store.getState);
|
||||
|
||||
expect(store.getState().comments.postStatus).toEqual('denied');
|
||||
});
|
||||
@@ -164,4 +167,76 @@ describe('Post comments view api tests', () => {
|
||||
|
||||
expect(store.getState().comments.postStatus).toEqual('denied');
|
||||
});
|
||||
|
||||
it('successfully added comment with default parentId', async () => {
|
||||
axiosMock.onGet(commentsApiUrl).reply(200, Factory.build('commentsResult'));
|
||||
await executeThunk(fetchThreadComments(threadId), store.dispatch, store.getState);
|
||||
|
||||
axiosMock.onPost(commentsApiUrl).reply(200, Factory.build('comment', {
|
||||
thread_id: threadId,
|
||||
raw_body: content,
|
||||
rendered_body: content,
|
||||
parent_id: null, // Explicitly expect null in response
|
||||
}));
|
||||
await executeThunk(addComment(content, threadId, null, false, ''), store.dispatch, store.getState);
|
||||
|
||||
expect(store.getState().comments.postStatus).toEqual('successful');
|
||||
});
|
||||
|
||||
it('successfully added comment with explicit enableInContextSidebar false', async () => {
|
||||
axiosMock.onGet(commentsApiUrl).reply(200, Factory.build('commentsResult'));
|
||||
await executeThunk(fetchThreadComments(threadId), store.dispatch, store.getState);
|
||||
|
||||
axiosMock.onPost(commentsApiUrl).reply(200, Factory.build('comment', {
|
||||
thread_id: threadId,
|
||||
raw_body: content,
|
||||
rendered_body: content,
|
||||
}));
|
||||
await executeThunk(addComment(content, threadId, null, false, 'recaptcha-token'), store.dispatch, store.getState);
|
||||
|
||||
expect(store.getState().comments.postStatus).toEqual('successful');
|
||||
});
|
||||
|
||||
it('successfully added comment with empty recaptchaToken', async () => {
|
||||
axiosMock.onGet(commentsApiUrl).reply(200, Factory.build('commentsResult'));
|
||||
await executeThunk(fetchThreadComments(threadId), store.dispatch, store.getState);
|
||||
|
||||
axiosMock.onPost(commentsApiUrl).reply(200, Factory.build('comment', {
|
||||
thread_id: threadId,
|
||||
raw_body: content,
|
||||
rendered_body: content,
|
||||
}));
|
||||
await executeThunk(addComment(content, threadId, null, true, ''), store.dispatch, store.getState);
|
||||
|
||||
expect(store.getState().comments.postStatus).toEqual('successful');
|
||||
});
|
||||
|
||||
it('successfully added comment with undefined parentId', async () => {
|
||||
axiosMock.onGet(commentsApiUrl).reply(200, Factory.build('commentsResult'));
|
||||
await executeThunk(fetchThreadComments(threadId), store.dispatch, store.getState);
|
||||
|
||||
axiosMock.onPost(commentsApiUrl).reply(200, Factory.build('comment', {
|
||||
thread_id: threadId,
|
||||
raw_body: content,
|
||||
rendered_body: content,
|
||||
parent_id: null, // Expect null as the function should handle undefined as null
|
||||
}));
|
||||
await executeThunk(addComment(content, threadId, undefined, false, ''), store.dispatch, store.getState);
|
||||
|
||||
expect(store.getState().comments.postStatus).toEqual('successful');
|
||||
});
|
||||
|
||||
it('successfully added comment with null recaptchaToken', async () => {
|
||||
axiosMock.onGet(commentsApiUrl).reply(200, Factory.build('commentsResult'));
|
||||
await executeThunk(fetchThreadComments(threadId), store.dispatch, store.getState);
|
||||
|
||||
axiosMock.onPost(commentsApiUrl).reply(200, Factory.build('comment', {
|
||||
thread_id: threadId,
|
||||
raw_body: content,
|
||||
rendered_body: content,
|
||||
}));
|
||||
await executeThunk(addComment(content, threadId, null, false, null), store.dispatch, store.getState);
|
||||
|
||||
expect(store.getState().comments.postStatus).toEqual('successful');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
} from 'react';
|
||||
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
|
||||
@@ -13,7 +14,8 @@ import { selectThread } from '../../posts/data/selectors';
|
||||
import { markThreadAsRead } from '../../posts/data/thunks';
|
||||
import { filterPosts } from '../../utils';
|
||||
import {
|
||||
selectCommentSortOrder, selectThreadComments, selectThreadCurrentPage, selectThreadHasMorePages,
|
||||
selectCommentSortOrder, selectDraftComments, selectDraftResponses,
|
||||
selectThreadComments, selectThreadCurrentPage, selectThreadHasMorePages,
|
||||
} from './selectors';
|
||||
import { fetchThreadComments } from './thunks';
|
||||
|
||||
@@ -40,13 +42,13 @@ export function usePost(postId) {
|
||||
return thread || {};
|
||||
}
|
||||
|
||||
export function usePostComments(endorsed = null) {
|
||||
export function usePostComments(threadType) {
|
||||
const { enableInContextSidebar, postId } = useContext(DiscussionContext);
|
||||
const [isLoading, dispatch] = useDispatchWithState();
|
||||
const comments = useSelector(selectThreadComments(postId, endorsed));
|
||||
const comments = useSelector(selectThreadComments(postId));
|
||||
const reverseOrder = useSelector(selectCommentSortOrder);
|
||||
const hasMorePages = useSelector(selectThreadHasMorePages(postId, endorsed));
|
||||
const currentPage = useSelector(selectThreadCurrentPage(postId, endorsed));
|
||||
const hasMorePages = useSelector(selectThreadHasMorePages(postId));
|
||||
const currentPage = useSelector(selectThreadCurrentPage(postId));
|
||||
|
||||
const endorsedCommentsIds = useMemo(() => (
|
||||
[...filterPosts(comments, 'endorsed')].map(comment => comment.id)
|
||||
@@ -58,19 +60,19 @@ export function usePostComments(endorsed = null) {
|
||||
|
||||
const handleLoadMoreResponses = useCallback(async () => {
|
||||
const params = {
|
||||
endorsed,
|
||||
threadType,
|
||||
page: currentPage + 1,
|
||||
reverseOrder,
|
||||
};
|
||||
await dispatch(fetchThreadComments(postId, params));
|
||||
trackLoadMoreEvent(postId, params);
|
||||
}, [currentPage, endorsed, postId, reverseOrder]);
|
||||
}, [currentPage, threadType, postId, reverseOrder]);
|
||||
|
||||
useEffect(() => {
|
||||
const abortController = new AbortController();
|
||||
|
||||
dispatch(fetchThreadComments(postId, {
|
||||
endorsed,
|
||||
threadType,
|
||||
page: 1,
|
||||
reverseOrder,
|
||||
enableInContextSidebar,
|
||||
@@ -80,7 +82,7 @@ export function usePostComments(endorsed = null) {
|
||||
return () => {
|
||||
abortController.abort();
|
||||
};
|
||||
}, [postId, endorsed, reverseOrder, enableInContextSidebar]);
|
||||
}, [postId, threadType, reverseOrder, enableInContextSidebar]);
|
||||
|
||||
return {
|
||||
endorsedCommentsIds,
|
||||
@@ -102,3 +104,73 @@ export function useCommentsCount(postId) {
|
||||
|
||||
return commentsLength;
|
||||
}
|
||||
|
||||
export const useDraftContent = () => {
|
||||
const comments = useSelector(selectDraftComments);
|
||||
const responses = useSelector(selectDraftResponses);
|
||||
|
||||
const getObjectByParentId = (data, parentId, isComment, id) => Object.values(data)
|
||||
.find(draft => (isComment ? draft.parentId === parentId && (id ? draft.id === id : draft.isNewContent === true)
|
||||
: draft.threadId === parentId && (id ? draft.id === id : draft.isNewContent === true)));
|
||||
|
||||
const updateDraftData = (draftData, newDraftObject) => ({
|
||||
...draftData,
|
||||
[newDraftObject.id]: newDraftObject,
|
||||
});
|
||||
|
||||
const addDraftContent = (content, parentId, id, threadId) => {
|
||||
const data = parentId ? comments : responses;
|
||||
const draftParentId = parentId || threadId;
|
||||
const isComment = !!parentId;
|
||||
const existingObj = getObjectByParentId(data, draftParentId, isComment, id);
|
||||
const newObject = existingObj
|
||||
? { ...existingObj, content }
|
||||
: {
|
||||
threadId,
|
||||
content,
|
||||
parentId,
|
||||
id: id || uuidv4(),
|
||||
isNewContent: !id,
|
||||
};
|
||||
|
||||
const updatedComments = parentId ? updateDraftData(comments, newObject) : comments;
|
||||
const updatedResponses = !parentId ? updateDraftData(responses, newObject) : responses;
|
||||
|
||||
return { updatedComments, updatedResponses };
|
||||
};
|
||||
|
||||
const getDraftContent = (parentId, threadId, id) => {
|
||||
if (id) {
|
||||
return parentId ? comments?.[id]?.content : responses?.[id]?.content;
|
||||
}
|
||||
|
||||
const data = parentId ? comments : responses;
|
||||
const draftParentId = parentId || threadId;
|
||||
const isComment = !!parentId;
|
||||
|
||||
return getObjectByParentId(data, draftParentId, isComment, id)?.content;
|
||||
};
|
||||
|
||||
const removeItem = (draftData, objId) => {
|
||||
const { [objId]: _, ...newDraftData } = draftData;
|
||||
return newDraftData;
|
||||
};
|
||||
|
||||
const updateContent = (items, itemId, parentId, isComment) => {
|
||||
const itemObj = itemId ? items[itemId] : getObjectByParentId(items, parentId, isComment, itemId);
|
||||
return itemObj ? removeItem(items, itemObj.id) : items;
|
||||
};
|
||||
|
||||
const removeDraftContent = (parentId, id, threadId) => {
|
||||
const updatedResponses = !parentId ? updateContent(responses, id, threadId, false) : responses;
|
||||
const updatedComments = parentId ? updateContent(comments, id, parentId, true) : comments;
|
||||
|
||||
return { updatedResponses, updatedComments };
|
||||
};
|
||||
|
||||
return {
|
||||
addDraftContent,
|
||||
getDraftContent,
|
||||
removeDraftContent,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Factory } from 'rosie';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { initializeMockApp } from '@edx/frontend-platform/testing';
|
||||
|
||||
import { EndorsementStatus } from '../../../data/constants';
|
||||
import { ThreadType } from '../../../data/constants';
|
||||
import { initializeStore } from '../../../store';
|
||||
import executeThunk from '../../../test-utils';
|
||||
import { getCommentsApiUrl } from './api';
|
||||
@@ -39,37 +39,23 @@ describe('Comments/Responses data layer tests', () => {
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
threadType: 'discussion',
|
||||
endorsed: EndorsementStatus.DISCUSSION,
|
||||
},
|
||||
{
|
||||
threadType: 'question',
|
||||
endorsed: EndorsementStatus.UNENDORSED,
|
||||
},
|
||||
{
|
||||
threadType: 'question',
|
||||
endorsed: EndorsementStatus.ENDORSED,
|
||||
},
|
||||
])('successfully processes comments for \'$threadType\' thread with endorsed=$endorsed', async ({
|
||||
endorsed,
|
||||
}) => {
|
||||
ThreadType.DISCUSSION,
|
||||
ThreadType.QUESTION,
|
||||
])('successfully processes comments for %s type thread', async (threadType) => {
|
||||
const threadId = 'test-thread';
|
||||
axiosMock.onGet(commentsApiUrl)
|
||||
.reply(200, Factory.build('commentsResult'));
|
||||
|
||||
await executeThunk(fetchThreadComments(threadId, { endorsed }), store.dispatch, store.getState);
|
||||
await executeThunk(fetchThreadComments(threadId, { threadType }), store.dispatch, store.getState);
|
||||
|
||||
expect(store.getState().comments.commentsInThreads)
|
||||
.toEqual({ 'test-thread': { [endorsed]: ['comment-1', 'comment-2', 'comment-3'] } });
|
||||
.toEqual({ 'test-thread': ['comment-1', 'comment-2', 'comment-3'] });
|
||||
expect(store.getState().comments.pagination)
|
||||
.toEqual({
|
||||
'test-thread': {
|
||||
[endorsed]: {
|
||||
currentPage: 1,
|
||||
totalPages: 1,
|
||||
hasMorePages: false,
|
||||
},
|
||||
currentPage: 1,
|
||||
totalPages: 1,
|
||||
hasMorePages: false,
|
||||
},
|
||||
});
|
||||
expect(Object.keys(store.getState().comments.commentsById))
|
||||
@@ -82,7 +68,7 @@ describe('Comments/Responses data layer tests', () => {
|
||||
.toEqual('test-thread');
|
||||
});
|
||||
|
||||
test('successfully processes comment responses', async () => {
|
||||
test('successfully processes comment replies', async () => {
|
||||
const threadId = 'test-thread';
|
||||
const commentId = 'comment-1';
|
||||
axiosMock.onGet(commentsApiUrl)
|
||||
@@ -101,7 +87,7 @@ describe('Comments/Responses data layer tests', () => {
|
||||
.toEqual({ 'comment-1': ['comment-4', 'comment-5', 'comment-6'] });
|
||||
});
|
||||
|
||||
test('successfully handles response creation for discussion type threads', async () => {
|
||||
test('successfully handles comment creation for threads', async () => {
|
||||
const threadId = 'test-thread';
|
||||
const content = 'Test comment';
|
||||
axiosMock.onGet(commentsApiUrl)
|
||||
@@ -119,21 +105,19 @@ describe('Comments/Responses data layer tests', () => {
|
||||
await executeThunk(addComment(content, threadId, null), store.dispatch, store.getState);
|
||||
|
||||
expect(store.getState().comments.commentsInThreads[threadId])
|
||||
.toEqual({
|
||||
[EndorsementStatus.DISCUSSION]: [
|
||||
'comment-1',
|
||||
'comment-2',
|
||||
'comment-3',
|
||||
'comment-4',
|
||||
],
|
||||
});
|
||||
.toEqual([
|
||||
'comment-1',
|
||||
'comment-2',
|
||||
'comment-3',
|
||||
'comment-4',
|
||||
]);
|
||||
expect(Object.keys(store.getState().comments.commentsById))
|
||||
.toEqual(['comment-1', 'comment-2', 'comment-3', 'comment-4']);
|
||||
expect(store.getState().comments.commentsById['comment-4'].threadId)
|
||||
.toEqual(threadId);
|
||||
});
|
||||
|
||||
test('successfully handles reply creation for discussion type threads', async () => {
|
||||
test('successfully handles reply creation for threads', async () => {
|
||||
const threadId = 'test-thread';
|
||||
const parentId = 'comment-1';
|
||||
const content = 'Test comment';
|
||||
@@ -156,13 +140,11 @@ describe('Comments/Responses data layer tests', () => {
|
||||
await executeThunk(addComment(content, threadId, null), store.dispatch, store.getState);
|
||||
|
||||
expect(store.getState().comments.commentsInThreads[threadId])
|
||||
.toEqual({
|
||||
[EndorsementStatus.DISCUSSION]: [
|
||||
'comment-1',
|
||||
'comment-2',
|
||||
'comment-3',
|
||||
],
|
||||
});
|
||||
.toEqual([
|
||||
'comment-1',
|
||||
'comment-2',
|
||||
'comment-3',
|
||||
]);
|
||||
expect(Object.keys(store.getState().comments.commentsById))
|
||||
.toEqual(['comment-1', 'comment-2', 'comment-3', 'comment-4']);
|
||||
expect(store.getState().comments.commentsInComments[parentId])
|
||||
@@ -173,54 +155,6 @@ describe('Comments/Responses data layer tests', () => {
|
||||
.toEqual(parentId);
|
||||
});
|
||||
|
||||
test('successfully handles comment creation for question type threads', async () => {
|
||||
const threadId = 'test-thread';
|
||||
const content = 'Test comment';
|
||||
axiosMock.onGet(commentsApiUrl)
|
||||
.reply(200, Factory.build('commentsResult', null, { endorsed: false }));
|
||||
await executeThunk(
|
||||
fetchThreadComments(threadId, { endorsed: EndorsementStatus.UNENDORSED }),
|
||||
store.dispatch,
|
||||
store.getState,
|
||||
);
|
||||
axiosMock.onGet(commentsApiUrl)
|
||||
.reply(200, Factory.build('commentsResult', null, { endorsed: true }));
|
||||
await executeThunk(
|
||||
fetchThreadComments(threadId, { endorsed: EndorsementStatus.ENDORSED }),
|
||||
store.dispatch,
|
||||
store.getState,
|
||||
);
|
||||
|
||||
axiosMock.onPost(commentsApiUrl)
|
||||
.reply(200, Factory.build('comment', {
|
||||
thread_id: threadId,
|
||||
raw_body: content,
|
||||
rendered_body: content,
|
||||
}));
|
||||
|
||||
await executeThunk(addComment(content, threadId, null), store.dispatch, store.getState);
|
||||
|
||||
expect(store.getState().comments.commentsInThreads[threadId])
|
||||
.toEqual({
|
||||
[EndorsementStatus.UNENDORSED]: [
|
||||
'comment-1',
|
||||
'comment-2',
|
||||
'comment-3',
|
||||
// Newly-added comment
|
||||
'comment-7',
|
||||
],
|
||||
[EndorsementStatus.ENDORSED]: [
|
||||
'comment-4',
|
||||
'comment-5',
|
||||
'comment-6',
|
||||
],
|
||||
});
|
||||
expect(Object.keys(store.getState().comments.commentsById))
|
||||
.toEqual(['comment-1', 'comment-2', 'comment-3', 'comment-4', 'comment-5', 'comment-6', 'comment-7']);
|
||||
expect(store.getState().comments.commentsById['comment-7'].threadId)
|
||||
.toEqual(threadId);
|
||||
});
|
||||
|
||||
test('successfully handles comment edits', async () => {
|
||||
const threadId = 'test-thread';
|
||||
const commentId = 'comment-1';
|
||||
@@ -271,7 +205,7 @@ describe('Comments/Responses data layer tests', () => {
|
||||
.toContain(commentId);
|
||||
});
|
||||
|
||||
test('correctly handles comment responses pagination after posting a new response', async () => {
|
||||
test('correctly handles comment replies pagination after posting a new reply', async () => {
|
||||
const threadId = 'test-thread';
|
||||
const commentId = 'comment-1';
|
||||
|
||||
@@ -327,15 +261,9 @@ describe('Comments/Responses data layer tests', () => {
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
threadType: 'discussion',
|
||||
endorsed: EndorsementStatus.DISCUSSION,
|
||||
},
|
||||
{
|
||||
threadType: 'unendorsed',
|
||||
endorsed: EndorsementStatus.UNENDORSED,
|
||||
},
|
||||
])('correctly handles `$threadType` thread comments pagination after posting a new comment', async ({ endorsed }) => {
|
||||
ThreadType.DISCUSSION,
|
||||
ThreadType.QUESTION,
|
||||
])('correctly handles %s thread comments pagination after posting a new comment', async (threadType) => {
|
||||
const threadId = 'test-thread';
|
||||
|
||||
// Build all comments first, so we can paginate over them and they
|
||||
@@ -348,7 +276,7 @@ describe('Comments/Responses data layer tests', () => {
|
||||
results: allComments.slice(0, 3),
|
||||
pagination: { count: 4, numPages: 2 },
|
||||
});
|
||||
await executeThunk(fetchThreadComments(threadId, { endorsed }), store.dispatch, store.getState);
|
||||
await executeThunk(fetchThreadComments(threadId, { threadType }), store.dispatch, store.getState);
|
||||
|
||||
// Post new comment
|
||||
const comment = Factory.build('comment', { thread_id: threadId });
|
||||
@@ -365,10 +293,10 @@ describe('Comments/Responses data layer tests', () => {
|
||||
results: allComments.slice(3, 6),
|
||||
pagination: { count: 6, numPages: 2 },
|
||||
});
|
||||
await executeThunk(fetchThreadComments(threadId, { page: 2, endorsed }), store.dispatch, store.getState);
|
||||
await executeThunk(fetchThreadComments(threadId, { page: 2, threadType }), store.dispatch, store.getState);
|
||||
|
||||
// sorting is implemented on backend
|
||||
expect(store.getState().comments.commentsInThreads[threadId][endorsed])
|
||||
expect(store.getState().comments.commentsInThreads[threadId])
|
||||
.toEqual([
|
||||
'comment-1',
|
||||
'comment-2',
|
||||
|
||||
@@ -8,9 +8,9 @@ export const selectCommentOrResponseById = commentOrResponseId => createSelector
|
||||
comments => comments[commentOrResponseId],
|
||||
);
|
||||
|
||||
export const selectThreadComments = (threadId, endorsed = null) => createSelector(
|
||||
export const selectThreadComments = (threadId) => createSelector(
|
||||
[
|
||||
state => state.comments.commentsInThreads[threadId]?.[endorsed] || [],
|
||||
state => state.comments.commentsInThreads[threadId] || [],
|
||||
selectCommentsById,
|
||||
],
|
||||
mapIdToComment,
|
||||
@@ -28,12 +28,12 @@ export const selectCommentResponses = commentId => createSelector(
|
||||
mapIdToComment,
|
||||
);
|
||||
|
||||
export const selectThreadHasMorePages = (threadId, endorsed = null) => (
|
||||
state => state.comments.pagination[threadId]?.[endorsed]?.hasMorePages || false
|
||||
export const selectThreadHasMorePages = (threadId) => (
|
||||
state => state.comments.pagination[threadId]?.hasMorePages || false
|
||||
);
|
||||
|
||||
export const selectThreadCurrentPage = (threadId, endorsed = null) => (
|
||||
state => state.comments.pagination[threadId]?.[endorsed]?.currentPage || null
|
||||
export const selectThreadCurrentPage = (threadId) => (
|
||||
state => state.comments.pagination[threadId]?.currentPage || null
|
||||
);
|
||||
|
||||
export const selectCommentHasMorePages = commentId => (
|
||||
@@ -47,3 +47,7 @@ export const selectCommentCurrentPage = commentId => (
|
||||
export const selectCommentsStatus = state => state.comments.status;
|
||||
|
||||
export const selectCommentSortOrder = state => state.comments.sortOrder;
|
||||
|
||||
export const selectDraftComments = state => state.comments.draftComments;
|
||||
|
||||
export const selectDraftResponses = state => state.comments.draftResponses;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
import { EndorsementStatus, RequestStatus } from '../../../data/constants';
|
||||
import { RequestStatus } from '../../../data/constants';
|
||||
|
||||
const commentsSlice = createSlice({
|
||||
name: 'comments',
|
||||
@@ -22,6 +22,8 @@ const commentsSlice = createSlice({
|
||||
pagination: {},
|
||||
responsesPagination: {},
|
||||
sortOrder: true,
|
||||
draftResponses: {},
|
||||
draftComments: {},
|
||||
},
|
||||
reducers: {
|
||||
fetchCommentsRequest: (state) => (
|
||||
@@ -31,17 +33,12 @@ const commentsSlice = createSlice({
|
||||
}
|
||||
),
|
||||
fetchCommentsSuccess: (state, { payload }) => {
|
||||
const { threadId, page, endorsed } = payload;
|
||||
const { threadId, page } = payload;
|
||||
|
||||
const newState = { ...state };
|
||||
|
||||
newState.status = RequestStatus.SUCCESSFUL;
|
||||
|
||||
newState.commentsInThreads = {
|
||||
...newState.commentsInThreads,
|
||||
[threadId]: newState.commentsInThreads[threadId] || {},
|
||||
};
|
||||
|
||||
newState.pagination = {
|
||||
...newState.pagination,
|
||||
[threadId]: newState.pagination[threadId] || {},
|
||||
@@ -50,23 +47,16 @@ const commentsSlice = createSlice({
|
||||
if (page === 1) {
|
||||
newState.commentsInThreads = {
|
||||
...newState.commentsInThreads,
|
||||
[threadId]: {
|
||||
...newState.commentsInThreads[threadId],
|
||||
[endorsed]: payload.commentsInThreads[threadId] || [],
|
||||
},
|
||||
[threadId]: payload.commentsInThreads[threadId] ? [...payload.commentsInThreads[threadId]] : [],
|
||||
};
|
||||
} else {
|
||||
newState.commentsInThreads = {
|
||||
...newState.commentsInThreads,
|
||||
[threadId]: {
|
||||
...newState.commentsInThreads[threadId],
|
||||
[endorsed]: [
|
||||
...new Set([
|
||||
...(newState.commentsInThreads[threadId][endorsed] || []),
|
||||
...(payload.commentsInThreads[threadId] || []),
|
||||
]),
|
||||
],
|
||||
},
|
||||
[threadId]: [
|
||||
...new Set([
|
||||
...(newState.commentsInThreads[threadId] || []),
|
||||
...(payload.commentsInThreads[threadId] || []),
|
||||
]),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -74,11 +64,9 @@ const commentsSlice = createSlice({
|
||||
...newState.pagination,
|
||||
[threadId]: {
|
||||
...newState.pagination[threadId],
|
||||
[endorsed]: {
|
||||
currentPage: payload.page,
|
||||
totalPages: payload.pagination.numPages,
|
||||
hasMorePages: Boolean(payload.pagination.next),
|
||||
},
|
||||
currentPage: payload.page,
|
||||
totalPages: payload.pagination.numPages,
|
||||
hasMorePages: Boolean(payload.pagination.next),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -181,21 +169,10 @@ const commentsSlice = createSlice({
|
||||
],
|
||||
};
|
||||
} else {
|
||||
const threadComments = newState.commentsInThreads[payload.threadId] || {};
|
||||
const endorsementStatus = threadComments[EndorsementStatus.DISCUSSION]
|
||||
? EndorsementStatus.DISCUSSION
|
||||
: EndorsementStatus.UNENDORSED;
|
||||
|
||||
const updatedThreadComments = {
|
||||
...threadComments,
|
||||
[endorsementStatus]: [
|
||||
...(threadComments[endorsementStatus] || []),
|
||||
payload.id,
|
||||
],
|
||||
};
|
||||
const threadComments = newState.commentsInThreads[payload.threadId] || [];
|
||||
newState.commentsInThreads = {
|
||||
...newState.commentsInThreads,
|
||||
[payload.threadId]: updatedThreadComments,
|
||||
[payload.threadId]: [...threadComments, payload.id],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -231,30 +208,7 @@ const commentsSlice = createSlice({
|
||||
[payload.id]: payload,
|
||||
},
|
||||
commentDraft: null,
|
||||
}
|
||||
),
|
||||
updateCommentsList: (state, { payload }) => {
|
||||
const { id: commentId, threadId, endorsed } = payload;
|
||||
const commentAddListtype = endorsed ? EndorsementStatus.ENDORSED : EndorsementStatus.UNENDORSED;
|
||||
const commentRemoveListType = !endorsed ? EndorsementStatus.ENDORSED : EndorsementStatus.UNENDORSED;
|
||||
|
||||
const updatedThread = { ...state.commentsInThreads[threadId] };
|
||||
|
||||
updatedThread[commentRemoveListType] = updatedThread[commentRemoveListType]
|
||||
?.filter(item => item !== commentId)
|
||||
?? [];
|
||||
updatedThread[commentAddListtype] = [
|
||||
...(updatedThread[commentAddListtype] || []), commentId,
|
||||
];
|
||||
|
||||
return {
|
||||
...state,
|
||||
commentsInThreads: {
|
||||
...state.commentsInThreads,
|
||||
[threadId]: updatedThread,
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
deleteCommentRequest: (state) => (
|
||||
{
|
||||
...state,
|
||||
@@ -285,12 +239,9 @@ const commentsSlice = createSlice({
|
||||
commentsById: { ...state.commentsById },
|
||||
};
|
||||
|
||||
[EndorsementStatus.DISCUSSION, EndorsementStatus.UNENDORSED, EndorsementStatus.ENDORSED].forEach((endorsed) => {
|
||||
newState.commentsInThreads[threadId] = {
|
||||
...newState.commentsInThreads[threadId],
|
||||
[endorsed]: newState.commentsInThreads[threadId]?.[endorsed]?.filter(item => item !== commentId),
|
||||
};
|
||||
});
|
||||
newState.commentsInThreads[threadId] = [
|
||||
...newState.commentsInThreads[threadId]?.filter(item => item !== commentId) || [],
|
||||
];
|
||||
|
||||
if (parentId) {
|
||||
newState.commentsInComments[parentId] = newState.commentsInComments[parentId].filter(
|
||||
@@ -308,6 +259,18 @@ const commentsSlice = createSlice({
|
||||
sortOrder: payload,
|
||||
}
|
||||
),
|
||||
setDraftComments: (state, { payload }) => (
|
||||
{
|
||||
...state,
|
||||
draftComments: payload,
|
||||
}
|
||||
),
|
||||
setDraftResponses: (state, { payload }) => (
|
||||
{
|
||||
...state,
|
||||
draftResponses: payload,
|
||||
}
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -328,12 +291,13 @@ export const {
|
||||
updateCommentFailed,
|
||||
updateCommentRequest,
|
||||
updateCommentSuccess,
|
||||
updateCommentsList,
|
||||
deleteCommentDenied,
|
||||
deleteCommentFailed,
|
||||
deleteCommentRequest,
|
||||
deleteCommentSuccess,
|
||||
setCommentSortOrder,
|
||||
setDraftComments,
|
||||
setDraftResponses,
|
||||
} = commentsSlice.actions;
|
||||
|
||||
export const commentsReducer = commentsSlice.reducer;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { camelCaseObject } from '@edx/frontend-platform';
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
|
||||
import { ContentActions, EndorsementStatus } from '../../../data/constants';
|
||||
import { getHttpErrorStatus } from '../../utils';
|
||||
import {
|
||||
deleteComment, getCommentResponses, getThreadComments, postComment, updateComment,
|
||||
@@ -26,7 +25,6 @@ import {
|
||||
updateCommentDenied,
|
||||
updateCommentFailed,
|
||||
updateCommentRequest,
|
||||
updateCommentsList,
|
||||
updateCommentSuccess,
|
||||
} from './slices';
|
||||
|
||||
@@ -78,7 +76,7 @@ export function fetchThreadComments(
|
||||
{
|
||||
page = 1,
|
||||
reverseOrder,
|
||||
endorsed = EndorsementStatus.DISCUSSION,
|
||||
threadType,
|
||||
enableInContextSidebar,
|
||||
signal,
|
||||
} = {},
|
||||
@@ -87,11 +85,10 @@ export function fetchThreadComments(
|
||||
try {
|
||||
dispatch(fetchCommentsRequest());
|
||||
const data = await getThreadComments(threadId, {
|
||||
page, reverseOrder, endorsed, enableInContextSidebar, signal,
|
||||
page, reverseOrder, threadType, enableInContextSidebar, signal,
|
||||
});
|
||||
dispatch(fetchCommentsSuccess({
|
||||
...normaliseComments(camelCaseObject(data)),
|
||||
endorsed,
|
||||
page,
|
||||
threadId,
|
||||
}));
|
||||
@@ -127,15 +124,12 @@ export function fetchCommentResponses(commentId, { page = 1, reverseOrder = true
|
||||
};
|
||||
}
|
||||
|
||||
export function editComment(commentId, comment, action = null) {
|
||||
export function editComment(commentId, comment) {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
dispatch(updateCommentRequest({ commentId }));
|
||||
const data = await updateComment(commentId, comment);
|
||||
dispatch(updateCommentSuccess(camelCaseObject(data)));
|
||||
if (action === ContentActions.ENDORSE) {
|
||||
dispatch(updateCommentsList(camelCaseObject(data)));
|
||||
}
|
||||
} catch (error) {
|
||||
if (getHttpErrorStatus(error) === 403) {
|
||||
dispatch(updateCommentDenied());
|
||||
@@ -147,15 +141,16 @@ export function editComment(commentId, comment, action = null) {
|
||||
};
|
||||
}
|
||||
|
||||
export function addComment(comment, threadId, parentId = null, enableInContextSidebar = false) {
|
||||
export function addComment(comment, threadId, parentId = null, enableInContextSidebar = false, recaptchaToken = '') {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
dispatch(postCommentRequest({
|
||||
comment,
|
||||
threadId,
|
||||
parentId,
|
||||
recaptchaToken,
|
||||
}));
|
||||
const data = await postComment(comment, threadId, parentId, enableInContextSidebar);
|
||||
const data = await postComment(comment, threadId, parentId, enableInContextSidebar, recaptchaToken);
|
||||
dispatch(postCommentSuccess(camelCaseObject(data)));
|
||||
} catch (error) {
|
||||
if (getHttpErrorStatus(error) === 403) {
|
||||
|
||||
@@ -221,6 +221,16 @@ const messages = defineMessages({
|
||||
}`,
|
||||
description: 'sort message showing current sorting',
|
||||
},
|
||||
verifyHumanLabel: {
|
||||
id: 'discussions.verify.human.label',
|
||||
defaultMessage: 'Verify you are human',
|
||||
description: 'Verify you are human description.',
|
||||
},
|
||||
captchaVerificationLabel: {
|
||||
id: 'discussions.captcha.verification.label',
|
||||
defaultMessage: 'Please complete the CAPTCHA verification',
|
||||
description: 'Please complete the CAPTCHA to continue.',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -3,11 +3,11 @@ import React, {
|
||||
} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Button, Spinner } from '@openedx/paragon';
|
||||
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 DiscussionContext from '../common/context';
|
||||
|
||||
@@ -84,6 +84,8 @@ export const getThread = async (threadId, courseId) => {
|
||||
* @param {boolean} following Follow the thread after creating
|
||||
* @param {boolean} anonymous Should the thread be anonymous to all users
|
||||
* @param {boolean} anonymousToPeers Should the thread be anonymous to peers
|
||||
* @param notifyAllLearners
|
||||
* @param recaptchaToken
|
||||
* @param {boolean} enableInContextSidebar
|
||||
* @returns {Promise<{}>}
|
||||
*/
|
||||
@@ -98,6 +100,8 @@ export const postThread = async (
|
||||
cohort,
|
||||
anonymous,
|
||||
anonymousToPeers,
|
||||
notifyAllLearners,
|
||||
recaptchaToken,
|
||||
} = {},
|
||||
enableInContextSidebar = false,
|
||||
) => {
|
||||
@@ -112,6 +116,8 @@ export const postThread = async (
|
||||
anonymousToPeers,
|
||||
groupId: cohort,
|
||||
enableInContextSidebar,
|
||||
notifyAllLearners,
|
||||
captchaToken: recaptchaToken,
|
||||
});
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getThreadsApiUrl(), postData);
|
||||
|
||||
@@ -11,9 +11,9 @@ const usePostList = (ids) => {
|
||||
|
||||
const sortedIds = useMemo(() => {
|
||||
posts.forEach((post) => {
|
||||
if (post.pinned) {
|
||||
if (post && post.pinned) {
|
||||
pinnedPostsIds.push(post.id);
|
||||
} else {
|
||||
} else if (post) {
|
||||
unpinnedPostsIds.push(post.id);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import camelCase from 'lodash/camelCase';
|
||||
|
||||
const selectThreads = state => state.threads.threadsById;
|
||||
|
||||
@@ -56,3 +57,7 @@ export const selectThreadSorting = () => state => state.threads.sortedBy;
|
||||
export const selectThreadFilters = () => state => state.threads.filters;
|
||||
|
||||
export const selectThreadNextPage = () => state => state.threads.nextPage;
|
||||
|
||||
export const selectAuthorAvatar = author => state => (
|
||||
state.threads.avatars?.[camelCase(author)]?.profile.image
|
||||
);
|
||||
|
||||
@@ -185,7 +185,7 @@ const threadsSlice = createSlice({
|
||||
},
|
||||
pages: !payload.anonymousToPeers
|
||||
? [
|
||||
...[payload.id].concat(state.pages[0]) || [],
|
||||
...(state.pages[0] ? [payload.id].concat(state.pages[0]) : []),
|
||||
...state.pages.slice(1),
|
||||
]
|
||||
: [...state.pages],
|
||||
|
||||
@@ -204,6 +204,8 @@ export function createNewThread({
|
||||
anonymousToPeers,
|
||||
cohort,
|
||||
enableInContextSidebar,
|
||||
notifyAllLearners,
|
||||
recaptchaToken,
|
||||
}) {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
@@ -217,12 +219,16 @@ export function createNewThread({
|
||||
anonymous,
|
||||
anonymousToPeers,
|
||||
cohort,
|
||||
notifyAllLearners,
|
||||
recaptchaToken,
|
||||
}));
|
||||
const data = await postThread(courseId, topicId, type, title, content, {
|
||||
cohort,
|
||||
following,
|
||||
anonymous,
|
||||
anonymousToPeers,
|
||||
notifyAllLearners,
|
||||
recaptchaToken,
|
||||
}, enableInContextSidebar);
|
||||
dispatch(postThreadSuccess(camelCaseObject(data)));
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import React, { useCallback, useContext } from 'react';
|
||||
|
||||
import {
|
||||
Button, Icon, IconButton,
|
||||
} from '@openedx/paragon';
|
||||
import { Close } from '@openedx/paragon/icons';
|
||||
import classNames from 'classnames';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Button, Icon, IconButton,
|
||||
} from '@edx/paragon';
|
||||
import { Close } from '@edx/paragon/icons';
|
||||
|
||||
import Search from '../../../components/Search';
|
||||
import { RequestStatus } from '../../../data/constants';
|
||||
@@ -55,7 +55,7 @@ const PostActionsBar = () => {
|
||||
<Button
|
||||
variant={enableInContextSidebar ? 'plain' : 'brand'}
|
||||
className={classNames(
|
||||
'my-0 font-style border-0 line-height-24',
|
||||
'my-0 font-style line-height-24',
|
||||
{ 'px-3 py-10px border-0': enableInContextSidebar },
|
||||
)}
|
||||
onClick={handleAddPost}
|
||||
|
||||
@@ -3,21 +3,23 @@ import React, {
|
||||
} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import {
|
||||
Button, Form, Spinner, StatefulButton,
|
||||
} from '@openedx/paragon';
|
||||
import { Help, Post } from '@openedx/paragon/icons';
|
||||
import { Formik } from 'formik';
|
||||
import { isEmpty } from 'lodash';
|
||||
import ReCAPTCHA from 'react-google-recaptcha';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { AppContext } from '@edx/frontend-platform/react';
|
||||
import {
|
||||
Button, Form, Spinner, StatefulButton,
|
||||
} from '@edx/paragon';
|
||||
import { Help, Post } from '@edx/paragon/icons';
|
||||
|
||||
import { TinyMCEEditor } from '../../../components';
|
||||
import FormikErrorFeedback from '../../../components/FormikErrorFeedback';
|
||||
import PostHelpPanel from '../../../components/PostHelpPanel';
|
||||
import PostPreviewPanel from '../../../components/PostPreviewPanel';
|
||||
import useDispatchWithState from '../../../data/hooks';
|
||||
import selectCourseCohorts from '../../cohorts/data/selectors';
|
||||
@@ -26,8 +28,10 @@ import DiscussionContext from '../../common/context';
|
||||
import { useCurrentDiscussionTopic } from '../../data/hooks';
|
||||
import {
|
||||
selectAnonymousPostingConfig,
|
||||
selectCaptchaSettings,
|
||||
selectDivisionSettings,
|
||||
selectEnableInContext,
|
||||
selectIsNotifyAllLearnersEnabled,
|
||||
selectModerationSettings,
|
||||
selectUserHasModerationPrivileges,
|
||||
selectUserIsGroupTa,
|
||||
@@ -41,6 +45,7 @@ import {
|
||||
selectNonCoursewareTopics as inContextNonCourseware,
|
||||
} from '../../in-context-topics/data/selectors';
|
||||
import { selectCoursewareTopics, selectNonCoursewareIds, selectNonCoursewareTopics } from '../../topics/data/selectors';
|
||||
import { updateUserDiscussionsTourByName } from '../../tours/data';
|
||||
import {
|
||||
discussionsPath, formikCompatibleHandler, isFormikFieldInvalid, useCommentsPagePath,
|
||||
} from '../../utils';
|
||||
@@ -58,6 +63,7 @@ const PostEditor = ({
|
||||
const location = useLocation();
|
||||
const dispatch = useDispatch();
|
||||
const editorRef = useRef(null);
|
||||
const recaptchaRef = useRef(null);
|
||||
const { courseId, postId } = useParams();
|
||||
const { authenticatedUser } = useContext(AppContext);
|
||||
const { category, enableInContextSidebar } = useContext(DiscussionContext);
|
||||
@@ -78,6 +84,8 @@ const PostEditor = ({
|
||||
const userIsStaff = useSelector(selectUserIsStaff);
|
||||
const archivedTopics = useSelector(selectArchivedTopics);
|
||||
const postEditorId = `post-editor-${editExisting ? postId : 'new'}`;
|
||||
const isNotifyAllLearnersEnabled = useSelector(selectIsNotifyAllLearnersEnabled);
|
||||
const captchaSettings = useSelector(selectCaptchaSettings);
|
||||
|
||||
const canDisplayEditReason = (editExisting
|
||||
&& (userHasModerationPrivileges || userIsGroupTa || userIsStaff)
|
||||
@@ -88,6 +96,34 @@ const PostEditor = ({
|
||||
editReasonCode: Yup.string().required(intl.formatMessage(messages.editReasonCodeError)),
|
||||
};
|
||||
|
||||
const shouldRequireCaptcha = !postId && captchaSettings.enabled;
|
||||
const captchaValidation = {
|
||||
recaptchaToken: Yup.string().required(intl.formatMessage(messages.captchaVerificationLabel)),
|
||||
};
|
||||
|
||||
const enableNotifyAllLearnersTour = useCallback((enabled) => {
|
||||
const data = {
|
||||
enabled,
|
||||
tourName: 'notify_all_learners',
|
||||
};
|
||||
dispatch(updateUserDiscussionsTourByName(data));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
enableNotifyAllLearnersTour(true);
|
||||
return () => {
|
||||
enableNotifyAllLearnersTour(false);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleCaptchaChange = useCallback((token, setFieldValue) => {
|
||||
setFieldValue('recaptchaToken', token || '');
|
||||
}, []);
|
||||
|
||||
const handleCaptchaExpired = useCallback((setFieldValue) => {
|
||||
setFieldValue('recaptchaToken', '');
|
||||
}, []);
|
||||
|
||||
const canSelectCohort = useCallback((tId) => {
|
||||
// If the user isn't privileged, they can't edit the cohort.
|
||||
// If the topic is being edited the cohort can't be changed.
|
||||
@@ -107,16 +143,22 @@ const PostEditor = ({
|
||||
title: post?.title || '',
|
||||
comment: post?.rawBody || '',
|
||||
follow: isEmpty(post?.following) ? true : post?.following,
|
||||
notifyAllLearners: false,
|
||||
anonymous: allowAnonymous ? false : undefined,
|
||||
anonymousToPeers: allowAnonymousToPeers ? false : undefined,
|
||||
cohort: post?.cohort || 'default',
|
||||
editReasonCode: post?.lastEdit?.reasonCode || (
|
||||
userIsStaff && canDisplayEditReason ? 'violates-guidelines' : undefined
|
||||
),
|
||||
recaptchaToken: '',
|
||||
};
|
||||
|
||||
const hideEditor = useCallback((resetForm) => {
|
||||
resetForm({ values: initialValues });
|
||||
// Reset CAPTCHA when hiding editor
|
||||
if (recaptchaRef.current) {
|
||||
recaptchaRef.current.reset();
|
||||
}
|
||||
if (editExisting) {
|
||||
const newLocation = discussionsPath(commentsPagePath, {
|
||||
courseId,
|
||||
@@ -148,7 +190,7 @@ const PostEditor = ({
|
||||
}));
|
||||
} else {
|
||||
const cohort = canSelectCohort(values.topic) ? selectedCohort(values.cohort) : undefined;
|
||||
// if not allowed to set cohort, always undefined, so no value is sent to backend
|
||||
// Include CAPTCHA token in the request for new posts
|
||||
await dispatchSubmit(createNewThread({
|
||||
courseId,
|
||||
topicId: values.topic,
|
||||
@@ -160,16 +202,18 @@ const PostEditor = ({
|
||||
anonymousToPeers: allowAnonymousToPeers ? values.anonymousToPeers : undefined,
|
||||
cohort,
|
||||
enableInContextSidebar,
|
||||
notifyAllLearners: values.notifyAllLearners,
|
||||
...(shouldRequireCaptcha ? { recaptchaToken: values.recaptchaToken } : {}),
|
||||
}));
|
||||
}
|
||||
/* istanbul ignore if: TinyMCE is mocked so this cannot be easily tested */
|
||||
|
||||
if (editorRef.current) {
|
||||
editorRef.current.plugins.autosave.removeDraft();
|
||||
}
|
||||
hideEditor(resetForm);
|
||||
}, [
|
||||
allowAnonymous, allowAnonymousToPeers, canSelectCohort, editExisting,
|
||||
enableInContextSidebar, hideEditor, postId, selectedCohort, topicId,
|
||||
enableInContextSidebar, hideEditor, postId, selectedCohort, topicId, shouldRequireCaptcha,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -215,10 +259,14 @@ const PostEditor = ({
|
||||
anonymousToPeers: Yup.bool()
|
||||
.default(false)
|
||||
.nullable(),
|
||||
notifyAllLearners: Yup.bool()
|
||||
.default(false),
|
||||
cohort: Yup.string()
|
||||
.nullable()
|
||||
.default(null),
|
||||
...(shouldRequireCaptcha ? { recaptchaToken: Yup.string().required() } : { }),
|
||||
...editReasonCodeValidation,
|
||||
...(shouldRequireCaptcha ? captchaValidation : {}),
|
||||
});
|
||||
|
||||
const handleInContextSelectLabel = (section, subsection) => (
|
||||
@@ -239,9 +287,10 @@ const PostEditor = ({
|
||||
handleBlur,
|
||||
handleChange,
|
||||
resetForm,
|
||||
setFieldValue,
|
||||
}) => (
|
||||
<Form className="m-4 card p-4 post-form" onSubmit={handleSubmit}>
|
||||
<h4 className="mb-4 font-style font-size-16" style={{ lineHeight: '16px' }}>
|
||||
<h4 className="mb-4 font-style" style={{ lineHeight: '16px' }}>
|
||||
{editExisting
|
||||
? intl.formatMessage(messages.editPostHeading)
|
||||
: intl.formatMessage(messages.addPostHeading)}
|
||||
@@ -279,6 +328,7 @@ const PostEditor = ({
|
||||
aria-describedby="topicAreaInput"
|
||||
floatingLabel={intl.formatMessage(messages.topicArea)}
|
||||
disabled={enableInContextSidebar}
|
||||
data-testid="topic-select"
|
||||
>
|
||||
{nonCoursewareTopics.map(topic => (
|
||||
<option
|
||||
@@ -366,6 +416,7 @@ const PostEditor = ({
|
||||
aria-describedby="titleInput"
|
||||
floatingLabel={intl.formatMessage(messages.postTitle)}
|
||||
value={values.title}
|
||||
data-testid="post-title-input"
|
||||
/>
|
||||
<FormikErrorFeedback name="title" />
|
||||
</Form.Group>
|
||||
@@ -409,12 +460,29 @@ const PostEditor = ({
|
||||
onEditorChange={formikCompatibleHandler(handleChange, 'comment')}
|
||||
onBlur={formikCompatibleHandler(handleBlur, 'comment')}
|
||||
/>
|
||||
<PostHelpPanel />
|
||||
<FormikErrorFeedback name="comment" />
|
||||
</div>
|
||||
<PostPreviewPanel htmlNode={values.comment} isPost editExisting={editExisting} />
|
||||
<div className="d-flex flex-row mt-n4 w-75 text-primary font-style">
|
||||
{!editExisting && (
|
||||
<>
|
||||
{isNotifyAllLearnersEnabled && (
|
||||
<Form.Group>
|
||||
<Form.Checkbox
|
||||
name="notifyAllLearners"
|
||||
id="notify-learners"
|
||||
checked={values.notifyAllLearners}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
className="mr-4.5"
|
||||
>
|
||||
<span>
|
||||
{intl.formatMessage(messages.notifyAllLearners)}
|
||||
</span>
|
||||
</Form.Checkbox>
|
||||
</Form.Group>
|
||||
)}
|
||||
<Form.Group>
|
||||
<Form.Checkbox
|
||||
name="follow"
|
||||
@@ -423,7 +491,7 @@ const PostEditor = ({
|
||||
onBlur={handleBlur}
|
||||
className="mr-4.5"
|
||||
>
|
||||
<span className="font-size-14">
|
||||
<span>
|
||||
{intl.formatMessage(messages.followPost)}
|
||||
</span>
|
||||
</Form.Checkbox>
|
||||
@@ -436,7 +504,7 @@ const PostEditor = ({
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
>
|
||||
<span className="font-size-14">
|
||||
<span>
|
||||
{intl.formatMessage(messages.anonymousToPeersPost)}
|
||||
</span>
|
||||
</Form.Checkbox>
|
||||
@@ -445,6 +513,31 @@ const PostEditor = ({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/* CAPTCHA Section - Only show for new posts for non-staff users */}
|
||||
{shouldRequireCaptcha && captchaSettings.siteKey && (
|
||||
<div className="mb-3">
|
||||
<Form.Group
|
||||
isInvalid={isFormikFieldInvalid('recaptchaToken', {
|
||||
errors,
|
||||
touched,
|
||||
})}
|
||||
>
|
||||
<Form.Label className="h6">
|
||||
{intl.formatMessage(messages.verifyHumanLabel)}
|
||||
</Form.Label>
|
||||
<div className="d-flex justify-content-start">
|
||||
<ReCAPTCHA
|
||||
ref={recaptchaRef}
|
||||
sitekey={captchaSettings.siteKey}
|
||||
onChange={(token) => handleCaptchaChange(token, setFieldValue)}
|
||||
onExpired={() => handleCaptchaExpired(setFieldValue)}
|
||||
onError={() => handleCaptchaExpired(setFieldValue)}
|
||||
/>
|
||||
</div>
|
||||
<FormikErrorFeedback name="recaptchaToken" />
|
||||
</Form.Group>
|
||||
</div>
|
||||
)}
|
||||
<div className="d-flex justify-content-end">
|
||||
<Button
|
||||
variant="outline-primary"
|
||||
|
||||
@@ -19,9 +19,14 @@ import { initializeStore } from '../../../store';
|
||||
import executeThunk from '../../../test-utils';
|
||||
import { getCohortsApiUrl } from '../../cohorts/data/api';
|
||||
import DiscussionContext from '../../common/context';
|
||||
import { getCourseConfigApiUrl } from '../../data/api';
|
||||
import fetchCourseConfig from '../../data/thunks';
|
||||
import fetchCourseTopics from '../../topics/data/thunks';
|
||||
import { getThreadsApiUrl } from '../data/api';
|
||||
import { fetchThread } from '../data/thunks';
|
||||
import MockReCAPTCHA, {
|
||||
mockOnChange, mockOnError, mockOnExpired,
|
||||
} from './mocksData/react-google-recaptcha';
|
||||
import PostEditor from './PostEditor';
|
||||
|
||||
import '../../cohorts/data/__factories__';
|
||||
@@ -31,11 +36,14 @@ import '../data/__factories__';
|
||||
|
||||
const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||
const topicsApiUrl = `${getApiBaseUrl()}/api/discussion/v1/course_topics/${courseId}`;
|
||||
const courseConfigApiUrl = getCourseConfigApiUrl();
|
||||
const threadsApiUrl = getThreadsApiUrl();
|
||||
let store;
|
||||
let axiosMock;
|
||||
let container;
|
||||
|
||||
jest.mock('react-google-recaptcha', () => MockReCAPTCHA);
|
||||
|
||||
async function renderComponent(editExisting = false, location = `/${courseId}/posts/`) {
|
||||
const paths = editExisting ? ROUTES.POSTS.EDIT_POST : [ROUTES.POSTS.NEW_POST];
|
||||
const wrapper = await render(
|
||||
@@ -58,6 +66,114 @@ async function renderComponent(editExisting = false, location = `/${courseId}/po
|
||||
container = wrapper.container;
|
||||
}
|
||||
|
||||
describe('PostEditor submit Form', () => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
Factory.resetAll();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
const cwtopics = Factory.buildList('category', 2);
|
||||
Factory.reset('topic');
|
||||
axiosMock.onGet(topicsApiUrl).reply(200, {
|
||||
courseware_topics: cwtopics,
|
||||
non_courseware_topics: Factory.buildList('topic', 3, {}, { topicPrefix: 'ncw-' }),
|
||||
});
|
||||
|
||||
store = initializeStore({
|
||||
config: {
|
||||
provider: 'legacy',
|
||||
allowAnonymous: true,
|
||||
allowAnonymousToPeers: true,
|
||||
hasModerationPrivileges: true,
|
||||
settings: {
|
||||
dividedInlineDiscussions: ['category-1-topic-2'],
|
||||
dividedCourseWideDiscussions: ['ncw-topic-2'],
|
||||
},
|
||||
captchaSettings: {
|
||||
enabled: true,
|
||||
siteKey: 'test-key',
|
||||
},
|
||||
},
|
||||
});
|
||||
await executeThunk(fetchCourseTopics(courseId), store.dispatch, store.getState);
|
||||
axiosMock.onGet(getCohortsApiUrl(courseId)).reply(200, Factory.buildList('cohort', 3));
|
||||
});
|
||||
|
||||
test('successfully submits a new post with CAPTCHA', async () => {
|
||||
const newThread = Factory.build('thread', { id: 'new-thread-1' });
|
||||
axiosMock.onPost(threadsApiUrl).reply(200, newThread);
|
||||
|
||||
await renderComponent();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(screen.getByTestId('topic-select'), {
|
||||
target: { value: 'ncw-topic-1' },
|
||||
});
|
||||
const postTitle = await screen.findByTestId('post-title-input');
|
||||
const tinymceEditor = await screen.findByTestId('tinymce-editor');
|
||||
const solveButton = screen.getByText('Solve CAPTCHA');
|
||||
|
||||
fireEvent.change(postTitle, { target: { value: 'Test Post Title' } });
|
||||
fireEvent.change(tinymceEditor, { target: { value: 'Test Post Content' } });
|
||||
fireEvent.click(solveButton);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /submit/i }));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(axiosMock.history.post).toHaveLength(1);
|
||||
expect(JSON.parse(axiosMock.history.post[0].data)).toMatchObject({
|
||||
course_id: 'course-v1:edX+DemoX+Demo_Course',
|
||||
topic_id: 'ncw-topic-1',
|
||||
type: 'discussion',
|
||||
title: 'Test Post Title',
|
||||
raw_body: 'Test Post Content',
|
||||
following: true,
|
||||
anonymous: false,
|
||||
anonymous_to_peers: false,
|
||||
enable_in_context_sidebar: false,
|
||||
notify_all_learners: false,
|
||||
captcha_token: 'mock-token',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('fails to submit a new post with CAPTCHA if token is missing', async () => {
|
||||
const newThread = Factory.build('thread', { id: 'new-thread-1' });
|
||||
axiosMock.onPost(threadsApiUrl).reply(200, newThread);
|
||||
|
||||
await renderComponent();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(screen.getByTestId('topic-select'), {
|
||||
target: { value: 'ncw-topic-1' },
|
||||
});
|
||||
const postTitle = await screen.findByTestId('post-title-input');
|
||||
const tinymceEditor = await screen.findByTestId('tinymce-editor');
|
||||
|
||||
fireEvent.change(postTitle, { target: { value: 'Test Post Title' } });
|
||||
fireEvent.change(tinymceEditor, { target: { value: 'Test Post Content' } });
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /submit/i }));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Please complete the CAPTCHA verification')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('PostEditor', () => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
@@ -107,6 +223,10 @@ describe('PostEditor', () => {
|
||||
allowAnonymous,
|
||||
allowAnonymousToPeers,
|
||||
moderationSettings: {},
|
||||
captchaSettings: {
|
||||
enabled: false,
|
||||
siteKey: '',
|
||||
},
|
||||
},
|
||||
});
|
||||
await executeThunk(fetchCourseTopics(courseId), store.dispatch, store.getState);
|
||||
@@ -116,7 +236,6 @@ describe('PostEditor', () => {
|
||||
allowAnonymousToPeers ? '' : 'not'} allowed`, async () => {
|
||||
await renderComponent();
|
||||
|
||||
expect(screen.queryByRole('heading')).toHaveTextContent('Add a post');
|
||||
expect(screen.queryAllByRole('radio')).toHaveLength(2);
|
||||
// 2 categories with 4 subcategories each
|
||||
expect(screen.queryAllByText(/category-\d-topic \d/)).toHaveLength(8);
|
||||
@@ -143,6 +262,46 @@ describe('PostEditor', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe.each([
|
||||
{
|
||||
isNotifyAllLearnersEnabled: true,
|
||||
description: 'when "Notify All Learners" is enabled',
|
||||
},
|
||||
{
|
||||
isNotifyAllLearnersEnabled: false,
|
||||
description: 'when "Notify All Learners" is disabled',
|
||||
},
|
||||
])('$description', ({ isNotifyAllLearnersEnabled }) => {
|
||||
beforeEach(async () => {
|
||||
store = initializeStore({
|
||||
config: {
|
||||
provider: 'legacy',
|
||||
is_notify_all_learners_enabled: isNotifyAllLearnersEnabled,
|
||||
moderationSettings: {},
|
||||
captchaSettings: {
|
||||
enabled: false,
|
||||
siteKey: '',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
axiosMock
|
||||
.onGet(`${courseConfigApiUrl}${courseId}/`)
|
||||
.reply(200, { is_notify_all_learners_enabled: isNotifyAllLearnersEnabled });
|
||||
|
||||
await store.dispatch(fetchCourseConfig(courseId));
|
||||
renderComponent();
|
||||
});
|
||||
|
||||
test(`should ${isNotifyAllLearnersEnabled ? 'show' : 'not show'} the "Notify All Learners" option`, async () => {
|
||||
if (isNotifyAllLearnersEnabled) {
|
||||
await waitFor(() => expect(screen.queryByText('Notify All Learners')).toBeInTheDocument());
|
||||
} else {
|
||||
await waitFor(() => expect(screen.queryByText('Notify All Learners')).not.toBeInTheDocument());
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('cohorting', () => {
|
||||
const dividedncw = ['ncw-topic-2'];
|
||||
const dividedcw = ['category-1-topic-2', 'category-2-topic-1', 'category-2-topic-2'];
|
||||
@@ -162,12 +321,64 @@ describe('PostEditor', () => {
|
||||
dividedCourseWideDiscussions: dividedncw,
|
||||
...settings,
|
||||
},
|
||||
captchaSettings: {
|
||||
enabled: false,
|
||||
siteKey: '',
|
||||
},
|
||||
...config,
|
||||
},
|
||||
});
|
||||
await executeThunk(fetchCourseTopics(courseId), store.dispatch, store.getState);
|
||||
}
|
||||
|
||||
test('renders the mocked ReCAPTCHA.', async () => {
|
||||
await setupData({
|
||||
captchaSettings: {
|
||||
enabled: true,
|
||||
siteKey: 'test-key',
|
||||
},
|
||||
});
|
||||
await renderComponent();
|
||||
expect(screen.getByTestId('mocked-recaptcha')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('successfully calls onTokenChange when Solve CAPTCHA button is clicked', async () => {
|
||||
await setupData({
|
||||
captchaSettings: {
|
||||
enabled: true,
|
||||
siteKey: 'test-key',
|
||||
},
|
||||
});
|
||||
await renderComponent();
|
||||
const solveButton = screen.getByText('Solve CAPTCHA');
|
||||
fireEvent.click(solveButton);
|
||||
expect(mockOnChange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('successfully calls onExpired handler when CAPTCHA expires', async () => {
|
||||
await setupData({
|
||||
captchaSettings: {
|
||||
enabled: true,
|
||||
siteKey: 'test-key',
|
||||
},
|
||||
});
|
||||
await renderComponent();
|
||||
fireEvent.click(screen.getByText('Expire CAPTCHA'));
|
||||
expect(mockOnExpired).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('successfully calls onError handler when CAPTCHA errors', async () => {
|
||||
await setupData({
|
||||
captchaSettings: {
|
||||
enabled: true,
|
||||
siteKey: 'test-key',
|
||||
},
|
||||
});
|
||||
await renderComponent();
|
||||
fireEvent.click(screen.getByText('Error CAPTCHA'));
|
||||
expect(mockOnError).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('test privileged user', async () => {
|
||||
await setupData();
|
||||
await renderComponent();
|
||||
@@ -319,6 +530,10 @@ describe('PostEditor', () => {
|
||||
dividedInlineDiscussions: dividedcw,
|
||||
dividedCourseWideDiscussions: dividedncw,
|
||||
},
|
||||
captchaSettings: {
|
||||
enabled: false,
|
||||
siteKey: '',
|
||||
},
|
||||
},
|
||||
});
|
||||
await executeThunk(fetchCourseTopics(courseId), store.dispatch, store.getState);
|
||||
@@ -368,5 +583,34 @@ describe('PostEditor', () => {
|
||||
expect(container.querySelector('[data-testid="hide-preview-button"]')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show Help Panel', async () => {
|
||||
await renderComponent(true, `/${courseId}/posts/${threadId}/edit`);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(container.querySelector('[data-testid="help-button"]'));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('[data-testid="hide-help-button"]')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should hide Help Panel', async () => {
|
||||
await renderComponent(true, `/${courseId}/posts/${threadId}/edit`);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(container.querySelector('[data-testid="help-button"]'));
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(container.querySelector('[data-testid="hide-help-button"]'));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('[data-testid="help-button"]')).toBeInTheDocument();
|
||||
expect(container.querySelector('[data-testid="hide-help-button"]')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import React, { useContext } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Card, Form } from '@openedx/paragon';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { Card, Form } from '@edx/paragon';
|
||||
|
||||
import DiscussionContext from '../../common/context';
|
||||
|
||||
const PostTypeCard = ({
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user