Compare commits
1 Commits
inf-663
...
kshitij/th
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40ab53d793 |
6
.env
6
.env
@@ -15,11 +15,9 @@ LOGO_WHITE_URL=''
|
|||||||
FAVICON_URL=''
|
FAVICON_URL=''
|
||||||
MARKETING_SITE_BASE_URL=''
|
MARKETING_SITE_BASE_URL=''
|
||||||
ORDER_HISTORY_URL=''
|
ORDER_HISTORY_URL=''
|
||||||
|
POST_MARK_AS_READ_DELAY=2000
|
||||||
REFRESH_ACCESS_TOKEN_ENDPOINT=''
|
REFRESH_ACCESS_TOKEN_ENDPOINT=''
|
||||||
SEGMENT_KEY=''
|
SEGMENT_KEY=''
|
||||||
SITE_NAME=''
|
SITE_NAME=''
|
||||||
USER_INFO_COOKIE_NAME=''
|
USER_INFO_COOKIE_NAME=''
|
||||||
SUPPORT_URL=''
|
THEME_LOADER_URL=''
|
||||||
TA_FEEDBACK_FORM= ''
|
|
||||||
STAFF_FEEDBACK_FORM= ''
|
|
||||||
DISPLAY_FEEDBACK_BANNER='false'
|
|
||||||
|
|||||||
@@ -16,11 +16,9 @@ LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
|
|||||||
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
|
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
|
||||||
MARKETING_SITE_BASE_URL='http://localhost:18000'
|
MARKETING_SITE_BASE_URL='http://localhost:18000'
|
||||||
ORDER_HISTORY_URL='http://localhost:1996/orders'
|
ORDER_HISTORY_URL='http://localhost:1996/orders'
|
||||||
|
POST_MARK_AS_READ_DELAY=2000
|
||||||
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
|
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
|
||||||
SEGMENT_KEY=''
|
SEGMENT_KEY=''
|
||||||
SITE_NAME=localhost
|
SITE_NAME=localhost
|
||||||
USER_INFO_COOKIE_NAME='edx-user-info'
|
USER_INFO_COOKIE_NAME='edx-user-info'
|
||||||
SUPPORT_URL='https://support.edx.org'
|
THEME_LOADER_URL='https://xitij2000.github.io/frontend-theme-prototype/themes.js'
|
||||||
TA_FEEDBACK_FORM='https://learner-form.test'
|
|
||||||
STAFF_FEEDBACK_FORM='https://staff-form.test'
|
|
||||||
DISPLAY_FEEDBACK_BANNER='false'
|
|
||||||
|
|||||||
15
.env.test
15
.env.test
@@ -8,17 +8,14 @@ LMS_BASE_URL='http://localhost:18000'
|
|||||||
LEARNING_BASE_URL='http://localhost:2000'
|
LEARNING_BASE_URL='http://localhost:2000'
|
||||||
LOGIN_URL='http://localhost:18000/login'
|
LOGIN_URL='http://localhost:18000/login'
|
||||||
LOGOUT_URL='http://localhost:18000/logout'
|
LOGOUT_URL='http://localhost:18000/logout'
|
||||||
LOGO_URL='https://edx-cdn.org/v3/default/logo.svg'
|
LOGO_URL=https://edx-cdn.org/v3/default/logo.svg
|
||||||
LOGO_TRADEMARK_URL='https://edx-cdn.org/v3/default/logo-trademark.svg'
|
LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/default/logo-trademark.svg
|
||||||
LOGO_WHITE_URL='https://edx-cdn.org/v3/default/logo-white.svg'
|
LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
|
||||||
FAVICON_URL='https://edx-cdn.org/v3/default/favicon.ico'
|
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
|
||||||
MARKETING_SITE_BASE_URL='http://localhost:18000'
|
MARKETING_SITE_BASE_URL='http://localhost:18000'
|
||||||
ORDER_HISTORY_URL='http://localhost:1996/orders'
|
ORDER_HISTORY_URL='http://localhost:1996/orders'
|
||||||
|
POST_MARK_AS_READ_DELAY=2000
|
||||||
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
|
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
|
||||||
SEGMENT_KEY=''
|
SEGMENT_KEY=''
|
||||||
SITE_NAME='localhost'
|
SITE_NAME=localhost
|
||||||
USER_INFO_COOKIE_NAME='edx-user-info'
|
USER_INFO_COOKIE_NAME='edx-user-info'
|
||||||
SUPPORT_URL='https://support.edx.org'
|
|
||||||
TA_FEEDBACK_FORM='https://learner-form.test'
|
|
||||||
STAFF_FEEDBACK_FORM='https://staff-form.test'
|
|
||||||
DISPLAY_FEEDBACK_BANNER='false'
|
|
||||||
|
|||||||
@@ -5,9 +5,6 @@ module.exports = createConfig('eslint',
|
|||||||
"plugins": ["simple-import-sort"],
|
"plugins": ["simple-import-sort"],
|
||||||
"rules": {
|
"rules": {
|
||||||
'import/no-extraneous-dependencies': 'off',
|
'import/no-extraneous-dependencies': 'off',
|
||||||
'react-hooks/exhaustive-deps': 'off',
|
|
||||||
'jsx-a11y/no-noninteractive-element-interactions': 'off',
|
|
||||||
'jsx-a11y/no-access-key': 'off',
|
|
||||||
'simple-import-sort/imports': [
|
'simple-import-sort/imports': [
|
||||||
'error', {
|
'error', {
|
||||||
groups: [
|
groups: [
|
||||||
|
|||||||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -11,7 +11,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
node: [16]
|
node: [12, 14, 16]
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2
|
||||||
|
|||||||
2
.github/workflows/commitlint.yml
vendored
2
.github/workflows/commitlint.yml
vendored
@@ -7,4 +7,4 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
commitlint:
|
commitlint:
|
||||||
uses: openedx/.github/.github/workflows/commitlint.yml@master
|
uses: edx/.github/.github/workflows/commitlint.yml@master
|
||||||
|
|||||||
13
.github/workflows/lockfileversion-check.yml
vendored
13
.github/workflows/lockfileversion-check.yml
vendored
@@ -1,13 +0,0 @@
|
|||||||
#check package-lock file version
|
|
||||||
|
|
||||||
name: Lockfile Version check
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- master
|
|
||||||
pull_request:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
version-check:
|
|
||||||
uses: openedx/.github/.github/workflows/lockfileversion-check.yml@master
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
process.env.TA_FEEDBACK_FORM= 'https://learner-form.test';
|
|
||||||
process.env.STAFF_FEEDBACK_FORM= 'https://staff-form.test';
|
|
||||||
@@ -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
|
|
||||||
29
Makefile
29
Makefile
@@ -1,6 +1,5 @@
|
|||||||
export TRANSIFEX_RESOURCE = frontend-app-discussions
|
|
||||||
transifex_resource = frontend-app-discussions
|
transifex_resource = frontend-app-discussions
|
||||||
transifex_langs = "ar,fr,es_419,zh_CN,tr_TR,pl,fr_CA,fr_FR,de_DE,it_IT"
|
transifex_langs = "ar,fr,es_419,zh_CN"
|
||||||
|
|
||||||
transifex_utils = ./node_modules/.bin/transifex-utils.js
|
transifex_utils = ./node_modules/.bin/transifex-utils.js
|
||||||
i18n = ./src/i18n
|
i18n = ./src/i18n
|
||||||
@@ -11,24 +10,12 @@ tx_url2 = https://www.transifex.com/api/2/project/edx-platform/resource/$(transi
|
|||||||
# This directory must match .babelrc .
|
# This directory must match .babelrc .
|
||||||
transifex_temp = ./temp/babel-plugin-react-intl
|
transifex_temp = ./temp/babel-plugin-react-intl
|
||||||
|
|
||||||
NPM_TESTS=build i18n_extract lint test
|
|
||||||
|
|
||||||
.PHONY: test
|
|
||||||
test: $(addprefix test.npm.,$(NPM_TESTS)) ## validate ci suite
|
|
||||||
|
|
||||||
.PHONY: test.npm.*
|
|
||||||
test.npm.%: validate-no-uncommitted-package-lock-changes
|
|
||||||
test -d node_modules || $(MAKE) requirements
|
|
||||||
npm run $(*)
|
|
||||||
|
|
||||||
.PHONY: requirements
|
|
||||||
|
|
||||||
precommit:
|
precommit:
|
||||||
npm run lint
|
npm run lint
|
||||||
npm audit
|
npm audit
|
||||||
|
|
||||||
requirements: ## install ci requirements
|
requirements:
|
||||||
npm ci
|
npm install
|
||||||
|
|
||||||
i18n.extract:
|
i18n.extract:
|
||||||
# Pulling display strings from .jsx files into .json files...
|
# Pulling display strings from .jsx files into .json files...
|
||||||
@@ -51,17 +38,17 @@ push_translations:
|
|||||||
# Pushing strings to Transifex...
|
# Pushing strings to Transifex...
|
||||||
tx push -s
|
tx push -s
|
||||||
# Fetching hashes from Transifex...
|
# Fetching hashes from Transifex...
|
||||||
./node_modules/@edx/reactifex/bash_scripts/get_hashed_strings_v3.sh
|
./node_modules/reactifex/bash_scripts/get_hashed_strings.sh $(tx_url1)
|
||||||
# Writing out comments to file...
|
# Writing out comments to file...
|
||||||
$(transifex_utils) $(transifex_temp) --comments --v3-scripts-path
|
$(transifex_utils) $(transifex_temp) --comments
|
||||||
# Pushing comments to Transifex...
|
# Pushing comments to Transifex...
|
||||||
./node_modules/@edx/reactifex/bash_scripts/put_comments_v3.sh
|
./node_modules/reactifex/bash_scripts/put_comments.sh $(tx_url2)
|
||||||
|
|
||||||
# Pulls translations from Transifex.
|
# Pulls translations from Transifex.
|
||||||
pull_translations:
|
pull_translations:
|
||||||
tx pull -t -f --mode reviewed --languages=$(transifex_langs)
|
tx pull -f --mode reviewed --language=$(transifex_langs)
|
||||||
|
|
||||||
# This target is used by Travis.
|
# This target is used by Travis.
|
||||||
validate-no-uncommitted-package-lock-changes:
|
validate-no-uncommitted-package-lock-changes:
|
||||||
# Checking for package-lock.json changes...
|
# Checking for package-lock.json changes...
|
||||||
git diff --exit-code package-lock.json
|
git diff --exit-code package-lock.json
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ This repository is a React-based micro frontend for the Open edX discussion foru
|
|||||||
|
|
||||||
1. Clone your new repo:
|
1. Clone your new repo:
|
||||||
|
|
||||||
``git clone https://github.com/openedx/frontend-app-discussions.git``
|
``git clone https://github.com/edx/frontend-app-discussions.git``
|
||||||
|
|
||||||
2. Install npm dependencies:
|
2. Install npm dependencies:
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@ The dev server is running at `http://localhost:2002 <http://localhost:2002>`_.
|
|||||||
Project Structure
|
Project Structure
|
||||||
-----------------
|
-----------------
|
||||||
|
|
||||||
The source for this project is organized into nested submodules according to the ADR `Feature-based Application Organization <https://github.com/openedx/frontend-app-discussions/blob/master/docs/decisions/0002-feature-based-application-organization.rst>`_.
|
The source for this project is organized into nested submodules according to the ADR `Feature-based Application Organization <https://github.com/edx/frontend-app-discussions/blob/master/docs/decisions/0002-feature-based-application-organization.rst>`_.
|
||||||
|
|
||||||
Build Process Notes
|
Build Process Notes
|
||||||
-------------------
|
-------------------
|
||||||
@@ -41,7 +41,7 @@ The production build is created with ``npm run build``.
|
|||||||
Internationalization
|
Internationalization
|
||||||
--------------------
|
--------------------
|
||||||
|
|
||||||
Please see `edx/frontend-platform's i18n module <https://edx.github.io/frontend-platform/module-Internationalization.html>`_ for documentation on internationalization. The documentation explains how to use it, and the `How To <https://github.com/openedx/frontend-i18n/blob/master/docs/how_tos/i18n.rst>`_ has more detail.
|
Please see `edx/frontend-platform's i18n module <https://edx.github.io/frontend-platform/module-Internationalization.html>`_ for documentation on internationalization. The documentation explains how to use it, and the `How To <https://github.com/edx/frontend-i18n/blob/master/docs/how_tos/i18n.rst>`_ has more detail.
|
||||||
|
|
||||||
.. |Build Status| image:: https://api.travis-ci.org/edx/frontend-app-discussions.svg?branch=master
|
.. |Build Status| image:: https://api.travis-ci.org/edx/frontend-app-discussions.svg?branch=master
|
||||||
:target: https://travis-ci.org/edx/frontend-app-discussions
|
:target: https://travis-ci.org/edx/frontend-app-discussions
|
||||||
|
|||||||
@@ -2,4 +2,4 @@
|
|||||||
React App i18n HOWTO
|
React App i18n HOWTO
|
||||||
####################
|
####################
|
||||||
|
|
||||||
This document has moved to the frontend-platform repo: https://github.com/openedx/frontend-platform/blob/master/docs/how_tos/i18n.rst
|
This document has moved to the frontend-platform repo: https://github.com/edx/frontend-platform/blob/master/docs/how_tos/i18n.rst
|
||||||
|
|||||||
@@ -2,8 +2,7 @@ const { createConfig } = require('@edx/frontend-build');
|
|||||||
|
|
||||||
module.exports = createConfig('jest', {
|
module.exports = createConfig('jest', {
|
||||||
// setupFilesAfterEnv is used after the jest environment has been loaded. In general this is what you want.
|
// setupFilesAfterEnv is used after the jest environment has been loaded. In general this is what you want.
|
||||||
// If you want to add config BEFORE jest loads, use setupFiles instead.
|
// If you want to add config BEFORE jest loads, use setupFiles instead.
|
||||||
setupFiles: ['<rootDir>/.jest/setEnvVars.js'],
|
|
||||||
setupFilesAfterEnv: [
|
setupFilesAfterEnv: [
|
||||||
'<rootDir>/src/setupTest.js',
|
'<rootDir>/src/setupTest.js',
|
||||||
],
|
],
|
||||||
|
|||||||
21674
package-lock.json
generated
21674
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
23
package.json
23
package.json
@@ -4,14 +4,16 @@
|
|||||||
"description": "Discussions Frontend",
|
"description": "Discussions Frontend",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "git+https://github.com/openedx/frontend-app-discussions.git"
|
"url": "git+https://github.com/edx/frontend-app-discussions.git"
|
||||||
},
|
},
|
||||||
"browserslist": [
|
"browserslist": [
|
||||||
"extends @edx/browserslist-config"
|
"last 2 versions",
|
||||||
|
"ie 11"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "fedx-scripts webpack",
|
"build": "fedx-scripts webpack",
|
||||||
"i18n_extract": "BABEL_ENV=i18n fedx-scripts babel src --quiet > /dev/null",
|
"i18n_extract": "BABEL_ENV=i18n fedx-scripts babel src --quiet > /dev/null",
|
||||||
|
"is-es5": "es-check es5 ./dist/*.js",
|
||||||
"lint": "fedx-scripts eslint --ext .js --ext .jsx .",
|
"lint": "fedx-scripts eslint --ext .js --ext .jsx .",
|
||||||
"lint:fix": "fedx-scripts eslint --ext .js --ext .jsx . --fix",
|
"lint:fix": "fedx-scripts eslint --ext .js --ext .jsx . --fix",
|
||||||
"snapshot": "fedx-scripts jest --updateSnapshot",
|
"snapshot": "fedx-scripts jest --updateSnapshot",
|
||||||
@@ -25,19 +27,17 @@
|
|||||||
},
|
},
|
||||||
"author": "edX",
|
"author": "edX",
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"homepage": "https://github.com/openedx/frontend-app-discussions#readme",
|
"homepage": "https://github.com/edx/frontend-app-discussions#readme",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public"
|
"access": "public"
|
||||||
},
|
},
|
||||||
"bugs": {
|
"bugs": {
|
||||||
"url": "https://github.com/openedx/frontend-app-discussions/issues"
|
"url": "https://github.com/edx/frontend-app-discussions/issues"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@edx/brand": "npm:@edx/brand-openedx@1.1.0",
|
"@edx/brand": "npm:@edx/brand-openedx@1.1.0",
|
||||||
"@edx/frontend-component-footer": "11.2.0",
|
"@edx/frontend-platform": "1.15.4",
|
||||||
"@edx/frontend-component-header": "3.2.0",
|
"@edx/paragon": "19.10.1",
|
||||||
"@edx/frontend-platform": "2.6.1",
|
|
||||||
"@edx/paragon": "20.15.0",
|
|
||||||
"@reduxjs/toolkit": "1.8.0",
|
"@reduxjs/toolkit": "1.8.0",
|
||||||
"@tinymce/tinymce-react": "3.13.1",
|
"@tinymce/tinymce-react": "3.13.1",
|
||||||
"babel-polyfill": "6.26.0",
|
"babel-polyfill": "6.26.0",
|
||||||
@@ -49,7 +49,6 @@
|
|||||||
"raw-loader": "4.0.2",
|
"raw-loader": "4.0.2",
|
||||||
"react": "16.14.0",
|
"react": "16.14.0",
|
||||||
"react-dom": "16.14.0",
|
"react-dom": "16.14.0",
|
||||||
"react-mathjax-preview": "2.2.6",
|
|
||||||
"react-redux": "7.2.6",
|
"react-redux": "7.2.6",
|
||||||
"react-router": "5.2.1",
|
"react-router": "5.2.1",
|
||||||
"react-router-dom": "5.3.0",
|
"react-router-dom": "5.3.0",
|
||||||
@@ -60,20 +59,18 @@
|
|||||||
"yup": "0.31.1"
|
"yup": "0.31.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@edx/browserslist-config": "1.1.0",
|
"@edx/frontend-build": "9.1.4",
|
||||||
"@edx/frontend-build": "11.0.1",
|
|
||||||
"@edx/reactifex": "1.0.3",
|
|
||||||
"@testing-library/jest-dom": "5.16.2",
|
"@testing-library/jest-dom": "5.16.2",
|
||||||
"@testing-library/react": "12.1.4",
|
"@testing-library/react": "12.1.4",
|
||||||
"@testing-library/user-event": "13.5.0",
|
"@testing-library/user-event": "13.5.0",
|
||||||
"axios-mock-adapter": "1.20.0",
|
"axios-mock-adapter": "1.20.0",
|
||||||
"babel-plugin-react-intl": "8.2.25",
|
|
||||||
"codecov": "3.8.3",
|
"codecov": "3.8.3",
|
||||||
"es-check": "6.2.1",
|
"es-check": "6.2.1",
|
||||||
"eslint-plugin-simple-import-sort": "7.0.0",
|
"eslint-plugin-simple-import-sort": "7.0.0",
|
||||||
"glob": "7.2.0",
|
"glob": "7.2.0",
|
||||||
"husky": "7.0.4",
|
"husky": "7.0.4",
|
||||||
"jest": "27.5.1",
|
"jest": "27.5.1",
|
||||||
|
"reactifex": "1.1.1",
|
||||||
"rosie": "2.1.0"
|
"rosie": "2.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,12 @@
|
|||||||
<!DOCTYPE html>
|
<!doctype html>
|
||||||
<html lang="en-us">
|
<html lang="en-us">
|
||||||
<head>
|
<head>
|
||||||
<title>Discussions | <%= process.env.SITE_NAME %></title>
|
<title>Discussions | <%= process.env.SITE_NAME %></title>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<link
|
<link rel="shortcut icon" href="<%=htmlWebpackPlugin.options.FAVICON_URL%>" type="image/x-icon" />
|
||||||
rel="shortcut icon"
|
|
||||||
href="<%=htmlWebpackPlugin.options.FAVICON_URL%>"
|
|
||||||
type="image/x-icon"
|
|
||||||
/>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="vh-100 vw-100 overflow-hidden">
|
||||||
<div id="root" class="small"></div>
|
<div id="root" class="vh-100 vw-100 small"></div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,201 +0,0 @@
|
|||||||
/* eslint-disable react/forbid-prop-types */
|
|
||||||
import React, { useMemo, useState } from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
|
|
||||||
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,
|
|
||||||
ThreadOrdering, ThreadType,
|
|
||||||
} from '../data/constants';
|
|
||||||
import { selectCourseCohorts } from '../discussions/cohorts/data/selectors';
|
|
||||||
import messages from '../discussions/posts/post-filter-bar/messages';
|
|
||||||
import { ActionItem } from '../discussions/posts/post-filter-bar/PostFilterBar';
|
|
||||||
|
|
||||||
function FilterBar({
|
|
||||||
intl,
|
|
||||||
filters,
|
|
||||||
selectedFilters,
|
|
||||||
onFilterChange,
|
|
||||||
showCohortsFilter,
|
|
||||||
}) {
|
|
||||||
const [isOpen, setOpen] = useState(false);
|
|
||||||
const cohorts = useSelector(selectCourseCohorts);
|
|
||||||
const { status } = useSelector(state => state.cohorts);
|
|
||||||
const selectedCohort = useMemo(() => cohorts.find(cohort => (
|
|
||||||
toString(cohort.id) === selectedFilters.cohort)),
|
|
||||||
[selectedFilters.cohort]);
|
|
||||||
|
|
||||||
const allFilters = [
|
|
||||||
{
|
|
||||||
id: 'type-all',
|
|
||||||
label: intl.formatMessage(messages.allPosts),
|
|
||||||
value: ThreadType.ALL,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'type-discussions',
|
|
||||||
label: intl.formatMessage(messages.filterDiscussions),
|
|
||||||
value: ThreadType.DISCUSSION,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'type-questions',
|
|
||||||
label: intl.formatMessage(messages.filterQuestions),
|
|
||||||
value: ThreadType.QUESTION,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'status-any',
|
|
||||||
label: intl.formatMessage(messages.filterAnyStatus),
|
|
||||||
value: PostsStatusFilter.ALL,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'status-unread',
|
|
||||||
label: intl.formatMessage(messages.filterUnread),
|
|
||||||
value: PostsStatusFilter.UNREAD,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'status-reported',
|
|
||||||
label: intl.formatMessage(messages.filterReported),
|
|
||||||
value: PostsStatusFilter.REPORTED,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'status-unanswered',
|
|
||||||
label: intl.formatMessage(messages.filterUnanswered),
|
|
||||||
value: PostsStatusFilter.UNANSWERED,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'status-unresponded',
|
|
||||||
label: intl.formatMessage(messages.filterUnresponded),
|
|
||||||
value: PostsStatusFilter.UNRESPONDED,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'sort-activity',
|
|
||||||
label: intl.formatMessage(messages.lastActivityAt),
|
|
||||||
value: ThreadOrdering.BY_LAST_ACTIVITY,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'sort-comments',
|
|
||||||
label: intl.formatMessage(messages.commentCount),
|
|
||||||
value: ThreadOrdering.BY_COMMENT_COUNT,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'sort-votes',
|
|
||||||
label: intl.formatMessage(messages.voteCount),
|
|
||||||
value: ThreadOrdering.BY_VOTE_COUNT,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Collapsible.Advanced
|
|
||||||
open={isOpen}
|
|
||||||
onToggle={() => setOpen(!isOpen)}
|
|
||||||
className="filter-bar collapsible-card-lg border-0"
|
|
||||||
>
|
|
||||||
<Collapsible.Trigger className="collapsible-trigger border-0">
|
|
||||||
<span className="text-primary-700 pr-4">
|
|
||||||
{intl.formatMessage(messages.sortFilterStatus, {
|
|
||||||
own: false,
|
|
||||||
type: selectedFilters.type,
|
|
||||||
sort: selectedFilters.orderBy,
|
|
||||||
status: selectedFilters.status,
|
|
||||||
cohortType: selectedCohort?.name ? 'group' : 'all',
|
|
||||||
cohort: capitalize(selectedCohort?.name),
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
<Collapsible.Visible whenClosed>
|
|
||||||
<Icon src={Tune} />
|
|
||||||
</Collapsible.Visible>
|
|
||||||
<Collapsible.Visible whenOpen>
|
|
||||||
<Icon src={Tune} />
|
|
||||||
</Collapsible.Visible>
|
|
||||||
</Collapsible.Trigger>
|
|
||||||
<Collapsible.Body className="collapsible-body px-4 pb-3 pt-0">
|
|
||||||
<Form>
|
|
||||||
<div className="d-flex flex-row py-2 justify-content-between">
|
|
||||||
{filters.map((value) => (
|
|
||||||
<Form.RadioSet
|
|
||||||
name={value.name}
|
|
||||||
className="d-flex flex-column list-group list-group-flush"
|
|
||||||
value={selectedFilters[value.name]}
|
|
||||||
onChange={onFilterChange}
|
|
||||||
>
|
|
||||||
{
|
|
||||||
value.filters.map(filterName => {
|
|
||||||
const element = allFilters.find(obj => obj.id === filterName);
|
|
||||||
if (element) {
|
|
||||||
return (
|
|
||||||
<ActionItem
|
|
||||||
id={element.id}
|
|
||||||
label={element.label}
|
|
||||||
value={element.value}
|
|
||||||
selected={selectedFilters[value.name]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
</Form.RadioSet>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{showCohortsFilter && (
|
|
||||||
<>
|
|
||||||
<div className="border-bottom my-2" />
|
|
||||||
{status === RequestStatus.IN_PROGRESS ? (
|
|
||||||
<div className="d-flex justify-content-center p-4">
|
|
||||||
<Spinner animation="border" variant="primary" size="lg" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="d-flex flex-row pt-2">
|
|
||||||
<Form.RadioSet
|
|
||||||
name="cohort"
|
|
||||||
className="d-flex flex-column list-group list-group-flush w-100"
|
|
||||||
value={selectedFilters.cohort}
|
|
||||||
onChange={onFilterChange}
|
|
||||||
>
|
|
||||||
<ActionItem
|
|
||||||
id="all-groups"
|
|
||||||
label="All groups"
|
|
||||||
value=""
|
|
||||||
selected={selectedFilters.cohort}
|
|
||||||
/>
|
|
||||||
{cohorts.map(cohort => (
|
|
||||||
<ActionItem
|
|
||||||
key={toString(cohort.id)}
|
|
||||||
id={toString(cohort.id)}
|
|
||||||
label={capitalize(cohort.name)}
|
|
||||||
value={toString(cohort.id)}
|
|
||||||
selected={selectedFilters.cohort}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Form.RadioSet>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Form>
|
|
||||||
</Collapsible.Body>
|
|
||||||
</Collapsible.Advanced>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
FilterBar.propTypes = {
|
|
||||||
intl: intlShape.isRequired,
|
|
||||||
filters: PropTypes.array.isRequired,
|
|
||||||
selectedFilters: PropTypes.object.isRequired,
|
|
||||||
onFilterChange: PropTypes.func.isRequired,
|
|
||||||
showCohortsFilter: PropTypes.bool,
|
|
||||||
};
|
|
||||||
|
|
||||||
FilterBar.defaultProps = {
|
|
||||||
showCohortsFilter: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default injectIntl(FilterBar);
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
|
|
||||||
import MathJax from 'react-mathjax-preview';
|
|
||||||
|
|
||||||
const baseConfig = {
|
|
||||||
showMathMenu: true,
|
|
||||||
tex2jax: {
|
|
||||||
inlineMath: [
|
|
||||||
['$', '$'],
|
|
||||||
['\\\\(', '\\\\)'],
|
|
||||||
['\\(', '\\)'],
|
|
||||||
['[mathjaxinline]', '[/mathjaxinline]'],
|
|
||||||
['\\begin{math}', '\\end{math}'],
|
|
||||||
],
|
|
||||||
displayMath: [
|
|
||||||
['[mathjax]', '[/mathjax]'],
|
|
||||||
['$$', '$$'],
|
|
||||||
['\\\\[', '\\\\]'],
|
|
||||||
['\\[', '\\]'],
|
|
||||||
['\\begin{displaymath}', '\\end{displaymath}'],
|
|
||||||
['\\begin{equation}', '\\end{equation}'],
|
|
||||||
],
|
|
||||||
},
|
|
||||||
|
|
||||||
skipStartupTypeset: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
function HTMLLoader({ htmlNode, componentId, cssClassName }) {
|
|
||||||
const isLatex = htmlNode.match(/(\${1,2})((?:\\.|.)*)\1/)
|
|
||||||
|| htmlNode.match(/(\[mathjax](.+?)\[\/mathjax])+/)
|
|
||||||
|| htmlNode.match(/(\[mathjaxinline](.+?)\[\/mathjaxinline])+/)
|
|
||||||
|| htmlNode.match(/(\\begin\{math}(.+?)\\end\{math})+/)
|
|
||||||
|| htmlNode.match(/(\\begin\{displaymath}(.+?)\\end\{displaymath})+/)
|
|
||||||
|| htmlNode.match(/(\\begin\{equation}(.+?)\\end\{equation})+/)
|
|
||||||
|| htmlNode.match(/(\\\[(.+?)\\\])+/)
|
|
||||||
|| htmlNode.match(/(\\\((.+?)\\\))+/);
|
|
||||||
|
|
||||||
return (
|
|
||||||
isLatex ? (
|
|
||||||
<MathJax
|
|
||||||
math={htmlNode}
|
|
||||||
id={componentId}
|
|
||||||
className={cssClassName}
|
|
||||||
sanitizeOptions={{ USE_PROFILES: { html: true } }}
|
|
||||||
config={baseConfig}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
// eslint-disable-next-line react/no-danger
|
|
||||||
: <div className={cssClassName} id={componentId} dangerouslySetInnerHTML={{ __html: htmlNode }} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
HTMLLoader.propTypes = {
|
|
||||||
htmlNode: PropTypes.node,
|
|
||||||
componentId: PropTypes.string,
|
|
||||||
cssClassName: PropTypes.string,
|
|
||||||
};
|
|
||||||
|
|
||||||
HTMLLoader.defaultProps = {
|
|
||||||
htmlNode: '',
|
|
||||||
componentId: null,
|
|
||||||
cssClassName: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
export default HTMLLoader;
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
import React, { useEffect } from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
|
|
||||||
import classNames from 'classnames';
|
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
|
||||||
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
|
||||||
|
|
||||||
import { fetchTab } from './data/thunks';
|
|
||||||
import Tabs from './tabs/Tabs';
|
|
||||||
import messages from './messages';
|
|
||||||
|
|
||||||
import './navBar.scss';
|
|
||||||
|
|
||||||
function CourseTabsNavigation({
|
|
||||||
activeTab, className, intl, courseId, rootSlug,
|
|
||||||
}) {
|
|
||||||
const dispatch = useDispatch();
|
|
||||||
|
|
||||||
const tabs = useSelector(state => state.courseTabs.tabs);
|
|
||||||
useEffect(() => {
|
|
||||||
dispatch(fetchTab(courseId, rootSlug));
|
|
||||||
}, [courseId]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div id="courseTabsNavigation" className={classNames('course-tabs-navigation', className)}>
|
|
||||||
<div className="container-xl">
|
|
||||||
{!!tabs.length
|
|
||||||
&& (
|
|
||||||
<Tabs
|
|
||||||
className="nav-underline-tabs"
|
|
||||||
aria-label={intl.formatMessage(messages.courseMaterial)}
|
|
||||||
>
|
|
||||||
{tabs.map(({ url, title, slug }) => (
|
|
||||||
<a
|
|
||||||
key={slug}
|
|
||||||
className={classNames('nav-item flex-shrink-0 nav-link', { active: slug === activeTab })}
|
|
||||||
href={url}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</Tabs>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
CourseTabsNavigation.propTypes = {
|
|
||||||
activeTab: PropTypes.string,
|
|
||||||
className: PropTypes.string,
|
|
||||||
rootSlug: PropTypes.string,
|
|
||||||
courseId: PropTypes.string.isRequired,
|
|
||||||
intl: intlShape.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
CourseTabsNavigation.defaultProps = {
|
|
||||||
activeTab: undefined,
|
|
||||||
className: null,
|
|
||||||
rootSlug: 'outline',
|
|
||||||
};
|
|
||||||
|
|
||||||
export default injectIntl(CourseTabsNavigation);
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
/* eslint-disable import/prefer-default-export */
|
|
||||||
import { camelCaseObject } from '@edx/frontend-platform';
|
|
||||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
|
||||||
|
|
||||||
import { API_BASE_URL } from '../../../data/constants';
|
|
||||||
|
|
||||||
function normalizeCourseHomeCourseMetadata(metadata, rootSlug) {
|
|
||||||
const data = camelCaseObject(metadata);
|
|
||||||
return {
|
|
||||||
...data,
|
|
||||||
tabs: data.tabs.map(tab => ({
|
|
||||||
// The API uses "courseware" as a slug for both courseware and the outline tab.
|
|
||||||
// If needed, we switch it to "outline" here for
|
|
||||||
// use within the MFE to differentiate between course home and courseware.
|
|
||||||
slug: tab.tabId === 'courseware' ? rootSlug : tab.tabId,
|
|
||||||
title: tab.title,
|
|
||||||
url: tab.url,
|
|
||||||
})),
|
|
||||||
isMasquerading: data.originalUserIsStaff && !data.isStaff,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getCourseHomeCourseMetadata(courseId, rootSlug) {
|
|
||||||
const url = `${API_BASE_URL}/api/course_home/course_metadata/${courseId}`;
|
|
||||||
// don't know the context of adding timezone in url. hence omitting it
|
|
||||||
// url = appendBrowserTimezoneToUrl(url);
|
|
||||||
const { data } = await getAuthenticatedHttpClient().get(url);
|
|
||||||
return normalizeCourseHomeCourseMetadata(data, rootSlug);
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export * from './slice';
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
/* eslint-disable no-param-reassign */
|
|
||||||
import { createSlice } from '@reduxjs/toolkit';
|
|
||||||
|
|
||||||
export const LOADING = 'loading';
|
|
||||||
export const LOADED = 'loaded';
|
|
||||||
export const FAILED = 'failed';
|
|
||||||
export const DENIED = 'denied';
|
|
||||||
|
|
||||||
const slice = createSlice({
|
|
||||||
name: 'courseTabs',
|
|
||||||
initialState: {
|
|
||||||
courseStatus: 'loading',
|
|
||||||
courseId: null,
|
|
||||||
tabs: [],
|
|
||||||
courseTitle: null,
|
|
||||||
courseNumber: null,
|
|
||||||
org: null,
|
|
||||||
},
|
|
||||||
reducers: {
|
|
||||||
fetchTabDenied: (state, { payload }) => {
|
|
||||||
state.courseId = payload.courseId;
|
|
||||||
state.courseStatus = DENIED;
|
|
||||||
},
|
|
||||||
fetchTabFailure: (state, { payload }) => {
|
|
||||||
state.courseId = payload.courseId;
|
|
||||||
state.courseStatus = FAILED;
|
|
||||||
},
|
|
||||||
fetchTabRequest: (state, { payload }) => {
|
|
||||||
state.courseId = payload.courseId;
|
|
||||||
state.courseStatus = LOADING;
|
|
||||||
},
|
|
||||||
fetchTabSuccess: (state, { payload }) => {
|
|
||||||
state.courseId = payload.courseId;
|
|
||||||
state.targetUserId = payload.targetUserId;
|
|
||||||
state.tabs = payload.tabs;
|
|
||||||
state.courseStatus = LOADED;
|
|
||||||
state.courseTitle = payload.courseTitle;
|
|
||||||
state.courseNumber = payload.courseNumber;
|
|
||||||
state.org = payload.org;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const {
|
|
||||||
fetchTabDenied,
|
|
||||||
fetchTabFailure,
|
|
||||||
fetchTabRequest,
|
|
||||||
fetchTabSuccess,
|
|
||||||
} = slice.actions;
|
|
||||||
|
|
||||||
export const courseTabsReducer = slice.reducer;
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
/* eslint-disable import/prefer-default-export, no-unused-expressions */
|
|
||||||
import { logError } from '@edx/frontend-platform/logging';
|
|
||||||
|
|
||||||
import { getCourseHomeCourseMetadata } from './api';
|
|
||||||
import {
|
|
||||||
fetchTabDenied,
|
|
||||||
fetchTabFailure,
|
|
||||||
fetchTabRequest,
|
|
||||||
fetchTabSuccess,
|
|
||||||
} from './slice';
|
|
||||||
|
|
||||||
export function fetchTab(courseId, rootSlug) {
|
|
||||||
return async (dispatch) => {
|
|
||||||
dispatch(fetchTabRequest({ courseId }));
|
|
||||||
try {
|
|
||||||
const courseHomeCourseMetadata = await getCourseHomeCourseMetadata(courseId, rootSlug);
|
|
||||||
if (!courseHomeCourseMetadata.courseAccess.hasAccess) {
|
|
||||||
dispatch(fetchTabDenied({ courseId }));
|
|
||||||
} else {
|
|
||||||
dispatch(fetchTabSuccess({
|
|
||||||
courseId,
|
|
||||||
tabs: courseHomeCourseMetadata.tabs,
|
|
||||||
org: courseHomeCourseMetadata.org,
|
|
||||||
courseNumber: courseHomeCourseMetadata.number,
|
|
||||||
courseTitle: courseHomeCourseMetadata.title,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
dispatch(fetchTabFailure({ courseId }));
|
|
||||||
logError(e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
/* eslint-disable import/prefer-default-export */
|
|
||||||
export { default as CourseTabsNavigation } from './CourseTabsNavigation';
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
courseMaterial: {
|
|
||||||
id: 'navigation.course.tabs.label',
|
|
||||||
defaultMessage: 'Course Material',
|
|
||||||
description: 'The accessible label for course tabs navigation',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default messages;
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
@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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav a {
|
|
||||||
&:not(.active):hover {
|
|
||||||
background-color: $light-400;
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-underline-tabs {
|
|
||||||
margin: 0 0 -1px;
|
|
||||||
|
|
||||||
.nav-link {
|
|
||||||
border-bottom: 4px solid transparent;
|
|
||||||
border-top: 4px solid transparent;
|
|
||||||
color: $gray-700;
|
|
||||||
|
|
||||||
// temporary until we can remove .btn class from dropdowns
|
|
||||||
border-left: 0;
|
|
||||||
border-right: 0;
|
|
||||||
border-radius: 0;
|
|
||||||
|
|
||||||
&:hover,
|
|
||||||
&:focus,
|
|
||||||
&.active {
|
|
||||||
font-weight: $font-weight-normal;
|
|
||||||
color: $primary-500;
|
|
||||||
border-bottom-color: $primary-500;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
import React, { useMemo } from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
|
|
||||||
import classNames from 'classnames';
|
|
||||||
|
|
||||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
|
||||||
import { Dropdown } from '@edx/paragon';
|
|
||||||
|
|
||||||
import useIndexOfLastVisibleChild from './useIndexOfLastVisibleChild';
|
|
||||||
|
|
||||||
export default function Tabs({ children, className, ...attrs }) {
|
|
||||||
const [
|
|
||||||
indexOfLastVisibleChild,
|
|
||||||
containerElementRef,
|
|
||||||
invisibleStyle,
|
|
||||||
overflowElementRef,
|
|
||||||
] = useIndexOfLastVisibleChild();
|
|
||||||
|
|
||||||
const tabChildren = useMemo(() => {
|
|
||||||
const childrenArray = React.Children.toArray(children);
|
|
||||||
const indexOfOverflowStart = indexOfLastVisibleChild + 1;
|
|
||||||
|
|
||||||
// All tabs will be rendered. Those that would overflow are set to invisible.
|
|
||||||
const wrappedChildren = childrenArray.map((child, index) => React.cloneElement(child, {
|
|
||||||
style: index > indexOfLastVisibleChild ? invisibleStyle : null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Build the list of items to put in the overflow menu
|
|
||||||
const overflowChildren = childrenArray.slice(indexOfOverflowStart)
|
|
||||||
.map(overflowChild => React.cloneElement(overflowChild, { className: 'dropdown-item' }));
|
|
||||||
|
|
||||||
// Insert the overflow menu at the cut off index (even if it will be hidden
|
|
||||||
// it so it can be part of measurements)
|
|
||||||
wrappedChildren.splice(indexOfOverflowStart, 0, (
|
|
||||||
<div
|
|
||||||
className="nav-item flex-shrink-0"
|
|
||||||
style={indexOfOverflowStart >= React.Children.count(children) ? invisibleStyle : null}
|
|
||||||
ref={overflowElementRef}
|
|
||||||
key="overflow"
|
|
||||||
>
|
|
||||||
<Dropdown className="h-100">
|
|
||||||
<Dropdown.Toggle variant="link" className="nav-link h-100" id="learn.course.tabs.navigation.overflow.menu">
|
|
||||||
<FormattedMessage
|
|
||||||
id="learn.course.tabs.navigation.overflow.menu"
|
|
||||||
description="The title of the overflow menu for course tabs"
|
|
||||||
defaultMessage="More..."
|
|
||||||
/>
|
|
||||||
</Dropdown.Toggle>
|
|
||||||
<Dropdown.Menu className="dropdown-menu-right">{overflowChildren}</Dropdown.Menu>
|
|
||||||
</Dropdown>
|
|
||||||
</div>
|
|
||||||
));
|
|
||||||
return wrappedChildren;
|
|
||||||
}, [children, indexOfLastVisibleChild]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<nav
|
|
||||||
{...attrs}
|
|
||||||
className={classNames('nav flex-nowrap', className)}
|
|
||||||
ref={containerElementRef}
|
|
||||||
>
|
|
||||||
{tabChildren}
|
|
||||||
</nav>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Tabs.propTypes = {
|
|
||||||
children: PropTypes.node,
|
|
||||||
className: PropTypes.string,
|
|
||||||
};
|
|
||||||
|
|
||||||
Tabs.defaultProps = {
|
|
||||||
children: null,
|
|
||||||
className: undefined,
|
|
||||||
};
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
import { render, screen } from '@testing-library/react';
|
|
||||||
import { IntlProvider } from 'react-intl';
|
|
||||||
|
|
||||||
import { initializeMockApp } from '@edx/frontend-platform';
|
|
||||||
|
|
||||||
import Tabs from './Tabs';
|
|
||||||
import useIndexOfLastVisibleChild from './useIndexOfLastVisibleChild';
|
|
||||||
|
|
||||||
jest.mock('./useIndexOfLastVisibleChild');
|
|
||||||
|
|
||||||
describe('Tabs', () => {
|
|
||||||
const mockChildren = [...Array(4).keys()].map(i => (<button key={i} type="button">{`Item ${i}`}</button>));
|
|
||||||
// Only half of the children will be visible. The rest of them will be in the dropdown.
|
|
||||||
const indexOfLastVisibleChild = mockChildren.length / 2 - 1;
|
|
||||||
|
|
||||||
const invisibleStyle = { visibility: 'hidden' };
|
|
||||||
useIndexOfLastVisibleChild.mockReturnValue([indexOfLastVisibleChild, null, invisibleStyle, null]);
|
|
||||||
|
|
||||||
function renderComponent(children = null) {
|
|
||||||
render(
|
|
||||||
<IntlProvider locale="en">
|
|
||||||
<Tabs>
|
|
||||||
{children}
|
|
||||||
</Tabs>
|
|
||||||
</IntlProvider>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
initializeMockApp({
|
|
||||||
authenticatedUser: {
|
|
||||||
userId: 3,
|
|
||||||
username: 'abc123',
|
|
||||||
administrator: true,
|
|
||||||
roles: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders without children', async () => {
|
|
||||||
renderComponent();
|
|
||||||
expect(screen.getByRole('button', { text: 'More...', hidden: true })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('hides invisible children', async () => {
|
|
||||||
renderComponent(mockChildren);
|
|
||||||
// adding hidden property is necessary because everything enclosed in a div with property hidden
|
|
||||||
const allButtons = screen.getAllByRole('button', { hidden: true });
|
|
||||||
expect(screen.getAllByRole('button', { hidden: false })).toHaveLength(3);
|
|
||||||
[...Array(mockChildren.length).keys()].forEach(i => {
|
|
||||||
if (i <= indexOfLastVisibleChild + 1) {
|
|
||||||
expect(allButtons[i]).not.toHaveAttribute('style');
|
|
||||||
} else {
|
|
||||||
expect(allButtons[i]).toHaveStyle('visibility: hidden;');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
import { useLayoutEffect, useRef, useState } from 'react';
|
|
||||||
|
|
||||||
import { useWindowSize } from '@edx/paragon';
|
|
||||||
|
|
||||||
const invisibleStyle = {
|
|
||||||
position: 'absolute',
|
|
||||||
left: 0,
|
|
||||||
pointerEvents: 'none',
|
|
||||||
visibility: 'hidden',
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This hook will find the index of the last child of a containing element
|
|
||||||
* that fits within its bounding rectangle. This is done by summing the widths
|
|
||||||
* of the children until they exceed the width of the container.
|
|
||||||
*
|
|
||||||
* The hook returns an array containing:
|
|
||||||
* [indexOfLastVisibleChild, containerElementRef, invisibleStyle, overflowElementRef]
|
|
||||||
*
|
|
||||||
* indexOfLastVisibleChild - the index of the last visible child
|
|
||||||
* containerElementRef - a ref to be added to the containing html node
|
|
||||||
* invisibleStyle - a set of styles to be applied to child of the containing node
|
|
||||||
* if it needs to be hidden. These styles remove the element visually, from
|
|
||||||
* screen readers, and from normal layout flow. But, importantly, these styles
|
|
||||||
* preserve the width of the element, so that future width calculations will
|
|
||||||
* still be accurate.
|
|
||||||
* overflowElementRef - a ref to be added to an html node inside the container
|
|
||||||
* that is likely to be used to contain a "More" type dropdown or other
|
|
||||||
* mechanism to reveal hidden children. The width of this element is always
|
|
||||||
* included when determining which children will fit or not. Usage of this ref
|
|
||||||
* is optional.
|
|
||||||
*/
|
|
||||||
export default function useIndexOfLastVisibleChild() {
|
|
||||||
const containerElementRef = useRef(null);
|
|
||||||
const overflowElementRef = useRef(null);
|
|
||||||
const containingRectRef = useRef({});
|
|
||||||
const [indexOfLastVisibleChild, setIndexOfLastVisibleChild] = useState(-1);
|
|
||||||
const windowSize = useWindowSize();
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
const containingRect = containerElementRef.current.getBoundingClientRect();
|
|
||||||
|
|
||||||
// No-op if the width is unchanged.
|
|
||||||
// (Assumes tabs themselves don't change count or width).
|
|
||||||
if (!containingRect.width === containingRectRef.current.width) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Update for future comparison
|
|
||||||
containingRectRef.current = containingRect;
|
|
||||||
|
|
||||||
// Get array of child nodes from NodeList form
|
|
||||||
const childNodesArr = Array.prototype.slice.call(containerElementRef.current.children);
|
|
||||||
const { nextIndexOfLastVisibleChild } = childNodesArr
|
|
||||||
// filter out the overflow element
|
|
||||||
.filter(childNode => childNode !== overflowElementRef.current)
|
|
||||||
// sum the widths to find the last visible element's index
|
|
||||||
.reduce((acc, childNode, index) => {
|
|
||||||
// use floor to prevent rounding errors
|
|
||||||
acc.sumWidth += Math.floor(childNode.getBoundingClientRect().width);
|
|
||||||
if (acc.sumWidth <= containingRect.width) {
|
|
||||||
acc.nextIndexOfLastVisibleChild = index;
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
}, {
|
|
||||||
// Include the overflow element's width to begin with. Doing this means
|
|
||||||
// sometimes we'll show a dropdown with one item in it when it would fit,
|
|
||||||
// but allowing this case dramatically simplifies the calculations we need
|
|
||||||
// to do above.
|
|
||||||
sumWidth: overflowElementRef.current ? overflowElementRef.current.getBoundingClientRect().width : 0,
|
|
||||||
nextIndexOfLastVisibleChild: -1,
|
|
||||||
});
|
|
||||||
|
|
||||||
setIndexOfLastVisibleChild(nextIndexOfLastVisibleChild);
|
|
||||||
}, [windowSize, containerElementRef.current]);
|
|
||||||
|
|
||||||
return [indexOfLastVisibleChild, containerElementRef, invisibleStyle, overflowElementRef];
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
|
|
||||||
import { injectIntl, intlShape } 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';
|
|
||||||
|
|
||||||
function PostPreviewPane({
|
|
||||||
htmlNode, intl, isPost, editExisting,
|
|
||||||
}) {
|
|
||||||
const [showPreviewPane, setShowPreviewPane] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{showPreviewPane && (
|
|
||||||
<div
|
|
||||||
className={`w-100 p-2 bg-light-200 rounded box-shadow-down-1 post-preview ${isPost ? 'mt-2 mb-5' : 'my-3'}`}
|
|
||||||
style={{ minHeight: '200px', wordBreak: 'break-word' }}
|
|
||||||
>
|
|
||||||
<IconButton
|
|
||||||
onClick={() => setShowPreviewPane(false)}
|
|
||||||
alt={intl.formatMessage(messages.actionsAlt)}
|
|
||||||
src={Close}
|
|
||||||
iconAs={Icon}
|
|
||||||
size="inline"
|
|
||||||
className="float-right p-3"
|
|
||||||
iconClassNames="icon-size"
|
|
||||||
/>
|
|
||||||
<HTMLLoader htmlNode={htmlNode} cssClassName="text-primary" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="d-flex justify-content-end">
|
|
||||||
{!showPreviewPane
|
|
||||||
&& (
|
|
||||||
<Button
|
|
||||||
variant="link"
|
|
||||||
size="md"
|
|
||||||
onClick={() => setShowPreviewPane(true)}
|
|
||||||
className={`text-primary-500 px-0 ${editExisting && 'mb-4.5'}`}
|
|
||||||
>
|
|
||||||
{intl.formatMessage(messages.showPreviewButton)}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
PostPreviewPane.propTypes = {
|
|
||||||
intl: intlShape.isRequired,
|
|
||||||
htmlNode: PropTypes.node.isRequired,
|
|
||||||
isPost: PropTypes.bool,
|
|
||||||
editExisting: PropTypes.bool,
|
|
||||||
};
|
|
||||||
|
|
||||||
PostPreviewPane.defaultProps = {
|
|
||||||
isPost: false,
|
|
||||||
editExisting: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default injectIntl(PostPreviewPane);
|
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import React, {
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
} from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
|
function ScrollThreshold({ onScroll }) {
|
||||||
|
const elementRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!elementRef.current) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// create the observer
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
onScroll();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
observer.observe(elementRef.current);
|
||||||
|
|
||||||
|
// cleanup callback
|
||||||
|
return () => {
|
||||||
|
observer.disconnect();
|
||||||
|
};
|
||||||
|
}, [elementRef]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={elementRef} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ScrollThreshold.propTypes = {
|
||||||
|
onScroll: PropTypes.func.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ScrollThreshold;
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
import React, { useContext, useEffect } from 'react';
|
|
||||||
|
|
||||||
import camelCase from 'lodash/camelCase';
|
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
|
||||||
|
|
||||||
import { injectIntl, intlShape } 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';
|
|
||||||
import { setSearchQuery } from '../discussions/posts/data';
|
|
||||||
import postsMessages from '../discussions/posts/post-actions-bar/messages';
|
|
||||||
import { setFilter as setTopicFilter } from '../discussions/topics/data/slices';
|
|
||||||
|
|
||||||
function Search({ intl }) {
|
|
||||||
const dispatch = useDispatch();
|
|
||||||
const { page } = useContext(DiscussionContext);
|
|
||||||
const postSearch = useSelector(({ threads }) => threads.filters.search);
|
|
||||||
const topicSearch = useSelector(({ topics }) => topics.filter);
|
|
||||||
const learnerSearch = useSelector(({ learners }) => learners.usernameSearch);
|
|
||||||
const isPostSearch = ['posts', 'my-posts'].includes(page);
|
|
||||||
const isTopicSearch = 'topics'.includes(page);
|
|
||||||
let searchValue = '';
|
|
||||||
let currentValue = '';
|
|
||||||
if (isPostSearch) {
|
|
||||||
currentValue = postSearch;
|
|
||||||
} else if (isTopicSearch) {
|
|
||||||
currentValue = topicSearch;
|
|
||||||
} else {
|
|
||||||
currentValue = learnerSearch;
|
|
||||||
}
|
|
||||||
|
|
||||||
const onClear = () => {
|
|
||||||
dispatch(setSearchQuery(''));
|
|
||||||
dispatch(setTopicFilter(''));
|
|
||||||
dispatch(setUsernameSearch(''));
|
|
||||||
};
|
|
||||||
|
|
||||||
const onChange = (query) => {
|
|
||||||
searchValue = query;
|
|
||||||
};
|
|
||||||
|
|
||||||
const onSubmit = (query) => {
|
|
||||||
if (query === '') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (isPostSearch) {
|
|
||||||
dispatch(setSearchQuery(query));
|
|
||||||
} else if (page === 'topics') {
|
|
||||||
dispatch(setTopicFilter(query));
|
|
||||||
} else if (page === 'learners') {
|
|
||||||
dispatch(setUsernameSearch(query));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => onClear(), [page]);
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<SearchField.Advanced
|
|
||||||
onClear={onClear}
|
|
||||||
onChange={onChange}
|
|
||||||
onSubmit={onSubmit}
|
|
||||||
value={currentValue}
|
|
||||||
>
|
|
||||||
<SearchField.Label />
|
|
||||||
<SearchField.Input
|
|
||||||
style={{ paddingRight: '1rem' }}
|
|
||||||
placeholder={intl.formatMessage(postsMessages.search, { page: camelCase(page) })}
|
|
||||||
/>
|
|
||||||
<span className="mt-auto mb-auto mr-2.5 pointer-cursor-hover">
|
|
||||||
<Icon
|
|
||||||
src={SearchIcon}
|
|
||||||
onClick={() => onSubmit(searchValue)}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</SearchField.Advanced>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Search.propTypes = {
|
|
||||||
intl: intlShape.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default injectIntl(Search);
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
|
|
||||||
import { injectIntl, intlShape } 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';
|
|
||||||
|
|
||||||
function SearchInfo({
|
|
||||||
intl,
|
|
||||||
count,
|
|
||||||
text,
|
|
||||||
loadingStatus,
|
|
||||||
onClear,
|
|
||||||
textSearchRewrite,
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="d-flex flex-row border-bottom border-light-400">
|
|
||||||
<Icon src={Search} className="justify-content-start ml-3.5 mr-2 mb-2 mt-2.5" />
|
|
||||||
<Button variant="" size="inline" className="text-justify p-2">
|
|
||||||
{loadingStatus === RequestStatus.SUCCESSFUL && (
|
|
||||||
textSearchRewrite ? intl.formatMessage(messages.searchRewriteInfo, {
|
|
||||||
searchString: text,
|
|
||||||
count,
|
|
||||||
textSearchRewrite,
|
|
||||||
})
|
|
||||||
: intl.formatMessage(messages.searchInfo, { count, text })
|
|
||||||
)}
|
|
||||||
{loadingStatus !== RequestStatus.SUCCESSFUL && intl.formatMessage(messages.searchInfoSearching)}
|
|
||||||
</Button>
|
|
||||||
<Button variant="link" size="inline" className="ml-auto mr-3" onClick={onClear} style={{ minWidth: '26%' }}>
|
|
||||||
{intl.formatMessage(messages.clearSearch)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
SearchInfo.propTypes = {
|
|
||||||
intl: intlShape.isRequired,
|
|
||||||
count: PropTypes.number.isRequired,
|
|
||||||
text: PropTypes.string.isRequired,
|
|
||||||
loadingStatus: PropTypes.string.isRequired,
|
|
||||||
textSearchRewrite: PropTypes.string,
|
|
||||||
onClear: PropTypes.func,
|
|
||||||
};
|
|
||||||
|
|
||||||
SearchInfo.defaultProps = {
|
|
||||||
onClear: () => {},
|
|
||||||
textSearchRewrite: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default injectIntl(SearchInfo);
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { Editor } from '@tinymce/tinymce-react';
|
import { Editor } from '@tinymce/tinymce-react';
|
||||||
import { useParams } from 'react-router';
|
import { useParams } from 'react-router';
|
||||||
@@ -6,11 +6,6 @@ import { useParams } from 'react-router';
|
|||||||
// eslint-disable-next-line no-unused-vars,import/no-extraneous-dependencies
|
// eslint-disable-next-line no-unused-vars,import/no-extraneous-dependencies
|
||||||
import tinymce from 'tinymce/tinymce';
|
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';
|
|
||||||
import { uploadFile } from '../discussions/posts/data/api';
|
import { uploadFile } from '../discussions/posts/data/api';
|
||||||
|
|
||||||
import 'tinymce/plugins/code';
|
import 'tinymce/plugins/code';
|
||||||
@@ -22,16 +17,12 @@ import 'tinymce/icons/default';
|
|||||||
import 'tinymce/skins/ui/oxide/skin.css';
|
import 'tinymce/skins/ui/oxide/skin.css';
|
||||||
// importing the plugin js.
|
// importing the plugin js.
|
||||||
import 'tinymce/plugins/autolink';
|
import 'tinymce/plugins/autolink';
|
||||||
import 'tinymce/plugins/autoresize';
|
|
||||||
import 'tinymce/plugins/autosave';
|
import 'tinymce/plugins/autosave';
|
||||||
import 'tinymce/plugins/codesample';
|
import 'tinymce/plugins/codesample';
|
||||||
import 'tinymce/plugins/image';
|
import 'tinymce/plugins/image';
|
||||||
import 'tinymce/plugins/imagetools';
|
import 'tinymce/plugins/imagetools';
|
||||||
import 'tinymce/plugins/link';
|
import 'tinymce/plugins/link';
|
||||||
import 'tinymce/plugins/lists';
|
import 'tinymce/plugins/lists';
|
||||||
import 'tinymce/plugins/emoticons';
|
|
||||||
import 'tinymce/plugins/emoticons/js/emojis';
|
|
||||||
import 'tinymce/plugins/charmap';
|
|
||||||
/* eslint import/no-webpack-loader-syntax: off */
|
/* eslint import/no-webpack-loader-syntax: off */
|
||||||
// eslint-disable-next-line import/no-unresolved
|
// eslint-disable-next-line import/no-unresolved
|
||||||
import edxBrandCss from '!!raw-loader!sass-loader!../index.scss';
|
import edxBrandCss from '!!raw-loader!sass-loader!../index.scss';
|
||||||
@@ -40,7 +31,6 @@ import contentCss from '!!raw-loader!tinymce/skins/content/default/content.min.c
|
|||||||
// eslint-disable-next-line import/no-unresolved
|
// eslint-disable-next-line import/no-unresolved
|
||||||
import contentUiCss from '!!raw-loader!tinymce/skins/ui/oxide/content.min.css';
|
import contentUiCss from '!!raw-loader!tinymce/skins/ui/oxide/content.min.css';
|
||||||
|
|
||||||
/* istanbul ignore next */
|
|
||||||
const setup = (editor) => {
|
const setup = (editor) => {
|
||||||
editor.ui.registry.addButton('openedx_code', {
|
editor.ui.registry.addButton('openedx_code', {
|
||||||
icon: 'sourcecode',
|
icon: 'sourcecode',
|
||||||
@@ -56,29 +46,17 @@ const setup = (editor) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/* istanbul ignore next */
|
|
||||||
export default function TinyMCEEditor(props) {
|
export default function TinyMCEEditor(props) {
|
||||||
// note that skin and content_css is disabled to avoid the normal
|
// note that skin and content_css is disabled to avoid the normal
|
||||||
// loading process and is instead loaded as a string via content_style
|
// loading process and is instead loaded as a string via content_style
|
||||||
|
|
||||||
const { courseId, postId } = useParams();
|
const { courseId, postId } = useParams();
|
||||||
const [showImageWarning, setShowImageWarning] = useState(false);
|
|
||||||
const intl = useIntl();
|
|
||||||
const uploadHandler = async (blobInfo, success, failure) => {
|
const uploadHandler = async (blobInfo, success, failure) => {
|
||||||
try {
|
try {
|
||||||
const blob = blobInfo.blob();
|
const blob = blobInfo.blob();
|
||||||
const imageSize = blobInfo.blob().size / 1024;
|
|
||||||
if (imageSize > MAX_UPLOAD_FILE_SIZE) {
|
|
||||||
failure(`Images size should not exceed ${MAX_UPLOAD_FILE_SIZE} KB`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const filename = blobInfo.filename();
|
const filename = blobInfo.filename();
|
||||||
const { location } = await uploadFile(blob, filename, courseId, postId || 'root');
|
const { location } = await uploadFile(blob, filename, courseId, postId || 'root');
|
||||||
const img = new Image();
|
|
||||||
img.onload = function () {
|
|
||||||
if (img.height > 999 || img.width > 999) { setShowImageWarning(true); }
|
|
||||||
};
|
|
||||||
img.src = location;
|
|
||||||
success(location);
|
success(location);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
failure(e.toString(), { remove: true });
|
failure(e.toString(), { remove: true });
|
||||||
@@ -94,54 +72,32 @@ export default function TinyMCEEditor(props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Editor
|
||||||
<Editor
|
init={{
|
||||||
init={{
|
skin: false,
|
||||||
skin: false,
|
menubar: false,
|
||||||
menubar: false,
|
branding: false,
|
||||||
branding: false,
|
contextmenu: false,
|
||||||
contextmenu: false,
|
browser_spellcheck: true,
|
||||||
browser_spellcheck: true,
|
a11y_advanced_options: true,
|
||||||
a11y_advanced_options: true,
|
autosave_interval: '1s',
|
||||||
autosave_interval: '1s',
|
autosave_restore_when_empty: true,
|
||||||
autosave_restore_when_empty: false,
|
plugins: 'autosave codesample link lists image imagetools code',
|
||||||
plugins: 'autoresize autosave codesample link lists image imagetools code emoticons charmap',
|
toolbar: 'formatselect | bold italic underline'
|
||||||
toolbar: 'undo redo'
|
+ ' | link blockquote openedx_code image'
|
||||||
+ ' | formatselect | bold italic underline'
|
+ ' | bullist numlist outdent indent'
|
||||||
+ ' | link blockquote openedx_code image'
|
+ ' | removeformat'
|
||||||
+ ' | bullist numlist outdent indent'
|
+ ' | openedx_html'
|
||||||
+ ' | removeformat'
|
+ ' | undo redo',
|
||||||
+ ' | openedx_html'
|
content_css: false,
|
||||||
+ ' | emoticons'
|
content_style: contentStyle,
|
||||||
+ ' | charmap',
|
body_class: 'm-2',
|
||||||
content_css: false,
|
default_link_target: '_blank',
|
||||||
content_style: contentStyle,
|
target_list: false,
|
||||||
body_class: 'm-2 text-editor',
|
images_upload_handler: uploadHandler,
|
||||||
default_link_target: '_blank',
|
setup,
|
||||||
target_list: false,
|
}}
|
||||||
images_upload_handler: uploadHandler,
|
{...props}
|
||||||
setup,
|
/>
|
||||||
}}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
<AlertModal
|
|
||||||
title={intl.formatMessage(messages.imageWarningModalTitle)}
|
|
||||||
isOpen={showImageWarning}
|
|
||||||
onClose={() => setShowImageWarning(false)}
|
|
||||||
isBlocking
|
|
||||||
footerNode={(
|
|
||||||
<ActionRow>
|
|
||||||
<Button variant="danger" onClick={() => setShowImageWarning(false)}>
|
|
||||||
{intl.formatMessage(messages.imageWarningDismissButton)}
|
|
||||||
</Button>
|
|
||||||
</ActionRow>
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<p>
|
|
||||||
{intl.formatMessage(messages.imageWarningMessage)}
|
|
||||||
</p>
|
|
||||||
</AlertModal>
|
|
||||||
</>
|
|
||||||
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
export default function InsertLink() {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
export default function Issue() {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="28"
|
|
||||||
height="28"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 28 28"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fill="#F2F0EF"
|
|
||||||
d="M0 14C0 6.268 6.268 0 14 0s14 6.268 14 14-6.268 14-14 14S0 21.732 0 14z"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
fill="#2D494E"
|
|
||||||
d="M14 2.333C7.56 2.333 2.333 7.56 2.333 14c0 6.44 5.227 11.667 11.667 11.667 6.44 0 11.667-5.227 11.667-11.667C25.667 7.56 20.44 2.334 14 2.334z"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
fill="#fff"
|
|
||||||
d="M12.833 22.167h2.334v-2.334h-2.334v2.334zM16.532 14.198l1.05-1.073a3.713 3.713 0 001.085-2.625A4.665 4.665 0 0014 5.833 4.665 4.665 0 009.333 10.5h2.334A2.34 2.34 0 0114 8.167a2.34 2.34 0 012.333 2.333c0 .642-.256 1.225-.688 1.645l-1.447 1.47a4.696 4.696 0 00-1.365 3.302v.583h2.334c0-1.75.525-2.45 1.365-3.302z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
export default function People() {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 16 16"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fill="#707070"
|
|
||||||
d="M11.072 7.332a1.992 1.992 0 001.993-2 1.997 1.997 0 10-3.993 0c0 1.107.893 2 2 2zm-5.334 0a1.992 1.992 0 001.994-2 1.997 1.997 0 10-3.993 0c0 1.107.893 2 2 2zm0 1.333c-1.553 0-4.666.78-4.666 2.334v1.666h9.333V11c0-1.554-3.113-2.334-4.667-2.334zm5.334 0c-.194 0-.414.014-.647.034.773.56 1.313 1.313 1.313 2.3v1.666h4V11c0-1.554-3.113-2.334-4.666-2.334z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
export default function PushPin() {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M16 9V4H18V2H6V4H8V9C8 10.66 6.66 12 5 12V14H10.97V21L11.97 22L12.97 21V14H19V12C17.34 12 16 10.66 16 9Z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
export default function Question() {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="28"
|
|
||||||
height="28"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 28 28"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fill="#fff"
|
|
||||||
d="M0 14.001c0-7.732 6.268-14 14-14s14 6.268 14 14-6.268 14-14 14-14-6.268-14-14z"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
fill="#2D494E"
|
|
||||||
d="M14 2.334c-6.44 0-11.667 5.227-11.667 11.667 0 6.44 5.227 11.667 11.667 11.667 6.44 0 11.666-5.227 11.666-11.667 0-6.44-5.226-11.667-11.666-11.667z"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
fill="#fff"
|
|
||||||
d="M12.833 22.168h2.333v-2.334h-2.333v2.334zM16.531 14.2l1.05-1.074a3.712 3.712 0 001.085-2.625A4.665 4.665 0 0014 5.834a4.665 4.665 0 00-4.667 4.667h2.333A2.34 2.34 0 0114 8.168a2.34 2.34 0 012.333 2.333c0 .642-.257 1.225-.688 1.645l-1.447 1.47a4.696 4.696 0 00-1.365 3.302v.583h2.333c0-1.75.525-2.45 1.365-3.302z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
export default function QuestionAnswer() {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="21"
|
|
||||||
height="20"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 21 20"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M18.737 5h-2.5v7.5H5.404V15h10l3.333 3.333V5zm-4.166 5.833V1.667H2.07v12.5l3.333-3.334h9.166z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
export default function QuestionAnswerOutline() {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="20"
|
|
||||||
height="20"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M12.6 3.267v6.067H4.08l-.512.512-.502.502v-7.08H12.6zm.867-1.733H2.198a.87.87 0 00-.867.867v12.134L4.8 11.068h8.668a.87.87 0 00.866-.867v-7.8a.87.87 0 00-.867-.867zM17.8 5h-1.733v7.8H4.799v1.734c0 .476.39.867.867.867H15.2l3.467 3.466v-13A.87.87 0 0017.8 5z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
export default function StarFilled() {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="21"
|
|
||||||
height="20"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 21 20"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M10.404 14.392l5.15 3.108-1.367-5.858 4.55-3.942-5.991-.508-2.342-5.525-2.342 5.525L2.07 7.7l4.55 3.942L5.254 17.5l5.15-3.108z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
export default function StarOutline() {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="20"
|
|
||||||
height="20"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M18.737 7.7l-5.991-.517-2.342-5.516-2.342 5.525L2.07 7.7l4.55 3.942L5.254 17.5l5.15-3.108 5.15 3.108-1.359-5.858L18.737 7.7zm-8.333 5.133L7.27 14.725l.834-3.567-2.767-2.4 3.65-.316 1.417-3.359 1.425 3.367 3.65.317-2.767 2.4.834 3.566-3.142-1.9z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
export default function ThumbUpFilled() {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="21"
|
|
||||||
height="20"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 21 20"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M12.212.833L6.237 6.817V17.5h10.258l3.075-7.167V6.667h-6.925l.934-4.484-1.367-1.35zM1.237 7.5H4.57v10H1.237v-10z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
export default function ThumbUpOutline() {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="20"
|
|
||||||
height="20"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M19.57 6.667v3.666L16.495 17.5H6.238V6.817L12.212.833l1.367 1.35-.934 4.484h6.925zm-11.666.841v8.325h7.492l2.508-5.841V8.333h-7.309l.925-4.45-3.616 3.625z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
/>
|
|
||||||
<path fill="currentColor" d="M4.57 17.5H1.237v-10H4.57v10z" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
export { default as InsertLink } from './InsertLink';
|
|
||||||
export { default as Issue } from './Issue';
|
|
||||||
export { default as People } from './People';
|
|
||||||
export { default as PushPin } from './PushPin';
|
|
||||||
export { default as Question } from './Question';
|
|
||||||
export { default as QuestionAnswer } from './QuestionAnswer';
|
|
||||||
export { default as QuestionAnswerOutline } from './QuestionAnswerOutline';
|
|
||||||
export { default as StarFilled } from './StarFilled';
|
|
||||||
export { default as StarOutline } from './StarOutline';
|
|
||||||
export { default as ThumbUpFilled } from './ThumbUpFilled';
|
|
||||||
export { default as ThumbUpOutline } from './ThumbUpOutline';
|
|
||||||
@@ -1,3 +1,2 @@
|
|||||||
export { default as PostActionsBar } from '../discussions/posts/post-actions-bar/PostActionsBar';
|
export { default as PostActionsBar } from '../discussions/posts/post-actions-bar/PostActionsBar';
|
||||||
export { default as Search } from './Search';
|
|
||||||
export { default as TinyMCEEditor } from './TinyMCEEditor';
|
export { default as TinyMCEEditor } from './TinyMCEEditor';
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ export const ContentActions = {
|
|||||||
PIN: 'pinned',
|
PIN: 'pinned',
|
||||||
ENDORSE: 'endorsed',
|
ENDORSE: 'endorsed',
|
||||||
CLOSE: 'closed',
|
CLOSE: 'closed',
|
||||||
COPY_LINK: 'copy_link',
|
|
||||||
REPORT: 'abuse_flagged',
|
REPORT: 'abuse_flagged',
|
||||||
DELETE: 'delete',
|
DELETE: 'delete',
|
||||||
FOLLOWING: 'following',
|
FOLLOWING: 'following',
|
||||||
@@ -74,9 +73,9 @@ export const RequestStatus = {
|
|||||||
* @readonly
|
* @readonly
|
||||||
* @enum {string}
|
* @enum {string}
|
||||||
*/
|
*/
|
||||||
export const AvatarOutlineAndLabelColors = {
|
export const AvatarBorderAndLabelColors = {
|
||||||
Staff: 'staff-color',
|
Staff: 'warning-700',
|
||||||
'Community TA': 'TA-color',
|
'Community TA': 'success-700',
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -111,7 +110,6 @@ export const PostsStatusFilter = {
|
|||||||
FOLLOWING: 'statusFollowing',
|
FOLLOWING: 'statusFollowing',
|
||||||
REPORTED: 'statusReported',
|
REPORTED: 'statusReported',
|
||||||
UNANSWERED: 'statusUnanswered',
|
UNANSWERED: 'statusUnanswered',
|
||||||
UNRESPONDED: 'statusUnresponded',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -134,7 +132,17 @@ export const TopicOrdering = {
|
|||||||
export const LearnersOrdering = {
|
export const LearnersOrdering = {
|
||||||
BY_FLAG: 'flagged',
|
BY_FLAG: 'flagged',
|
||||||
BY_LAST_ACTIVITY: 'activity',
|
BY_LAST_ACTIVITY: 'activity',
|
||||||
BY_RECENCY: 'recency',
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enum for Learner content tabs
|
||||||
|
* @readonly
|
||||||
|
* @enum {string}
|
||||||
|
*/
|
||||||
|
export const LearnerTabs = {
|
||||||
|
POSTS: 'posts',
|
||||||
|
COMMENTS: 'comments',
|
||||||
|
RESPONSES: 'responses',
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -154,7 +162,12 @@ export const Routes = {
|
|||||||
},
|
},
|
||||||
LEARNERS: {
|
LEARNERS: {
|
||||||
PATH: `${BASE_PATH}/learners`,
|
PATH: `${BASE_PATH}/learners`,
|
||||||
POSTS: `${BASE_PATH}/learners/:learnerUsername/posts(/:postId)?`,
|
LEARNER: `${BASE_PATH}/learners/:learnerUsername`,
|
||||||
|
TABS: {
|
||||||
|
posts: `${BASE_PATH}/learners/:learnerUsername/${LearnerTabs.POSTS}`,
|
||||||
|
responses: `${BASE_PATH}/learners/:learnerUsername/${LearnerTabs.RESPONSES}`,
|
||||||
|
comments: `${BASE_PATH}/learners/:learnerUsername/${LearnerTabs.COMMENTS}`,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
POSTS: {
|
POSTS: {
|
||||||
PATH: `${BASE_PATH}/topics/:topicId`,
|
PATH: `${BASE_PATH}/topics/:topicId`,
|
||||||
@@ -170,7 +183,6 @@ export const Routes = {
|
|||||||
`${BASE_PATH}/topics/:topicId/posts/:postId/edit`,
|
`${BASE_PATH}/topics/:topicId/posts/:postId/edit`,
|
||||||
`${BASE_PATH}/posts/:postId/edit`,
|
`${BASE_PATH}/posts/:postId/edit`,
|
||||||
`${BASE_PATH}/my-posts/:postId/edit`,
|
`${BASE_PATH}/my-posts/:postId/edit`,
|
||||||
`${BASE_PATH}/learners/:learnerUsername/posts/:postId/edit`,
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
COMMENTS: {
|
COMMENTS: {
|
||||||
@@ -179,7 +191,6 @@ export const Routes = {
|
|||||||
`${BASE_PATH}/topics/:topicId/posts/:postId`,
|
`${BASE_PATH}/topics/:topicId/posts/:postId`,
|
||||||
`${BASE_PATH}/posts/:postId`,
|
`${BASE_PATH}/posts/:postId`,
|
||||||
`${BASE_PATH}/my-posts/:postId`,
|
`${BASE_PATH}/my-posts/:postId`,
|
||||||
`${BASE_PATH}/learners/:learnerUsername/posts/:postId`,
|
|
||||||
],
|
],
|
||||||
PAGE: `${BASE_PATH}/:page`,
|
PAGE: `${BASE_PATH}/:page`,
|
||||||
PAGES: {
|
PAGES: {
|
||||||
@@ -187,7 +198,6 @@ export const Routes = {
|
|||||||
topics: `${BASE_PATH}/topics/:topicId/posts/:postId`,
|
topics: `${BASE_PATH}/topics/:topicId/posts/:postId`,
|
||||||
posts: `${BASE_PATH}/posts/:postId`,
|
posts: `${BASE_PATH}/posts/:postId`,
|
||||||
'my-posts': `${BASE_PATH}/my-posts/:postId`,
|
'my-posts': `${BASE_PATH}/my-posts/:postId`,
|
||||||
learners: `${BASE_PATH}/learners/:learnerUsername/posts/:postId`,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
TOPICS: {
|
TOPICS: {
|
||||||
@@ -203,20 +213,10 @@ export const Routes = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PostsPages = {
|
|
||||||
category: `${BASE_PATH}/category/:category/posts`,
|
|
||||||
topics: `${BASE_PATH}/topics/:topicId/posts`,
|
|
||||||
posts: `${BASE_PATH}/posts`,
|
|
||||||
'my-posts': `${BASE_PATH}/my-posts`,
|
|
||||||
learners: `${BASE_PATH}/learners/:learnerUsername/posts`,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ALL_ROUTES = []
|
export const ALL_ROUTES = []
|
||||||
.concat([Routes.TOPICS.CATEGORY_POST, Routes.TOPICS.CATEGORY])
|
.concat([Routes.TOPICS.CATEGORY_POST, Routes.TOPICS.CATEGORY])
|
||||||
.concat(Routes.COMMENTS.PATH)
|
.concat(Routes.COMMENTS.PATH)
|
||||||
.concat(Routes.TOPICS.PATH)
|
.concat(Routes.TOPICS.PATH)
|
||||||
.concat([Routes.POSTS.ALL_POSTS, Routes.POSTS.MY_POSTS])
|
.concat([Routes.POSTS.ALL_POSTS, Routes.POSTS.MY_POSTS])
|
||||||
.concat([Routes.LEARNERS.POSTS, Routes.LEARNERS.PATH])
|
.concat([Routes.LEARNERS.LEARNER, Routes.LEARNERS.PATH])
|
||||||
.concat([Routes.DISCUSSIONS.PATH]);
|
.concat([Routes.DISCUSSIONS.PATH]);
|
||||||
|
|
||||||
export const MAX_UPLOAD_FILE_SIZE = 1024;
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { initializeStore } from '../store';
|
|||||||
import { executeThunk } from '../test-utils';
|
import { executeThunk } from '../test-utils';
|
||||||
import { getBlocksAPIResponse } from './__factories__';
|
import { getBlocksAPIResponse } from './__factories__';
|
||||||
import { blocksAPIURL } from './api';
|
import { blocksAPIURL } from './api';
|
||||||
import { RequestStatus } from './constants';
|
|
||||||
import { fetchCourseBlocks } from './thunks';
|
import { fetchCourseBlocks } from './thunks';
|
||||||
|
|
||||||
const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||||
@@ -36,7 +35,7 @@ describe('Course blocks data layer tests', () => {
|
|||||||
axiosMock.reset();
|
axiosMock.reset();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('successfully processes block data', async () => {
|
test('successfully processes block data', async () => {
|
||||||
axiosMock.onGet(blocksAPIURL)
|
axiosMock.onGet(blocksAPIURL)
|
||||||
.reply(200, getBlocksAPIResponse());
|
.reply(200, getBlocksAPIResponse());
|
||||||
|
|
||||||
@@ -78,29 +77,4 @@ describe('Course blocks data layer tests', () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles network error', async () => {
|
|
||||||
axiosMock.onGet(blocksAPIURL).networkError();
|
|
||||||
|
|
||||||
await executeThunk(fetchCourseBlocks(courseId, 'test-user'), store.dispatch, store.getState);
|
|
||||||
|
|
||||||
expect(store.getState().blocks.status)
|
|
||||||
.toBe(RequestStatus.FAILED);
|
|
||||||
});
|
|
||||||
it('handles network timeout', async () => {
|
|
||||||
axiosMock.onGet(blocksAPIURL).timeout();
|
|
||||||
|
|
||||||
await executeThunk(fetchCourseBlocks(courseId, 'test-user'), store.dispatch, store.getState);
|
|
||||||
|
|
||||||
expect(store.getState().blocks.status)
|
|
||||||
.toBe(RequestStatus.FAILED);
|
|
||||||
});
|
|
||||||
it('handles access denied', async () => {
|
|
||||||
axiosMock.onGet(blocksAPIURL).reply(403, {});
|
|
||||||
|
|
||||||
await executeThunk(fetchCourseBlocks(courseId, 'test-user'), store.dispatch, store.getState);
|
|
||||||
|
|
||||||
expect(store.getState().blocks.status)
|
|
||||||
.toBe(RequestStatus.DENIED);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { createSelector } from '@reduxjs/toolkit';
|
import { createSelector } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
import { selectDiscussionProvider, selectGroupAtSubsection } from '../discussions/data/selectors';
|
import { selectDiscussionProvider } from '../discussions/data/selectors';
|
||||||
import { DiscussionProvider } from './constants';
|
import { DiscussionProvider } from './constants';
|
||||||
|
|
||||||
export const selectTopicContext = (topicId) => (state) => state.blocks.topics[topicId];
|
export const selectTopicContext = (topicId) => (state) => state.blocks.topics[topicId];
|
||||||
@@ -14,18 +14,6 @@ export const selectorForUnitSubsection = createSelector(
|
|||||||
blocks => key => blocks[blocks[key]?.parent],
|
blocks => key => blocks[blocks[key]?.parent],
|
||||||
);
|
);
|
||||||
|
|
||||||
// If subsection grouping is enabled, and the current selection is a unit, then get the current subsection.
|
|
||||||
export const selectCurrentCategoryGrouping = createSelector(
|
|
||||||
selectDiscussionProvider,
|
|
||||||
selectGroupAtSubsection,
|
|
||||||
selectBlocks,
|
|
||||||
(provider, groupAtSubsection, blocks) => blockId => (
|
|
||||||
(provider !== 'openedx' || !groupAtSubsection || blocks[blockId]?.type !== 'vertical')
|
|
||||||
? blockId
|
|
||||||
: blocks[blockId].parent
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const selectChapters = (state) => state.blocks.chapters;
|
export const selectChapters = (state) => state.blocks.chapters;
|
||||||
export const selectTopicsUnderCategory = createSelector(
|
export const selectTopicsUnderCategory = createSelector(
|
||||||
selectDiscussionProvider,
|
selectDiscussionProvider,
|
||||||
@@ -44,10 +32,4 @@ export const selectSequences = createSelector(
|
|||||||
(chapterIds, blocks) => chapterIds?.flatMap(cId => blocks[cId].children.map(seqId => blocks[seqId])) || [],
|
(chapterIds, blocks) => chapterIds?.flatMap(cId => blocks[cId].children.map(seqId => blocks[seqId])) || [],
|
||||||
);
|
);
|
||||||
|
|
||||||
export const selectArchivedTopics = createSelector(
|
|
||||||
state => state.topics.topics,
|
|
||||||
state => state.topics.archivedIds || [],
|
|
||||||
(topics, ids) => ids.map(id => topics[id]),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const selectTopicIds = () => (state) => state.blocks.chapters;
|
export const selectTopicIds = () => (state) => state.blocks.chapters;
|
||||||
|
|||||||
@@ -58,21 +58,19 @@ function normaliseCourseBlocks({
|
|||||||
} else {
|
} else {
|
||||||
blocks[verticalId].children?.forEach(discussionId => {
|
blocks[verticalId].children?.forEach(discussionId => {
|
||||||
const discussion = camelCaseObject(blocks[discussionId]);
|
const discussion = camelCaseObject(blocks[discussionId]);
|
||||||
const { topicId } = discussion.studentViewData || {};
|
const { topicId } = discussion.studentViewData;
|
||||||
if (topicId) {
|
blockData[discussionId] = discussion;
|
||||||
blockData[discussionId] = discussion;
|
// Add this topic id to the list of topics for the current chapter, sequential, and vertical
|
||||||
// Add this topic id to the list of topics for the current chapter, sequential, and vertical
|
chapterData.topics.push(topicId);
|
||||||
chapterData.topics.push(topicId);
|
blockData[sequentialId].topics.push(topicId);
|
||||||
blockData[sequentialId].topics.push(topicId);
|
blockData[verticalId].topics.push(topicId);
|
||||||
blockData[verticalId].topics.push(topicId);
|
// Store the topic's context in the course in a map
|
||||||
// Store the topic's context in the course in a map
|
topics[topicId] = {
|
||||||
topics[topicId] = {
|
chapterName: blockData[chapterId].displayName,
|
||||||
chapterName: blockData[chapterId].displayName,
|
verticalName: blockData[sequentialId].displayName,
|
||||||
verticalName: blockData[sequentialId].displayName,
|
unitName: blockData[verticalId].displayName,
|
||||||
unitName: blockData[verticalId].displayName,
|
unitLink: blockData[verticalId].lmsWebUrl,
|
||||||
unitLink: blockData[verticalId].lmsWebUrl,
|
};
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import React, { useEffect, useMemo } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
import { useParams } from 'react-router';
|
import { useParams } from 'react-router';
|
||||||
|
|
||||||
|
import { ensureConfig, getConfig } from '@edx/frontend-platform';
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||||
import { Button, Spinner } from '@edx/paragon';
|
import { Button, Spinner } from '@edx/paragon';
|
||||||
|
|
||||||
@@ -11,23 +12,27 @@ import { EndorsementStatus, ThreadType } from '../../data/constants';
|
|||||||
import { useDispatchWithState } from '../../data/hooks';
|
import { useDispatchWithState } from '../../data/hooks';
|
||||||
import { Post } from '../posts';
|
import { Post } from '../posts';
|
||||||
import { selectThread } from '../posts/data/selectors';
|
import { selectThread } from '../posts/data/selectors';
|
||||||
import { fetchThread, markThreadAsRead } from '../posts/data/thunks';
|
import { markThreadAsRead } from '../posts/data/thunks';
|
||||||
import { filterPosts } from '../utils';
|
|
||||||
import { selectThreadComments, selectThreadCurrentPage, selectThreadHasMorePages } from './data/selectors';
|
import { selectThreadComments, selectThreadCurrentPage, selectThreadHasMorePages } from './data/selectors';
|
||||||
import { fetchThreadComments } from './data/thunks';
|
import { fetchThreadComments } from './data/thunks';
|
||||||
import { Comment, ResponseEditor } from './comment';
|
import { Comment, ResponseEditor } from './comment';
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
|
|
||||||
|
ensureConfig(['POST_MARK_AS_READ_DELAY'], 'Comment thread view');
|
||||||
|
|
||||||
function usePost(postId) {
|
function usePost(postId) {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const thread = useSelector(selectThread(postId));
|
const thread = useSelector(selectThread(postId));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (thread && !thread.read) {
|
const markReadTimer = setTimeout(() => {
|
||||||
dispatch(markThreadAsRead(postId));
|
if (thread && !thread.read) {
|
||||||
}
|
dispatch(markThreadAsRead(postId));
|
||||||
|
}
|
||||||
|
}, getConfig().POST_MARK_AS_READ_DELAY);
|
||||||
|
return () => {
|
||||||
|
clearTimeout(markReadTimer);
|
||||||
|
};
|
||||||
}, [postId]);
|
}, [postId]);
|
||||||
|
|
||||||
return thread;
|
return thread;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +64,6 @@ function DiscussionCommentsView({
|
|||||||
postId,
|
postId,
|
||||||
intl,
|
intl,
|
||||||
endorsed,
|
endorsed,
|
||||||
isClosed,
|
|
||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
comments,
|
comments,
|
||||||
@@ -67,56 +71,41 @@ function DiscussionCommentsView({
|
|||||||
isLoading,
|
isLoading,
|
||||||
handleLoadMoreResponses,
|
handleLoadMoreResponses,
|
||||||
} = usePostComments(postId, endorsed);
|
} = usePostComments(postId, endorsed);
|
||||||
const sortedComments = useMemo(() => [...filterPosts(comments, 'endorsed'),
|
|
||||||
...filterPosts(comments, 'unendorsed')], [comments]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="m-3">
|
||||||
{((hasMorePages && isLoading) || !isLoading)
|
<div className="my-3">
|
||||||
&& (
|
|
||||||
<div className="mx-4 text-primary-700" role="heading" aria-level="2" style={{ lineHeight: '28px' }}>
|
|
||||||
{endorsed === EndorsementStatus.ENDORSED
|
{endorsed === EndorsementStatus.ENDORSED
|
||||||
? intl.formatMessage(messages.endorsedResponseCount, { num: sortedComments.length })
|
? intl.formatMessage(messages.endorsedResponseCount, { num: comments.length })
|
||||||
: intl.formatMessage(messages.responseCount, { num: sortedComments.length })}
|
: intl.formatMessage(messages.responseCount, { num: comments.length })}
|
||||||
</div>
|
</div>
|
||||||
)}
|
{comments.map(comment => (
|
||||||
|
<Comment comment={comment} key={comment.id} postType={postType} />
|
||||||
|
))}
|
||||||
|
|
||||||
<div className="mx-4" role="list">
|
{hasMorePages && !isLoading && (
|
||||||
{sortedComments.map(comment => (
|
<Button
|
||||||
<Comment comment={comment} key={comment.id} postType={postType} isClosedPost={isClosed} />
|
onClick={handleLoadMoreResponses}
|
||||||
))}
|
variant="link"
|
||||||
{hasMorePages && !isLoading && (
|
block="true"
|
||||||
<Button
|
className="card p-4"
|
||||||
onClick={handleLoadMoreResponses}
|
data-testid="load-more-comments"
|
||||||
variant="link"
|
>
|
||||||
block="true"
|
{intl.formatMessage(messages.loadMoreResponses)}
|
||||||
className="card p-4 mb-4 font-weight-500 font-size-14"
|
</Button>
|
||||||
style={{
|
)}
|
||||||
lineHeight: '20px',
|
{isLoading
|
||||||
}}
|
|
||||||
data-testid="load-more-comments"
|
|
||||||
>
|
|
||||||
{intl.formatMessage(messages.loadMoreResponses)}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{isLoading
|
|
||||||
&& (
|
&& (
|
||||||
<div className="card my-4 p-4 d-flex align-items-center">
|
<div className="card my-4 p-4 d-flex align-items-center">
|
||||||
<Spinner animation="border" variant="primary" />
|
<Spinner animation="border" variant="primary" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!!sortedComments.length && !isClosed
|
</div>
|
||||||
&& <ResponseEditor postId={postId} addWrappingDiv />}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
DiscussionCommentsView.propTypes = {
|
DiscussionCommentsView.propTypes = {
|
||||||
postId: PropTypes.string.isRequired,
|
postId: PropTypes.string.isRequired,
|
||||||
postType: PropTypes.string.isRequired,
|
postType: PropTypes.string.isRequired,
|
||||||
isClosed: PropTypes.bool.isRequired,
|
|
||||||
intl: intlShape.isRequired,
|
intl: intlShape.isRequired,
|
||||||
endorsed: PropTypes.oneOf([
|
endorsed: PropTypes.oneOf([
|
||||||
EndorsementStatus.ENDORSED, EndorsementStatus.UNENDORSED, EndorsementStatus.DISCUSSION,
|
EndorsementStatus.ENDORSED, EndorsementStatus.UNENDORSED, EndorsementStatus.DISCUSSION,
|
||||||
@@ -126,18 +115,16 @@ DiscussionCommentsView.propTypes = {
|
|||||||
function CommentsView({ intl }) {
|
function CommentsView({ intl }) {
|
||||||
const { postId } = useParams();
|
const { postId } = useParams();
|
||||||
const thread = usePost(postId);
|
const thread = usePost(postId);
|
||||||
const dispatch = useDispatch();
|
|
||||||
if (!thread) {
|
if (!thread) {
|
||||||
dispatch(fetchThread(postId, true));
|
|
||||||
return (
|
return (
|
||||||
<Spinner animation="border" variant="primary" data-testid="loading-indicator" />
|
<Spinner animation="border" variant="primary" data-testid="loading-indicator" />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="discussion-comments d-flex flex-column m-4 p-4.5 card">
|
<div className="discussion-comments d-flex flex-column mt-3 mb-0 mx-3 p-4 card">
|
||||||
<Post post={thread} />
|
<Post post={thread} />
|
||||||
{!thread.closed && <ResponseEditor postId={postId} /> }
|
<ResponseEditor postId={postId} />
|
||||||
</div>
|
</div>
|
||||||
{thread.type === ThreadType.DISCUSSION
|
{thread.type === ThreadType.DISCUSSION
|
||||||
&& (
|
&& (
|
||||||
@@ -146,7 +133,6 @@ function CommentsView({ intl }) {
|
|||||||
intl={intl}
|
intl={intl}
|
||||||
postType={thread.type}
|
postType={thread.type}
|
||||||
endorsed={EndorsementStatus.DISCUSSION}
|
endorsed={EndorsementStatus.DISCUSSION}
|
||||||
isClosed={thread.closed}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{thread.type === ThreadType.QUESTION && (
|
{thread.type === ThreadType.QUESTION && (
|
||||||
@@ -156,14 +142,12 @@ function CommentsView({ intl }) {
|
|||||||
intl={intl}
|
intl={intl}
|
||||||
postType={thread.type}
|
postType={thread.type}
|
||||||
endorsed={EndorsementStatus.ENDORSED}
|
endorsed={EndorsementStatus.ENDORSED}
|
||||||
isClosed={thread.closed}
|
|
||||||
/>
|
/>
|
||||||
<DiscussionCommentsView
|
<DiscussionCommentsView
|
||||||
postId={postId}
|
postId={postId}
|
||||||
intl={intl}
|
intl={intl}
|
||||||
postType={thread.type}
|
postType={thread.type}
|
||||||
endorsed={EndorsementStatus.UNENDORSED}
|
endorsed={EndorsementStatus.UNENDORSED}
|
||||||
isClosed={thread.closed}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import { AppProvider } from '@edx/frontend-platform/react';
|
|||||||
|
|
||||||
import { initializeStore } from '../../store';
|
import { initializeStore } from '../../store';
|
||||||
import { executeThunk } from '../../test-utils';
|
import { executeThunk } from '../../test-utils';
|
||||||
import { DiscussionContext } from '../common/context';
|
|
||||||
import { courseConfigApiUrl } from '../data/api';
|
import { courseConfigApiUrl } from '../data/api';
|
||||||
import { fetchCourseConfig } from '../data/thunks';
|
import { fetchCourseConfig } from '../data/thunks';
|
||||||
import DiscussionContent from '../discussions-home/DiscussionContent';
|
import DiscussionContent from '../discussions-home/DiscussionContent';
|
||||||
@@ -82,20 +81,16 @@ function renderComponent(postId) {
|
|||||||
render(
|
render(
|
||||||
<IntlProvider locale="en">
|
<IntlProvider locale="en">
|
||||||
<AppProvider store={store}>
|
<AppProvider store={store}>
|
||||||
<DiscussionContext.Provider
|
<MemoryRouter initialEntries={[`/${courseId}/posts/${postId}`]}>
|
||||||
value={{ courseId }}
|
<DiscussionContent />
|
||||||
>
|
<Route
|
||||||
<MemoryRouter initialEntries={[`/${courseId}/posts/${postId}`]}>
|
path="*"
|
||||||
<DiscussionContent />
|
render={({ location }) => {
|
||||||
<Route
|
testLocation = location;
|
||||||
path="*"
|
return null;
|
||||||
render={({ location }) => {
|
}}
|
||||||
testLocation = location;
|
/>
|
||||||
return null;
|
</MemoryRouter>
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</MemoryRouter>
|
|
||||||
</DiscussionContext.Provider>
|
|
||||||
</AppProvider>
|
</AppProvider>
|
||||||
</IntlProvider>,
|
</IntlProvider>,
|
||||||
);
|
);
|
||||||
@@ -160,10 +155,9 @@ describe('CommentsView', () => {
|
|||||||
it('should show and hide the editor', async () => {
|
it('should show and hide the editor', async () => {
|
||||||
renderComponent(discussionPostId);
|
renderComponent(discussionPostId);
|
||||||
await waitFor(() => screen.findByText('comment number 1', { exact: false }));
|
await waitFor(() => screen.findByText('comment number 1', { exact: false }));
|
||||||
const addResponseButtons = screen.getAllByRole('button', { name: /add a response/i });
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
addResponseButtons[0],
|
screen.getByRole('button', { name: /add a response/i }),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
expect(screen.queryByTestId('tinymce-editor')).toBeInTheDocument();
|
expect(screen.queryByTestId('tinymce-editor')).toBeInTheDocument();
|
||||||
@@ -172,17 +166,15 @@ describe('CommentsView', () => {
|
|||||||
});
|
});
|
||||||
expect(screen.queryByTestId('tinymce-editor')).not.toBeInTheDocument();
|
expect(screen.queryByTestId('tinymce-editor')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should allow posting a response', async () => {
|
it('should allow posting a response', async () => {
|
||||||
renderComponent(discussionPostId);
|
renderComponent(discussionPostId);
|
||||||
await waitFor(() => screen.findByText('comment number 1', { exact: false }));
|
await waitFor(() => screen.findByText('comment number 1', { exact: false }));
|
||||||
const responseButtons = screen.getAllByRole('button', { name: /add a response/i });
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
responseButtons[0],
|
screen.getByRole('button', { name: /add a response/i }),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
await act(() => {
|
act(() => {
|
||||||
fireEvent.change(screen.getByTestId('tinymce-editor'), { target: { value: 'testing123' } });
|
fireEvent.change(screen.getByTestId('tinymce-editor'), { target: { value: 'testing123' } });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -194,13 +186,6 @@ describe('CommentsView', () => {
|
|||||||
expect(screen.queryByTestId('tinymce-editor')).not.toBeInTheDocument();
|
expect(screen.queryByTestId('tinymce-editor')).not.toBeInTheDocument();
|
||||||
await waitFor(async () => expect(await screen.findByText('testing123', { exact: false })).toBeInTheDocument());
|
await waitFor(async () => expect(await screen.findByText('testing123', { exact: false })).toBeInTheDocument());
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not allow posting a response on a closed post', async () => {
|
|
||||||
renderComponent(closedPostId);
|
|
||||||
await waitFor(() => screen.findByText('Thread-2', { exact: false }));
|
|
||||||
expect(screen.queryByRole('button', { name: /add a response/i })).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should allow posting a comment', async () => {
|
it('should allow posting a comment', async () => {
|
||||||
renderComponent(discussionPostId);
|
renderComponent(discussionPostId);
|
||||||
await waitFor(() => screen.findByText('comment number 1', { exact: false }));
|
await waitFor(() => screen.findByText('comment number 1', { exact: false }));
|
||||||
@@ -221,17 +206,6 @@ describe('CommentsView', () => {
|
|||||||
expect(screen.queryByTestId('tinymce-editor')).not.toBeInTheDocument();
|
expect(screen.queryByTestId('tinymce-editor')).not.toBeInTheDocument();
|
||||||
await waitFor(async () => expect(await screen.findByText('testing123', { exact: false })).toBeInTheDocument());
|
await waitFor(async () => expect(await screen.findByText('testing123', { exact: false })).toBeInTheDocument());
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not allow posting a comment on a closed post', async () => {
|
|
||||||
renderComponent(closedPostId);
|
|
||||||
await waitFor(() => screen.findByText('thread-2', { exact: false }));
|
|
||||||
await act(async () => {
|
|
||||||
expect(
|
|
||||||
screen.queryByRole('button', { name: /add a comment/i }),
|
|
||||||
).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should allow editing an existing comment', async () => {
|
it('should allow editing an existing comment', async () => {
|
||||||
renderComponent(discussionPostId);
|
renderComponent(discussionPostId);
|
||||||
await waitFor(() => screen.findByText('comment number 1', { exact: false }));
|
await waitFor(() => screen.findByText('comment number 1', { exact: false }));
|
||||||
@@ -257,7 +231,7 @@ describe('CommentsView', () => {
|
|||||||
|
|
||||||
async function setupCourseConfig(reasonCodesEnabled = true) {
|
async function setupCourseConfig(reasonCodesEnabled = true) {
|
||||||
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`).reply(200, {
|
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`).reply(200, {
|
||||||
has_moderation_privileges: true,
|
user_is_privileged: true,
|
||||||
reason_codes_enabled: reasonCodesEnabled,
|
reason_codes_enabled: reasonCodesEnabled,
|
||||||
editReasons: [
|
editReasons: [
|
||||||
{ code: 'reason-1', label: 'reason 1' },
|
{ code: 'reason-1', label: 'reason 1' },
|
||||||
|
|||||||
@@ -4,18 +4,16 @@ import PropTypes from 'prop-types';
|
|||||||
import { useDispatch } from 'react-redux';
|
import { useDispatch } from 'react-redux';
|
||||||
import * as timeago from 'timeago.js';
|
import * as timeago from 'timeago.js';
|
||||||
|
|
||||||
import { injectIntl } from '@edx/frontend-platform/i18n';
|
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
import timeLocale from '../../common/time-locale';
|
|
||||||
import LikeButton from '../../posts/post/LikeButton';
|
import LikeButton from '../../posts/post/LikeButton';
|
||||||
import { editComment } from '../data/thunks';
|
import { editComment } from '../data/thunks';
|
||||||
|
|
||||||
function CommentIcons({
|
function CommentIcons({
|
||||||
comment,
|
comment,
|
||||||
|
intl,
|
||||||
}) {
|
}) {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
timeago.register('time-locale', timeLocale);
|
|
||||||
|
|
||||||
const handleLike = () => dispatch(editComment(comment.id, { voted: !comment.voted }));
|
const handleLike = () => dispatch(editComment(comment.id, { voted: !comment.voted }));
|
||||||
return (
|
return (
|
||||||
<div className="d-flex flex-row align-items-center">
|
<div className="d-flex flex-row align-items-center">
|
||||||
@@ -24,8 +22,8 @@ function CommentIcons({
|
|||||||
onClick={handleLike}
|
onClick={handleLike}
|
||||||
voted={comment.voted}
|
voted={comment.voted}
|
||||||
/>
|
/>
|
||||||
<div className="d-flex flex-fill text-gray-500 justify-content-end" title={comment.createdAt}>
|
<div className="d-flex flex-fill text-gray-500 justify-content-end mt-2" title={comment.createdAt}>
|
||||||
{timeago.format(comment.createdAt, 'time-locale')}
|
{timeago.format(comment.createdAt, intl.locale)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -39,6 +37,7 @@ CommentIcons.propTypes = {
|
|||||||
voted: PropTypes.bool,
|
voted: PropTypes.bool,
|
||||||
createdAt: PropTypes.string,
|
createdAt: PropTypes.string,
|
||||||
}).isRequired,
|
}).isRequired,
|
||||||
|
intl: intlShape.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(CommentIcons);
|
export default injectIntl(CommentIcons);
|
||||||
|
|||||||
@@ -7,12 +7,9 @@ import { useDispatch, useSelector } from 'react-redux';
|
|||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||||
import { Button, useToggle } from '@edx/paragon';
|
import { Button, useToggle } from '@edx/paragon';
|
||||||
|
|
||||||
import HTMLLoader from '../../../components/HTMLLoader';
|
|
||||||
import { ContentActions } from '../../../data/constants';
|
import { ContentActions } from '../../../data/constants';
|
||||||
import { AlertBanner, DeleteConfirmation, EndorsedAlertBanner } from '../../common';
|
import { AlertBanner, DeleteConfirmation } from '../../common';
|
||||||
import { selectBlackoutDate } from '../../data/selectors';
|
|
||||||
import { fetchThread } from '../../posts/data/thunks';
|
import { fetchThread } from '../../posts/data/thunks';
|
||||||
import { inBlackoutDateRange } from '../../utils';
|
|
||||||
import CommentIcons from '../comment-icons/CommentIcons';
|
import CommentIcons from '../comment-icons/CommentIcons';
|
||||||
import { selectCommentCurrentPage, selectCommentHasMorePages, selectCommentResponses } from '../data/selectors';
|
import { selectCommentCurrentPage, selectCommentHasMorePages, selectCommentResponses } from '../data/selectors';
|
||||||
import { editComment, fetchCommentResponses, removeComment } from '../data/thunks';
|
import { editComment, fetchCommentResponses, removeComment } from '../data/thunks';
|
||||||
@@ -26,7 +23,6 @@ function Comment({
|
|||||||
postType,
|
postType,
|
||||||
comment,
|
comment,
|
||||||
showFullThread = true,
|
showFullThread = true,
|
||||||
isClosedPost,
|
|
||||||
intl,
|
intl,
|
||||||
}) {
|
}) {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
@@ -38,113 +34,94 @@ function Comment({
|
|||||||
const [isReplying, setReplying] = useState(false);
|
const [isReplying, setReplying] = useState(false);
|
||||||
const hasMorePages = useSelector(selectCommentHasMorePages(comment.id));
|
const hasMorePages = useSelector(selectCommentHasMorePages(comment.id));
|
||||||
const currentPage = useSelector(selectCommentCurrentPage(comment.id));
|
const currentPage = useSelector(selectCommentCurrentPage(comment.id));
|
||||||
const blackoutDateRange = useSelector(selectBlackoutDate);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// If the comment has a parent comment, it won't have any children, so don't fetch them.
|
// If the comment has a parent comment, it won't have any children, so don't fetch them.
|
||||||
if (hasChildren && !currentPage && showFullThread) {
|
if (hasChildren && !currentPage && showFullThread) {
|
||||||
dispatch(fetchCommentResponses(comment.id, { page: 1 }));
|
dispatch(fetchCommentResponses(comment.id, { page: 1 }));
|
||||||
}
|
}
|
||||||
}, [comment.id]);
|
}, [comment.id]);
|
||||||
|
|
||||||
const actionHandlers = {
|
const actionHandlers = {
|
||||||
[ContentActions.EDIT_CONTENT]: () => setEditing(true),
|
[ContentActions.EDIT_CONTENT]: () => setEditing(true),
|
||||||
[ContentActions.ENDORSE]: async () => {
|
[ContentActions.ENDORSE]: async () => {
|
||||||
await dispatch(editComment(comment.id, { endorsed: !comment.endorsed }, ContentActions.ENDORSE));
|
await dispatch(editComment(comment.id, { endorsed: !comment.endorsed }));
|
||||||
await dispatch(fetchThread(comment.threadId));
|
await dispatch(fetchThread(comment.threadId));
|
||||||
},
|
},
|
||||||
[ContentActions.DELETE]: showDeleteConfirmation,
|
[ContentActions.DELETE]: showDeleteConfirmation,
|
||||||
[ContentActions.REPORT]: () => dispatch(editComment(comment.id, { flagged: !comment.abuseFlagged })),
|
[ContentActions.REPORT]: () => dispatch(editComment(comment.id, { flagged: !comment.abuseFlagged })),
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLoadMoreComments = () => (
|
const handleLoadMoreComments = () => (
|
||||||
dispatch(fetchCommentResponses(comment.id, { page: currentPage + 1 }))
|
dispatch(fetchCommentResponses(comment.id, { page: currentPage + 1 }))
|
||||||
);
|
);
|
||||||
|
const commentClasses = classNames('d-flex flex-column card', { 'my-3': showFullThread });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classNames({ 'py-2 my-3': showFullThread })}>
|
<div className={commentClasses} data-testid={`comment-${comment.id}`}>
|
||||||
<div className="d-flex flex-column card" data-testid={`comment-${comment.id}`} role="listitem">
|
<DeleteConfirmation
|
||||||
<DeleteConfirmation
|
isOpen={isDeleting}
|
||||||
isOpen={isDeleting}
|
title={intl.formatMessage(messages.deleteResponseTitle)}
|
||||||
title={intl.formatMessage(messages.deleteResponseTitle)}
|
description={intl.formatMessage(messages.deleteResponseDescription)}
|
||||||
description={intl.formatMessage(messages.deleteResponseDescription)}
|
onClose={hideDeleteConfirmation}
|
||||||
onClose={hideDeleteConfirmation}
|
onDelete={() => {
|
||||||
onDelete={() => {
|
dispatch(removeComment(comment.id));
|
||||||
dispatch(removeComment(comment.id));
|
hideDeleteConfirmation();
|
||||||
hideDeleteConfirmation();
|
}}
|
||||||
}}
|
/>
|
||||||
|
<AlertBanner postType={postType} content={comment} />
|
||||||
|
<div className="d-flex flex-column p-4">
|
||||||
|
<CommentHeader comment={comment} actionHandlers={actionHandlers} postType={postType} />
|
||||||
|
{isEditing
|
||||||
|
? (
|
||||||
|
<CommentEditor comment={comment} onCloseEditor={() => setEditing(false)} />
|
||||||
|
)
|
||||||
|
// eslint-disable-next-line react/no-danger
|
||||||
|
: <div className="comment-body px-2" dangerouslySetInnerHTML={{ __html: comment.renderedBody }} />}
|
||||||
|
<CommentIcons
|
||||||
|
comment={comment}
|
||||||
|
following={comment.following}
|
||||||
|
onLike={() => dispatch(editComment(comment.id, { voted: !comment.voted }))}
|
||||||
|
createdAt={comment.createdAt}
|
||||||
/>
|
/>
|
||||||
<EndorsedAlertBanner postType={postType} content={comment} />
|
<div className="d-flex my-2 flex-column">
|
||||||
<div className="d-flex flex-column p-4.5">
|
{/* Pass along intl since component used here is the one before it's injected with `injectIntl` */}
|
||||||
<AlertBanner content={comment} />
|
{inlineReplies.map(inlineReply => (
|
||||||
<CommentHeader comment={comment} actionHandlers={actionHandlers} postType={postType} />
|
<Reply
|
||||||
{isEditing
|
reply={inlineReply}
|
||||||
? (
|
postType={postType}
|
||||||
<CommentEditor comment={comment} onCloseEditor={() => setEditing(false)} formClasses="pt-3" />
|
key={inlineReply.id}
|
||||||
)
|
intl={intl}
|
||||||
: <HTMLLoader cssClassName="comment-body pt-4 text-primary-500" componentId="comment" htmlNode={comment.renderedBody} />}
|
/>
|
||||||
<CommentIcons
|
))}
|
||||||
comment={comment}
|
</div>
|
||||||
following={comment.following}
|
{hasMorePages && (
|
||||||
onLike={() => dispatch(editComment(comment.id, { voted: !comment.voted }))}
|
|
||||||
createdAt={comment.createdAt}
|
|
||||||
/>
|
|
||||||
<div className="sr-only" role="heading" aria-level="3"> {intl.formatMessage(messages.replies, { count: inlineReplies.length })}</div>
|
|
||||||
<div className="d-flex flex-column" role="list">
|
|
||||||
{/* Pass along intl since component used here is the one before it's injected with `injectIntl` */}
|
|
||||||
{inlineReplies.map(inlineReply => (
|
|
||||||
<Reply
|
|
||||||
reply={inlineReply}
|
|
||||||
postType={postType}
|
|
||||||
key={inlineReply.id}
|
|
||||||
intl={intl}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{hasMorePages && (
|
|
||||||
<Button
|
<Button
|
||||||
onClick={handleLoadMoreComments}
|
onClick={handleLoadMoreComments}
|
||||||
variant="link"
|
variant="link"
|
||||||
block="true"
|
block="true"
|
||||||
className="mt-4.5 font-size-14 font-style-normal font-family-inter font-weight-500 px-2.5 py-2"
|
className="my-4"
|
||||||
data-testid="load-more-comments-responses"
|
data-testid="load-more-comments-responses"
|
||||||
style={{
|
|
||||||
lineHeight: '20px',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{intl.formatMessage(messages.loadMoreComments)}
|
{intl.formatMessage(messages.loadMoreResponses)}
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
|
{!isNested && showFullThread
|
||||||
|
&& (
|
||||||
|
isReplying
|
||||||
|
? (
|
||||||
|
<CommentEditor
|
||||||
|
comment={{
|
||||||
|
threadId: comment.threadId,
|
||||||
|
parentId: comment.id,
|
||||||
|
}}
|
||||||
|
edit={false}
|
||||||
|
onCloseEditor={() => setReplying(false)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<Button className="d-flex flex-grow " variant="outline-secondary" onClick={() => setReplying(true)}>
|
||||||
|
{intl.formatMessage(messages.addComment)}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
{!isNested && showFullThread && (
|
|
||||||
isReplying ? (
|
|
||||||
<CommentEditor
|
|
||||||
comment={{
|
|
||||||
threadId: comment.threadId,
|
|
||||||
parentId: comment.id,
|
|
||||||
}}
|
|
||||||
edit={false}
|
|
||||||
onCloseEditor={() => setReplying(false)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{(!isClosedPost && !inBlackoutDateRange(blackoutDateRange))
|
|
||||||
&& (
|
|
||||||
<Button
|
|
||||||
className="d-flex flex-grow mt-3 py-2 font-size-14"
|
|
||||||
variant="outline-primary"
|
|
||||||
style={{
|
|
||||||
lineHeight: '20px',
|
|
||||||
}}
|
|
||||||
onClick={() => setReplying(true)}
|
|
||||||
>
|
|
||||||
{intl.formatMessage(messages.addComment)}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -154,13 +131,11 @@ Comment.propTypes = {
|
|||||||
postType: PropTypes.oneOf(['discussion', 'question']).isRequired,
|
postType: PropTypes.oneOf(['discussion', 'question']).isRequired,
|
||||||
comment: commentShape.isRequired,
|
comment: commentShape.isRequired,
|
||||||
showFullThread: PropTypes.bool,
|
showFullThread: PropTypes.bool,
|
||||||
isClosedPost: PropTypes.bool,
|
|
||||||
intl: intlShape.isRequired,
|
intl: intlShape.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
Comment.defaultProps = {
|
Comment.defaultProps = {
|
||||||
showFullThread: true,
|
showFullThread: true,
|
||||||
isClosedPost: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(Comment);
|
export default injectIntl(Comment);
|
||||||
|
|||||||
@@ -10,15 +10,8 @@ import { AppContext } from '@edx/frontend-platform/react';
|
|||||||
import { Button, Form, StatefulButton } from '@edx/paragon';
|
import { Button, Form, StatefulButton } from '@edx/paragon';
|
||||||
|
|
||||||
import { TinyMCEEditor } from '../../../components';
|
import { TinyMCEEditor } from '../../../components';
|
||||||
import FormikErrorFeedback from '../../../components/FormikErrorFeedback';
|
|
||||||
import PostPreviewPane from '../../../components/PostPreviewPane';
|
|
||||||
import { useDispatchWithState } from '../../../data/hooks';
|
import { useDispatchWithState } from '../../../data/hooks';
|
||||||
import {
|
import { selectModerationSettings, selectUserIsPrivileged } from '../../data/selectors';
|
||||||
selectModerationSettings,
|
|
||||||
selectUserHasModerationPrivileges,
|
|
||||||
selectUserIsGroupTa,
|
|
||||||
selectUserIsStaff,
|
|
||||||
} from '../../data/selectors';
|
|
||||||
import { formikCompatibleHandler, isFormikFieldInvalid } from '../../utils';
|
import { formikCompatibleHandler, isFormikFieldInvalid } from '../../utils';
|
||||||
import { addComment, editComment } from '../data/thunks';
|
import { addComment, editComment } from '../data/thunks';
|
||||||
import messages from '../messages';
|
import messages from '../messages';
|
||||||
@@ -28,48 +21,15 @@ function CommentEditor({
|
|||||||
comment,
|
comment,
|
||||||
onCloseEditor,
|
onCloseEditor,
|
||||||
edit,
|
edit,
|
||||||
formClasses,
|
|
||||||
}) {
|
}) {
|
||||||
const editorRef = useRef(null);
|
|
||||||
const { authenticatedUser } = useContext(AppContext);
|
const { authenticatedUser } = useContext(AppContext);
|
||||||
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
|
const userIsPrivileged = useSelector(selectUserIsPrivileged);
|
||||||
const userIsGroupTa = useSelector(selectUserIsGroupTa);
|
|
||||||
const userIsStaff = useSelector(selectUserIsStaff);
|
|
||||||
const { reasonCodesEnabled, editReasons } = useSelector(selectModerationSettings);
|
const { reasonCodesEnabled, editReasons } = useSelector(selectModerationSettings);
|
||||||
const [submitting, dispatch] = useDispatchWithState();
|
const [submitting, dispatch] = useDispatchWithState();
|
||||||
|
const editorRef = useRef(null);
|
||||||
const canDisplayEditReason = (reasonCodesEnabled && edit
|
const saveUpdatedComment = async (values) => {
|
||||||
&& (userHasModerationPrivileges || userIsGroupTa || userIsStaff)
|
|
||||||
&& comment?.author !== authenticatedUser.username
|
|
||||||
);
|
|
||||||
|
|
||||||
const editReasonCodeValidation = canDisplayEditReason && {
|
|
||||||
editReasonCode: Yup.string().required(intl.formatMessage(messages.editReasonCodeError)),
|
|
||||||
};
|
|
||||||
|
|
||||||
const validationSchema = Yup.object().shape({
|
|
||||||
comment: Yup.string()
|
|
||||||
.required(),
|
|
||||||
...editReasonCodeValidation,
|
|
||||||
});
|
|
||||||
|
|
||||||
const initialValues = {
|
|
||||||
comment: comment.rawBody,
|
|
||||||
editReasonCode: comment?.lastEdit?.reasonCode || (userIsStaff ? 'violates-guidelines' : ''),
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCloseEditor = (resetForm) => {
|
|
||||||
resetForm({ values: initialValues });
|
|
||||||
onCloseEditor();
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveUpdatedComment = async (values, { resetForm }) => {
|
|
||||||
if (comment.id) {
|
if (comment.id) {
|
||||||
const payload = {
|
await dispatch(editComment(comment.id, values));
|
||||||
...values,
|
|
||||||
editReasonCode: values.editReasonCode || undefined,
|
|
||||||
};
|
|
||||||
await dispatch(editComment(comment.id, payload));
|
|
||||||
} else {
|
} else {
|
||||||
await dispatch(addComment(values.comment, comment.threadId, comment.parentId));
|
await dispatch(addComment(values.comment, comment.threadId, comment.parentId));
|
||||||
}
|
}
|
||||||
@@ -77,16 +37,22 @@ function CommentEditor({
|
|||||||
if (editorRef.current) {
|
if (editorRef.current) {
|
||||||
editorRef.current.plugins.autosave.removeDraft();
|
editorRef.current.plugins.autosave.removeDraft();
|
||||||
}
|
}
|
||||||
handleCloseEditor(resetForm);
|
onCloseEditor();
|
||||||
};
|
};
|
||||||
// The editorId is used to autosave contents to localstorage. This format means that the autosave is scoped to
|
// 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.
|
// the current comment id, or the current comment parent or the curren thread.
|
||||||
const editorId = `comment-editor-${comment.id || comment.parentId || comment.threadId}`;
|
const editorId = `comment-editor-${comment.id || comment.parentId || comment.threadId}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Formik
|
<Formik
|
||||||
initialValues={initialValues}
|
initialValues={{ comment: comment.rawBody }}
|
||||||
validationSchema={validationSchema}
|
validationSchema={Yup.object()
|
||||||
|
.shape({
|
||||||
|
comment: Yup.string()
|
||||||
|
.required(),
|
||||||
|
editReasonCode: Yup.string()
|
||||||
|
.nullable()
|
||||||
|
.default(undefined),
|
||||||
|
})}
|
||||||
onSubmit={saveUpdatedComment}
|
onSubmit={saveUpdatedComment}
|
||||||
>
|
>
|
||||||
{({
|
{({
|
||||||
@@ -96,19 +62,17 @@ function CommentEditor({
|
|||||||
handleSubmit,
|
handleSubmit,
|
||||||
handleBlur,
|
handleBlur,
|
||||||
handleChange,
|
handleChange,
|
||||||
resetForm,
|
|
||||||
}) => (
|
}) => (
|
||||||
<Form onSubmit={handleSubmit} className={formClasses}>
|
<Form onSubmit={handleSubmit}>
|
||||||
{canDisplayEditReason && (
|
{(reasonCodesEnabled
|
||||||
<Form.Group
|
&& userIsPrivileged
|
||||||
isInvalid={isFormikFieldInvalid('editReasonCode', {
|
&& comment.author !== authenticatedUser.username
|
||||||
errors,
|
&& edit
|
||||||
touched,
|
) && (
|
||||||
})}
|
<Form.Group>
|
||||||
>
|
|
||||||
<Form.Control
|
<Form.Control
|
||||||
name="editReasonCode"
|
name="editReasonCode"
|
||||||
className="mt-2 mr-0"
|
className="mt-2"
|
||||||
as="select"
|
as="select"
|
||||||
value={values.editReasonCode}
|
value={values.editReasonCode}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
@@ -124,7 +88,6 @@ function CommentEditor({
|
|||||||
<option key={code} value={code}>{label}</option>
|
<option key={code} value={code}>{label}</option>
|
||||||
))}
|
))}
|
||||||
</Form.Control>
|
</Form.Control>
|
||||||
<FormikErrorFeedback name="editReasonCode" />
|
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
)}
|
)}
|
||||||
<TinyMCEEditor
|
<TinyMCEEditor
|
||||||
@@ -148,11 +111,10 @@ function CommentEditor({
|
|||||||
{intl.formatMessage(messages.commentError)}
|
{intl.formatMessage(messages.commentError)}
|
||||||
</Form.Control.Feedback>
|
</Form.Control.Feedback>
|
||||||
)}
|
)}
|
||||||
<PostPreviewPane htmlNode={values.comment} />
|
|
||||||
<div className="d-flex py-2 justify-content-end">
|
<div className="d-flex py-2 justify-content-end">
|
||||||
<Button
|
<Button
|
||||||
variant="outline-primary"
|
variant="outline-primary"
|
||||||
onClick={() => handleCloseEditor(resetForm)}
|
onClick={onCloseEditor}
|
||||||
>
|
>
|
||||||
{intl.formatMessage(messages.cancel)}
|
{intl.formatMessage(messages.cancel)}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -180,17 +142,14 @@ CommentEditor.propTypes = {
|
|||||||
parentId: PropTypes.string,
|
parentId: PropTypes.string,
|
||||||
rawBody: PropTypes.string,
|
rawBody: PropTypes.string,
|
||||||
author: PropTypes.string,
|
author: PropTypes.string,
|
||||||
lastEdit: PropTypes.object,
|
|
||||||
}).isRequired,
|
}).isRequired,
|
||||||
onCloseEditor: PropTypes.func.isRequired,
|
onCloseEditor: PropTypes.func.isRequired,
|
||||||
intl: intlShape.isRequired,
|
intl: intlShape.isRequired,
|
||||||
edit: PropTypes.bool,
|
edit: PropTypes.bool,
|
||||||
formClasses: PropTypes.string,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
CommentEditor.defaultProps = {
|
CommentEditor.defaultProps = {
|
||||||
edit: true,
|
edit: true,
|
||||||
formClasses: '',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(CommentEditor);
|
export default injectIntl(CommentEditor);
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
|
|
||||||
import { injectIntl } from '@edx/frontend-platform/i18n';
|
import { injectIntl } from '@edx/frontend-platform/i18n';
|
||||||
import { Avatar, Icon } from '@edx/paragon';
|
import { Avatar, Icon } from '@edx/paragon';
|
||||||
import { CheckCircle, Verified } from '@edx/paragon/icons';
|
import { CheckCircle, Verified } from '@edx/paragon/icons';
|
||||||
|
|
||||||
import { AvatarOutlineAndLabelColors, ThreadType } from '../../../data/constants';
|
import { AvatarBorderAndLabelColors, ThreadType } from '../../../data/constants';
|
||||||
import { AuthorLabel } from '../../common';
|
import { AuthorLabel } from '../../common';
|
||||||
import ActionsDropdown from '../../common/ActionsDropdown';
|
import ActionsDropdown from '../../common/ActionsDropdown';
|
||||||
import { useAlertBannerVisible } from '../../data/hooks';
|
|
||||||
import { selectAuthorAvatars } from '../../posts/data/selectors';
|
import { selectAuthorAvatars } from '../../posts/data/selectors';
|
||||||
import { commentShape } from './proptypes';
|
import { commentShape } from './proptypes';
|
||||||
|
|
||||||
@@ -21,37 +19,22 @@ function CommentHeader({
|
|||||||
actionHandlers,
|
actionHandlers,
|
||||||
}) {
|
}) {
|
||||||
const authorAvatars = useSelector(selectAuthorAvatars(comment.author));
|
const authorAvatars = useSelector(selectAuthorAvatars(comment.author));
|
||||||
const colorClass = AvatarOutlineAndLabelColors[comment.authorLabel];
|
const colorClass = AvatarBorderAndLabelColors[comment.authorLabel];
|
||||||
const hasAnyAlert = useAlertBannerVisible(comment);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classNames('d-flex flex-row justify-content-between', {
|
<div className="d-flex flex-row justify-content-between">
|
||||||
'mt-2': hasAnyAlert,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<div className="align-items-center d-flex flex-row">
|
<div className="align-items-center d-flex flex-row">
|
||||||
<Avatar
|
<Avatar
|
||||||
className={`border-0 ml-0.5 mr-2.5 ${colorClass ? `outline-${colorClass}` : 'outline-anonymous'}`}
|
className={`m-2 ${colorClass && `border-${colorClass}`}`}
|
||||||
|
style={{ borderWidth: '2px' }}
|
||||||
alt={comment.author}
|
alt={comment.author}
|
||||||
src={authorAvatars?.imageUrlSmall}
|
src={authorAvatars?.imageUrlSmall}
|
||||||
style={{
|
|
||||||
width: '32px',
|
|
||||||
height: '32px',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<AuthorLabel
|
|
||||||
author={comment.author}
|
|
||||||
authorLabel={comment.authorLabel}
|
|
||||||
labelColor={colorClass && `text-${colorClass}`}
|
|
||||||
linkToProfile
|
|
||||||
/>
|
/>
|
||||||
|
<AuthorLabel author={comment.author} authorLabel={comment.authorLabel} labelColor={colorClass && `text-${colorClass}`} />
|
||||||
</div>
|
</div>
|
||||||
<div className="d-flex align-items-center">
|
<div className="d-flex align-items-center">
|
||||||
<span className="btn-icon btn-icon-sm mr-1 align-items-center">
|
{comment.endorsed && (postType === 'question'
|
||||||
{comment.endorsed && (postType === 'question'
|
? <Icon src={CheckCircle} className="text-success" data-testid="check-icon" />
|
||||||
? <Icon src={CheckCircle} className="text-success" data-testid="check-icon" />
|
: <Icon src={Verified} data-testid="verified-icon" />)}
|
||||||
: <Icon src={Verified} className="text-dark-500" data-testid="verified-icon" />)}
|
|
||||||
</span>
|
|
||||||
<ActionsDropdown
|
<ActionsDropdown
|
||||||
commentOrPost={{
|
commentOrPost={{
|
||||||
...comment,
|
...comment,
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { initializeMockApp } from '@edx/frontend-platform';
|
|||||||
import { AppProvider } from '@edx/frontend-platform/react';
|
import { AppProvider } from '@edx/frontend-platform/react';
|
||||||
|
|
||||||
import { initializeStore } from '../../../store';
|
import { initializeStore } from '../../../store';
|
||||||
import { DiscussionContext } from '../../common/context';
|
|
||||||
import CommentHeader from './CommentHeader';
|
import CommentHeader from './CommentHeader';
|
||||||
|
|
||||||
let store;
|
let store;
|
||||||
@@ -16,11 +15,7 @@ function renderComponent(comment, postType, actionHandlers) {
|
|||||||
return render(
|
return render(
|
||||||
<IntlProvider locale="en">
|
<IntlProvider locale="en">
|
||||||
<AppProvider store={store}>
|
<AppProvider store={store}>
|
||||||
<DiscussionContext.Provider
|
<CommentHeader comment={comment} postType={postType} actionHandlers={actionHandlers} />
|
||||||
value={{ courseId: 'course-v1:edX+TestX+Test_Course' }}
|
|
||||||
>
|
|
||||||
<CommentHeader comment={comment} postType={postType} actionHandlers={actionHandlers} />
|
|
||||||
</DiscussionContext.Provider>
|
|
||||||
</AppProvider>
|
</AppProvider>
|
||||||
</IntlProvider>,
|
</IntlProvider>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,13 +7,10 @@ import * as timeago from 'timeago.js';
|
|||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||||
import { Avatar, useToggle } from '@edx/paragon';
|
import { Avatar, useToggle } from '@edx/paragon';
|
||||||
|
|
||||||
import HTMLLoader from '../../../components/HTMLLoader';
|
import { AvatarBorderAndLabelColors, ContentActions } from '../../../data/constants';
|
||||||
import { AvatarOutlineAndLabelColors, ContentActions } from '../../../data/constants';
|
|
||||||
import {
|
import {
|
||||||
ActionsDropdown, AlertBanner, AuthorLabel, DeleteConfirmation,
|
ActionsDropdown, AlertBanner, AuthorLabel, DeleteConfirmation,
|
||||||
} from '../../common';
|
} from '../../common';
|
||||||
import timeLocale from '../../common/time-locale';
|
|
||||||
import { useAlertBannerVisible } from '../../data/hooks';
|
|
||||||
import { selectAuthorAvatars } from '../../posts/data/selectors';
|
import { selectAuthorAvatars } from '../../posts/data/selectors';
|
||||||
import { editComment, removeComment } from '../data/thunks';
|
import { editComment, removeComment } from '../data/thunks';
|
||||||
import messages from '../messages';
|
import messages from '../messages';
|
||||||
@@ -25,26 +22,19 @@ function Reply({
|
|||||||
postType,
|
postType,
|
||||||
intl,
|
intl,
|
||||||
}) {
|
}) {
|
||||||
timeago.register('time-locale', timeLocale);
|
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const [isEditing, setEditing] = useState(false);
|
const [isEditing, setEditing] = useState(false);
|
||||||
const [isDeleting, showDeleteConfirmation, hideDeleteConfirmation] = useToggle(false);
|
const [isDeleting, showDeleteConfirmation, hideDeleteConfirmation] = useToggle(false);
|
||||||
const actionHandlers = {
|
const actionHandlers = {
|
||||||
[ContentActions.EDIT_CONTENT]: () => setEditing(true),
|
[ContentActions.EDIT_CONTENT]: () => setEditing(true),
|
||||||
[ContentActions.ENDORSE]: () => dispatch(editComment(
|
[ContentActions.ENDORSE]: () => dispatch(editComment(reply.id, { endorsed: !reply.endorsed })),
|
||||||
reply.id,
|
|
||||||
{ endorsed: !reply.endorsed },
|
|
||||||
ContentActions.ENDORSE,
|
|
||||||
)),
|
|
||||||
[ContentActions.DELETE]: showDeleteConfirmation,
|
[ContentActions.DELETE]: showDeleteConfirmation,
|
||||||
[ContentActions.REPORT]: () => dispatch(editComment(reply.id, { flagged: !reply.abuseFlagged })),
|
[ContentActions.REPORT]: () => dispatch(editComment(reply.id, { flagged: !reply.abuseFlagged })),
|
||||||
};
|
};
|
||||||
const authorAvatars = useSelector(selectAuthorAvatars(reply.author));
|
const authorAvatars = useSelector(selectAuthorAvatars(reply.author));
|
||||||
const colorClass = AvatarOutlineAndLabelColors[reply.authorLabel];
|
const colorClass = AvatarBorderAndLabelColors[reply.authorLabel];
|
||||||
const hasAnyAlert = useAlertBannerVisible(reply);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="d-flex flex-column mt-4.5" data-testid={`reply-${reply.id}`} role="listitem">
|
<div className="d-flex my-2 flex-column" data-testid={`reply-${reply.id}`}>
|
||||||
<DeleteConfirmation
|
<DeleteConfirmation
|
||||||
isOpen={isDeleting}
|
isOpen={isDeleting}
|
||||||
title={intl.formatMessage(messages.deleteCommentTitle)}
|
title={intl.formatMessage(messages.deleteCommentTitle)}
|
||||||
@@ -55,36 +45,22 @@ function Reply({
|
|||||||
hideDeleteConfirmation();
|
hideDeleteConfirmation();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<div className="d-flex flex-fill ml-6">
|
||||||
{hasAnyAlert && (
|
<AlertBanner postType={null} content={reply} intl={intl} />
|
||||||
<div className="d-flex">
|
</div>
|
||||||
<div className="d-flex invisible">
|
|
||||||
<Avatar />
|
|
||||||
</div>
|
|
||||||
<div className="w-100">
|
|
||||||
<AlertBanner content={reply} intl={intl} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="d-flex">
|
<div className="d-flex">
|
||||||
<div className="d-flex mr-3 mt-2.5">
|
|
||||||
|
<div className="d-flex m-3">
|
||||||
<Avatar
|
<Avatar
|
||||||
className={`ml-0.5 mt-0.5 border-0 ${colorClass ? `outline-${colorClass}` : 'outline-anonymous'}`}
|
className={`m-2 ${colorClass && `border-${colorClass}`}`}
|
||||||
|
style={{ borderWidth: '2px' }}
|
||||||
alt={reply.author}
|
alt={reply.author}
|
||||||
src={authorAvatars?.imageUrlSmall}
|
src={authorAvatars?.imageUrlSmall}
|
||||||
style={{
|
|
||||||
width: '32px',
|
|
||||||
height: '32px',
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div className="rounded bg-light-300 px-4 py-2 flex-fill">
|
||||||
className="bg-light-300 px-4 pb-2 pt-2.5 flex-fill"
|
<div className="d-flex flex-row justify-content-between align-items-center">
|
||||||
style={{ borderRadius: '0rem 0.375rem 0.375rem' }}
|
<AuthorLabel author={reply.author} authorLabel={reply.authorLabel} labelColor={colorClass && `text-${colorClass}`} />
|
||||||
>
|
|
||||||
<div className="d-flex flex-row justify-content-between align-items-center mb-0.5">
|
|
||||||
<AuthorLabel author={reply.author} authorLabel={reply.authorLabel} labelColor={colorClass && `text-${colorClass}`} linkToProfile />
|
|
||||||
<ActionsDropdown
|
<ActionsDropdown
|
||||||
commentOrPost={{
|
commentOrPost={{
|
||||||
...reply,
|
...reply,
|
||||||
@@ -95,11 +71,13 @@ function Reply({
|
|||||||
</div>
|
</div>
|
||||||
{isEditing
|
{isEditing
|
||||||
? <CommentEditor comment={reply} onCloseEditor={() => setEditing(false)} />
|
? <CommentEditor comment={reply} onCloseEditor={() => setEditing(false)} />
|
||||||
: <HTMLLoader componentId="reply" htmlNode={reply.renderedBody} cssClassName="text-primary-500" />}
|
// eslint-disable-next-line react/no-danger
|
||||||
|
: <div dangerouslySetInnerHTML={{ __html: reply.renderedBody }} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-gray-500 align-self-end mt-2" title={reply.createdAt}>
|
<div className="text-gray-500 align-self-end mt-2" title={reply.createdAt}>
|
||||||
{timeago.format(reply.createdAt, 'time-locale')}
|
{timeago.format(reply.createdAt, intl.locale)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,50 +1,27 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
|
||||||
import { useSelector } from 'react-redux';
|
|
||||||
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||||
import { Button } from '@edx/paragon';
|
import { Button } from '@edx/paragon';
|
||||||
|
|
||||||
import { selectBlackoutDate } from '../../data/selectors';
|
|
||||||
import { inBlackoutDateRange } from '../../utils';
|
|
||||||
import messages from '../messages';
|
import messages from '../messages';
|
||||||
import CommentEditor from './CommentEditor';
|
import CommentEditor from './CommentEditor';
|
||||||
|
|
||||||
function ResponseEditor({
|
function ResponseEditor({
|
||||||
postId,
|
postId,
|
||||||
intl,
|
intl,
|
||||||
addWrappingDiv,
|
|
||||||
}) {
|
}) {
|
||||||
const [addingResponse, setAddingResponse] = useState(false);
|
const [addingResponse, setAddingResponse] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setAddingResponse(false);
|
|
||||||
}, [postId]);
|
|
||||||
|
|
||||||
const blackoutDateRange = useSelector(selectBlackoutDate);
|
|
||||||
|
|
||||||
return addingResponse
|
return addingResponse
|
||||||
? (
|
? (
|
||||||
<div className={classNames({ 'bg-white p-4 mb-4 rounded': addWrappingDiv })}>
|
<CommentEditor
|
||||||
<CommentEditor
|
comment={{ threadId: postId }}
|
||||||
comment={{ threadId: postId }}
|
edit={false}
|
||||||
edit={false}
|
onCloseEditor={() => setAddingResponse(false)}
|
||||||
onCloseEditor={() => setAddingResponse(false)}
|
/>
|
||||||
/>
|
) : (
|
||||||
</div>
|
<div className="actions d-flex">
|
||||||
)
|
<Button variant="primary" onClick={() => setAddingResponse(true)}>
|
||||||
: !inBlackoutDateRange(blackoutDateRange) && (
|
|
||||||
<div className={classNames({ 'mb-4': addWrappingDiv }, 'actions d-flex')}>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
className="px-2.5 py-2 font-size-14"
|
|
||||||
onClick={() => setAddingResponse(true)}
|
|
||||||
style={{
|
|
||||||
lineHeight: '20px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{intl.formatMessage(messages.addResponse)}
|
{intl.formatMessage(messages.addResponse)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -54,11 +31,6 @@ function ResponseEditor({
|
|||||||
ResponseEditor.propTypes = {
|
ResponseEditor.propTypes = {
|
||||||
postId: PropTypes.string.isRequired,
|
postId: PropTypes.string.isRequired,
|
||||||
intl: intlShape.isRequired,
|
intl: intlShape.isRequired,
|
||||||
addWrappingDiv: PropTypes.bool,
|
|
||||||
};
|
|
||||||
|
|
||||||
ResponseEditor.defaultProps = {
|
|
||||||
addWrappingDiv: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(ResponseEditor);
|
export default injectIntl(ResponseEditor);
|
||||||
|
|||||||
@@ -117,3 +117,29 @@ export async function deleteComment(commentId) {
|
|||||||
await getAuthenticatedHttpClient()
|
await getAuthenticatedHttpClient()
|
||||||
.delete(url);
|
.delete(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the comments by a specific user in a course's discussions
|
||||||
|
*
|
||||||
|
* comments = responses + comments in the UI
|
||||||
|
*
|
||||||
|
* @param {string} courseId Course ID for the course
|
||||||
|
* @param {string} username Username of the user
|
||||||
|
* @returns API response in the format
|
||||||
|
* {
|
||||||
|
* results: [array of comments],
|
||||||
|
* pagination: {count, num_pages, next, previous}
|
||||||
|
* }
|
||||||
|
|
||||||
|
*/
|
||||||
|
export async function getUserComments(courseId, username, { page }) {
|
||||||
|
const { data } = await getAuthenticatedHttpClient()
|
||||||
|
.get(commentsApiUrl, {
|
||||||
|
params: {
|
||||||
|
course_id: courseId,
|
||||||
|
username,
|
||||||
|
page,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|||||||
@@ -145,18 +145,6 @@ const commentsSlice = createSlice({
|
|||||||
state.commentsById[payload.id] = payload;
|
state.commentsById[payload.id] = payload;
|
||||||
state.commentDraft = null;
|
state.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;
|
|
||||||
|
|
||||||
state.commentsInThreads[threadId][commentRemoveListType] = (
|
|
||||||
state.commentsInThreads[threadId]?.[commentRemoveListType]?.filter(item => item !== commentId)
|
|
||||||
);
|
|
||||||
state.commentsInThreads[threadId][commentAddListtype] = [
|
|
||||||
...state.commentsInThreads[threadId][commentAddListtype], payload.id,
|
|
||||||
];
|
|
||||||
},
|
|
||||||
deleteCommentRequest: (state) => {
|
deleteCommentRequest: (state) => {
|
||||||
state.postStatus = RequestStatus.IN_PROGRESS;
|
state.postStatus = RequestStatus.IN_PROGRESS;
|
||||||
},
|
},
|
||||||
@@ -201,7 +189,6 @@ export const {
|
|||||||
updateCommentFailed,
|
updateCommentFailed,
|
||||||
updateCommentRequest,
|
updateCommentRequest,
|
||||||
updateCommentSuccess,
|
updateCommentSuccess,
|
||||||
updateCommentsList,
|
|
||||||
deleteCommentDenied,
|
deleteCommentDenied,
|
||||||
deleteCommentFailed,
|
deleteCommentFailed,
|
||||||
deleteCommentRequest,
|
deleteCommentRequest,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { camelCaseObject } from '@edx/frontend-platform';
|
import { camelCaseObject } from '@edx/frontend-platform';
|
||||||
import { logError } from '@edx/frontend-platform/logging';
|
import { logError } from '@edx/frontend-platform/logging';
|
||||||
|
|
||||||
import { ContentActions, EndorsementStatus } from '../../../data/constants';
|
import { EndorsementStatus } from '../../../data/constants';
|
||||||
import { getHttpErrorStatus } from '../../utils';
|
import { getHttpErrorStatus } from '../../utils';
|
||||||
import {
|
import {
|
||||||
deleteComment, getCommentResponses, getThreadComments, postComment, updateComment,
|
deleteComment, getCommentResponses, getThreadComments, postComment, updateComment,
|
||||||
@@ -27,7 +27,6 @@ import {
|
|||||||
updateCommentDenied,
|
updateCommentDenied,
|
||||||
updateCommentFailed,
|
updateCommentFailed,
|
||||||
updateCommentRequest,
|
updateCommentRequest,
|
||||||
updateCommentsList,
|
|
||||||
updateCommentSuccess,
|
updateCommentSuccess,
|
||||||
} from './slices';
|
} from './slices';
|
||||||
|
|
||||||
@@ -117,15 +116,12 @@ export function fetchCommentResponses(commentId, { page = 1 } = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function editComment(commentId, comment, action = null) {
|
export function editComment(commentId, comment) {
|
||||||
return async (dispatch) => {
|
return async (dispatch) => {
|
||||||
try {
|
try {
|
||||||
dispatch(updateCommentRequest({ commentId }));
|
dispatch(updateCommentRequest({ commentId }));
|
||||||
const data = await updateComment(commentId, comment);
|
const data = await updateComment(commentId, comment);
|
||||||
dispatch(updateCommentSuccess(camelCaseObject(data)));
|
dispatch(updateCommentSuccess(camelCaseObject(data)));
|
||||||
if (action === ContentActions.ENDORSE) {
|
|
||||||
dispatch(updateCommentsList(camelCaseObject(data)));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (getHttpErrorStatus(error) === 403) {
|
if (getHttpErrorStatus(error) === 403) {
|
||||||
dispatch(updateCommentDenied());
|
dispatch(updateCommentDenied());
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ const messages = defineMessages({
|
|||||||
},
|
},
|
||||||
endorsedResponseCount: {
|
endorsedResponseCount: {
|
||||||
id: 'discussions.comments.comment.endorsedResponseCount',
|
id: 'discussions.comments.comment.endorsedResponseCount',
|
||||||
defaultMessage: `{num, plural,
|
defaultMessage: `{num, plural,
|
||||||
=0 {No endorsed responses}
|
=0 {No endorsed responses}
|
||||||
one {Showing # endorsed response}
|
one {Showing # endorsed response}
|
||||||
other {Showing # endorsed responses}
|
other {Showing # endorsed responses}
|
||||||
@@ -55,7 +55,6 @@ const messages = defineMessages({
|
|||||||
defaultMessage: `{postType, select,
|
defaultMessage: `{postType, select,
|
||||||
discussion {Discussion}
|
discussion {Discussion}
|
||||||
question {Question}
|
question {Question}
|
||||||
other {{postType}}
|
|
||||||
} posted {relativeTime} by`,
|
} posted {relativeTime} by`,
|
||||||
description: 'Timestamp for when a user posted the message followed by username. The relative time is already translated.',
|
description: 'Timestamp for when a user posted the message followed by username. The relative time is already translated.',
|
||||||
},
|
},
|
||||||
@@ -148,11 +147,6 @@ const messages = defineMessages({
|
|||||||
defaultMessage: 'Reason for editing',
|
defaultMessage: 'Reason for editing',
|
||||||
description: 'Label for field visible to moderators that allows them to select a reason for editing another user\'s response',
|
description: 'Label for field visible to moderators that allows them to select a reason for editing another user\'s response',
|
||||||
},
|
},
|
||||||
editReasonCodeError: {
|
|
||||||
id: 'discussions.editor.posts.editReasonCode.error',
|
|
||||||
defaultMessage: 'Select reason for editing',
|
|
||||||
description: 'Error message visible to moderators when they submit the post/response/comment without select reason for editing',
|
|
||||||
},
|
|
||||||
editedBy: {
|
editedBy: {
|
||||||
id: 'discussions.comment.comments.editedBy',
|
id: 'discussions.comment.comments.editedBy',
|
||||||
defaultMessage: 'Edited by',
|
defaultMessage: 'Edited by',
|
||||||
@@ -167,16 +161,6 @@ const messages = defineMessages({
|
|||||||
id: 'discussions.post.closedBy',
|
id: 'discussions.post.closedBy',
|
||||||
defaultMessage: 'Post closed by',
|
defaultMessage: 'Post closed by',
|
||||||
},
|
},
|
||||||
replies: {
|
|
||||||
id: 'discussion.comment.repliesHeading',
|
|
||||||
defaultMessage: '{count} replies for the response added',
|
|
||||||
description: 'Text added for screen reader to understand nesting replies.',
|
|
||||||
},
|
|
||||||
time: {
|
|
||||||
id: 'discussion.comment.time',
|
|
||||||
defaultMessage: '{time} ago',
|
|
||||||
description: 'Time text for endorse banner',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export default messages;
|
export default messages;
|
||||||
|
|||||||
@@ -1,21 +1,18 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
import { useSelector } from 'react-redux';
|
|
||||||
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||||
import { logError } from '@edx/frontend-platform/logging';
|
import { logError } from '@edx/frontend-platform/logging';
|
||||||
import {
|
import {
|
||||||
Button, Dropdown, Icon, IconButton, ModalPopup, useToggle,
|
Button, Dropdown, Icon, IconButton, ModalPopup,
|
||||||
} from '@edx/paragon';
|
} from '@edx/paragon';
|
||||||
import { MoreHoriz } from '@edx/paragon/icons';
|
import { MoreHoriz } from '@edx/paragon/icons';
|
||||||
|
|
||||||
import { ContentActions } from '../../data/constants';
|
import { ContentActions } from '../../data/constants';
|
||||||
import { commentShape } from '../comments/comment/proptypes';
|
import { commentShape } from '../comments/comment/proptypes';
|
||||||
import { selectBlackoutDate } from '../data/selectors';
|
|
||||||
import messages from '../messages';
|
import messages from '../messages';
|
||||||
import { postShape } from '../posts/post/proptypes';
|
import { postShape } from '../posts/post/proptypes';
|
||||||
import { inBlackoutDateRange, useActions } from '../utils';
|
import { useActions } from '../utils';
|
||||||
|
|
||||||
function ActionsDropdown({
|
function ActionsDropdown({
|
||||||
intl,
|
intl,
|
||||||
@@ -23,8 +20,8 @@ function ActionsDropdown({
|
|||||||
disabled,
|
disabled,
|
||||||
actionHandlers,
|
actionHandlers,
|
||||||
}) {
|
}) {
|
||||||
const [isOpen, open, close] = useToggle(false);
|
const [isOpen, setOpen] = useState(false);
|
||||||
const [target, setTarget] = useState(null);
|
const dropdownIconRef = React.useRef(null);
|
||||||
const actions = useActions(commentOrPost);
|
const actions = useActions(commentOrPost);
|
||||||
const handleActions = (action) => {
|
const handleActions = (action) => {
|
||||||
const actionFunction = actionHandlers[action];
|
const actionFunction = actionHandlers[action];
|
||||||
@@ -34,25 +31,20 @@ function ActionsDropdown({
|
|||||||
logError(`Unknown or unimplemented action ${action}`);
|
logError(`Unknown or unimplemented action ${action}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const blackoutDateRange = useSelector(selectBlackoutDate);
|
|
||||||
// Find and remove edit action if in blackout date range.
|
|
||||||
if (inBlackoutDateRange(blackoutDateRange)) {
|
|
||||||
actions.splice(actions.findIndex(action => action.id === 'edit'), 1);
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<IconButton
|
<span ref={dropdownIconRef}>
|
||||||
onClick={open}
|
<IconButton
|
||||||
alt={intl.formatMessage(messages.actionsAlt)}
|
onClick={() => setOpen(!isOpen)}
|
||||||
src={MoreHoriz}
|
alt={intl.formatMessage(messages.actionsAlt)}
|
||||||
iconAs={Icon}
|
src={MoreHoriz}
|
||||||
disabled={disabled}
|
iconAs={Icon}
|
||||||
size="sm"
|
disabled={disabled}
|
||||||
ref={setTarget}
|
/>
|
||||||
/>
|
</span>
|
||||||
<ModalPopup
|
<ModalPopup
|
||||||
onClose={close}
|
onClose={() => setOpen(false)}
|
||||||
positionRef={target}
|
positionRef={dropdownIconRef}
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
placement="auto-start"
|
placement="auto-start"
|
||||||
>
|
>
|
||||||
@@ -70,7 +62,7 @@ function ActionsDropdown({
|
|||||||
variant="tertiary"
|
variant="tertiary"
|
||||||
size="inline"
|
size="inline"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
close();
|
setOpen(false);
|
||||||
handleActions(action.action);
|
handleActions(action.action);
|
||||||
}}
|
}}
|
||||||
className="d-flex justify-content-start py-1.5 mr-4"
|
className="d-flex justify-content-start py-1.5 mr-4"
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { camelCaseObject, initializeMockApp, snakeCaseObject } from '@edx/fronte
|
|||||||
import { AppProvider } from '@edx/frontend-platform/react';
|
import { AppProvider } from '@edx/frontend-platform/react';
|
||||||
|
|
||||||
import { ContentActions } from '../../data/constants';
|
import { ContentActions } from '../../data/constants';
|
||||||
import { initializeStore } from '../../store';
|
|
||||||
import messages from '../messages';
|
import messages from '../messages';
|
||||||
import { ACTIONS_LIST } from '../utils';
|
import { ACTIONS_LIST } from '../utils';
|
||||||
import ActionsDropdown from './ActionsDropdown';
|
import ActionsDropdown from './ActionsDropdown';
|
||||||
@@ -127,7 +126,6 @@ describe('ActionsDropdown', () => {
|
|||||||
roles: [],
|
roles: [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
store = initializeStore();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each(buildTestContent())('can open drop down if enabled', async (commentOrPost) => {
|
it.each(buildTestContent())('can open drop down if enabled', async (commentOrPost) => {
|
||||||
@@ -152,36 +150,6 @@ describe('ActionsDropdown', () => {
|
|||||||
await waitFor(() => expect(screen.queryByTestId('actions-dropdown-modal-popup')).not.toBeInTheDocument());
|
await waitFor(() => expect(screen.queryByTestId('actions-dropdown-modal-popup')).not.toBeInTheDocument());
|
||||||
});
|
});
|
||||||
|
|
||||||
it('copy link action should be visible on posts', async () => {
|
|
||||||
const commentOrPost = {
|
|
||||||
testFor: 'thread',
|
|
||||||
...camelCaseObject(Factory.build('thread', { editable_fields: ['copy_link'] }, null)),
|
|
||||||
};
|
|
||||||
renderComponent(commentOrPost, { disabled: false });
|
|
||||||
|
|
||||||
const openButton = await findOpenActionsDropdownButton();
|
|
||||||
await act(async () => {
|
|
||||||
fireEvent.click(openButton);
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => expect(screen.queryByText('Copy link')).toBeInTheDocument());
|
|
||||||
});
|
|
||||||
|
|
||||||
it('copy link action should not be visible on a comment', async () => {
|
|
||||||
const commentOrPost = {
|
|
||||||
testFor: 'comments',
|
|
||||||
...camelCaseObject(Factory.build('comment', {}, null)),
|
|
||||||
};
|
|
||||||
renderComponent(commentOrPost, { disabled: false });
|
|
||||||
|
|
||||||
const openButton = await findOpenActionsDropdownButton();
|
|
||||||
await act(async () => {
|
|
||||||
fireEvent.click(openButton);
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => expect(screen.queryByText('Copy link')).not.toBeInTheDocument());
|
|
||||||
});
|
|
||||||
|
|
||||||
describe.each(canPerformActionTestData)('Actions', ({
|
describe.each(canPerformActionTestData)('Actions', ({
|
||||||
testFor, action, label, reason, ...commentOrPost
|
testFor, action, label, reason, ...commentOrPost
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
@@ -2,62 +2,86 @@ import React from 'react';
|
|||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
|
import * as timeago from 'timeago.js';
|
||||||
|
|
||||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||||
import { Alert } from '@edx/paragon';
|
import { Alert } from '@edx/paragon';
|
||||||
import { Error } from '@edx/paragon/icons';
|
import { CheckCircle, Error, Verified } from '@edx/paragon/icons';
|
||||||
|
|
||||||
|
import { ThreadType } from '../../data/constants';
|
||||||
import { commentShape } from '../comments/comment/proptypes';
|
import { commentShape } from '../comments/comment/proptypes';
|
||||||
import messages from '../comments/messages';
|
import messages from '../comments/messages';
|
||||||
import { selectModerationSettings, selectUserHasModerationPrivileges, selectUserIsGroupTa } from '../data/selectors';
|
import { selectModerationSettings, selectUserIsPrivileged } from '../data/selectors';
|
||||||
import { postShape } from '../posts/post/proptypes';
|
import { postShape } from '../posts/post/proptypes';
|
||||||
import AuthorLabel from './AuthorLabel';
|
import AuthorLabel from './AuthorLabel';
|
||||||
|
|
||||||
function AlertBanner({
|
function AlertBanner({
|
||||||
intl,
|
intl,
|
||||||
content,
|
content,
|
||||||
|
postType,
|
||||||
}) {
|
}) {
|
||||||
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
|
const isQuestion = postType === ThreadType.QUESTION;
|
||||||
const userIsGroupTa = useSelector(selectUserIsGroupTa);
|
const classes = isQuestion ? 'bg-success-500 text-white' : 'bg-dark-500 text-white';
|
||||||
|
const iconClass = isQuestion ? CheckCircle : Verified;
|
||||||
|
const userIsPrivileged = useSelector(selectUserIsPrivileged);
|
||||||
const { reasonCodesEnabled } = useSelector(selectModerationSettings);
|
const { reasonCodesEnabled } = useSelector(selectModerationSettings);
|
||||||
const userIsContentAuthor = getAuthenticatedUser().username === content.author;
|
|
||||||
const canSeeLastEditOrClosedAlert = (userHasModerationPrivileges || userIsContentAuthor || userIsGroupTa);
|
|
||||||
const canSeeReportedBanner = content?.abuseFlagged;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{canSeeReportedBanner && (
|
{content.endorsed && (
|
||||||
<Alert icon={Error} variant="danger" className="px-3 mb-2 py-10px shadow-none flex-fill">
|
<Alert
|
||||||
|
variant="plain"
|
||||||
|
className={`p-3 m-0 align-items-center shadow-none ${classes}`}
|
||||||
|
style={{ borderRadius: '0.375rem 0.375rem 0 0' }}
|
||||||
|
icon={iconClass}
|
||||||
|
>
|
||||||
|
<div className="d-flex justify-content-between">
|
||||||
|
<strong className="lead">{intl.formatMessage(
|
||||||
|
isQuestion
|
||||||
|
? messages.answer
|
||||||
|
: messages.endorsed,
|
||||||
|
)}
|
||||||
|
</strong>
|
||||||
|
<span className="d-flex align-items-center mr-1">
|
||||||
|
<span className="mr-2">
|
||||||
|
{intl.formatMessage(
|
||||||
|
isQuestion
|
||||||
|
? messages.answeredLabel
|
||||||
|
: messages.endorsedLabel,
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<AuthorLabel author={content.endorsedBy} authorLabel={content.endorsedByLabel} />
|
||||||
|
{timeago.format(content.endorsedAt, intl.locale)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{content.abuseFlagged && (
|
||||||
|
<Alert icon={Error} variant="danger" className="p-3 m-0 shadow-none mb-1 flex-fill">
|
||||||
{intl.formatMessage(messages.abuseFlaggedMessage)}
|
{intl.formatMessage(messages.abuseFlaggedMessage)}
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
{reasonCodesEnabled && canSeeLastEditOrClosedAlert && (
|
{reasonCodesEnabled && userIsPrivileged && content.lastEdit?.reason && (
|
||||||
<>
|
<Alert variant="info" className="p-3 m-0 shadow-none mb-1 bg-light-200">
|
||||||
{content.lastEdit?.reason && (
|
<div className="d-flex align-items-center">
|
||||||
<Alert variant="info" className="px-3 shadow-none mb-2 py-10px bg-light-200">
|
{intl.formatMessage(messages.editedBy)}
|
||||||
<div className="d-flex align-items-center flex-wrap">
|
<span className="ml-1 mr-3">
|
||||||
{intl.formatMessage(messages.editedBy)}
|
<AuthorLabel author={content.lastEdit.editorUsername} linkToProfile />
|
||||||
<span className="ml-1 mr-3">
|
</span>
|
||||||
<AuthorLabel author={content.lastEdit.editorUsername} linkToProfile />
|
{intl.formatMessage(messages.reason)}: {content.lastEdit.reason}
|
||||||
</span>
|
</div>
|
||||||
{intl.formatMessage(messages.reason)}: {content.lastEdit.reason}
|
</Alert>
|
||||||
</div>
|
)}
|
||||||
</Alert>
|
{reasonCodesEnabled && content.closed && (
|
||||||
)}
|
<Alert variant="info" className="p-3 m-0 shadow-none mb-1 bg-light-200">
|
||||||
{content.closed && (
|
<div className="d-flex align-items-center">
|
||||||
<Alert variant="info" className="px-3 shadow-none mb-2 py-10px bg-light-200">
|
{intl.formatMessage(messages.closedBy)}
|
||||||
<div className="d-flex align-items-center flex-wrap">
|
<span className="ml-1 ">
|
||||||
{intl.formatMessage(messages.closedBy)}
|
<AuthorLabel author={content.closedBy} linkToProfile />
|
||||||
<span className="ml-1 ">
|
</span>
|
||||||
<AuthorLabel author={content.closedBy} linkToProfile />
|
<span className="mx-1" />
|
||||||
</span>
|
{intl.formatMessage(messages.reason)}: {content.closeReason}
|
||||||
<span className="mx-1" />
|
</div>
|
||||||
{content.closeReason && (`${intl.formatMessage(messages.reason)}: ${content.closeReason}`)}
|
</Alert>
|
||||||
</div>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -66,6 +90,11 @@ function AlertBanner({
|
|||||||
AlertBanner.propTypes = {
|
AlertBanner.propTypes = {
|
||||||
intl: intlShape.isRequired,
|
intl: intlShape.isRequired,
|
||||||
content: PropTypes.oneOfType([commentShape.isRequired, postShape.isRequired]).isRequired,
|
content: PropTypes.oneOfType([commentShape.isRequired, postShape.isRequired]).isRequired,
|
||||||
|
postType: PropTypes.string,
|
||||||
|
};
|
||||||
|
|
||||||
|
AlertBanner.defaultProps = {
|
||||||
|
postType: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(AlertBanner);
|
export default injectIntl(AlertBanner);
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { ThreadType } from '../../data/constants';
|
|||||||
import { initializeStore } from '../../store';
|
import { initializeStore } from '../../store';
|
||||||
import messages from '../comments/messages';
|
import messages from '../comments/messages';
|
||||||
import AlertBanner from './AlertBanner';
|
import AlertBanner from './AlertBanner';
|
||||||
import { DiscussionContext } from './context';
|
|
||||||
|
|
||||||
import '../comments/data/__factories__';
|
import '../comments/data/__factories__';
|
||||||
import '../posts/data/__factories__';
|
import '../posts/data/__factories__';
|
||||||
@@ -23,17 +22,15 @@ function buildTestContent(type, buildParams) {
|
|||||||
|
|
||||||
function renderComponent(
|
function renderComponent(
|
||||||
content,
|
content,
|
||||||
|
postType,
|
||||||
) {
|
) {
|
||||||
render(
|
render(
|
||||||
<IntlProvider locale="en">
|
<IntlProvider locale="en">
|
||||||
<AppProvider store={store}>
|
<AppProvider store={store}>
|
||||||
<DiscussionContext.Provider
|
<AlertBanner
|
||||||
value={{ courseId: 'course-v1:edX+TestX+Test_Course' }}
|
content={content}
|
||||||
>
|
postType={postType}
|
||||||
<AlertBanner
|
/>
|
||||||
content={content}
|
|
||||||
/>
|
|
||||||
</DiscussionContext.Provider>
|
|
||||||
</AppProvider>
|
</AppProvider>
|
||||||
</IntlProvider>,
|
</IntlProvider>,
|
||||||
);
|
);
|
||||||
@@ -47,6 +44,27 @@ describe.each([
|
|||||||
props: { abuseFlagged: true },
|
props: { abuseFlagged: true },
|
||||||
expectText: [messages.abuseFlaggedMessage.defaultMessage],
|
expectText: [messages.abuseFlaggedMessage.defaultMessage],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Staff endorsed comment in a question thread',
|
||||||
|
type: 'comment',
|
||||||
|
postType: ThreadType.QUESTION,
|
||||||
|
props: { endorsed: true, endorsedBy: 'test-user', endorsedByLabel: 'Staff' },
|
||||||
|
expectText: [messages.answer.defaultMessage, messages.answeredLabel.defaultMessage, 'test-user', 'Staff'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'TA endorsed comment in a question thread',
|
||||||
|
type: 'comment',
|
||||||
|
postType: ThreadType.QUESTION,
|
||||||
|
props: { endorsed: true, endorsedBy: 'test-user', endorsedByLabel: 'Community TA' },
|
||||||
|
expectText: [messages.answer.defaultMessage, messages.answeredLabel.defaultMessage, 'test-user', 'TA'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'endorsed comment in a discussion thread',
|
||||||
|
type: 'comment',
|
||||||
|
postType: ThreadType.DISCUSSION,
|
||||||
|
props: { endorsed: true, endorsedBy: 'test-user' },
|
||||||
|
expectText: [messages.endorsed.defaultMessage, messages.endorsedLabel.defaultMessage, 'test-user'],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'flagged thread',
|
label: 'flagged thread',
|
||||||
type: 'thread',
|
type: 'thread',
|
||||||
@@ -58,7 +76,7 @@ describe.each([
|
|||||||
label: 'edited content',
|
label: 'edited content',
|
||||||
type: 'thread',
|
type: 'thread',
|
||||||
postType: null,
|
postType: null,
|
||||||
props: { closed: false, last_edit: { reason: 'test-reason', editorUsername: 'editor-user' } },
|
props: { last_edit: { reason: 'test-reason', editorUsername: 'editor-user' } },
|
||||||
expectText: [messages.editedBy.defaultMessage, messages.reason.defaultMessage, 'editor-user', 'test-reason'],
|
expectText: [messages.editedBy.defaultMessage, messages.reason.defaultMessage, 'editor-user', 'test-reason'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -69,7 +87,7 @@ describe.each([
|
|||||||
expectText: [messages.closedBy.defaultMessage, 'closing-user', 'test-close-reason'],
|
expectText: [messages.closedBy.defaultMessage, 'closing-user', 'test-close-reason'],
|
||||||
},
|
},
|
||||||
])('AlertBanner', ({
|
])('AlertBanner', ({
|
||||||
label, type, props, expectText,
|
label, type, postType, props, expectText,
|
||||||
}) => {
|
}) => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
initializeMockApp({
|
initializeMockApp({
|
||||||
@@ -82,12 +100,12 @@ describe.each([
|
|||||||
});
|
});
|
||||||
store = initializeStore({
|
store = initializeStore({
|
||||||
config: {
|
config: {
|
||||||
hasModerationPrivileges: true,
|
userIsPrivileged: true,
|
||||||
reasonCodesEnabled: true,
|
reasonCodesEnabled: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const content = buildTestContent(type, props);
|
const content = buildTestContent(type, props);
|
||||||
renderComponent(content);
|
renderComponent(content, postType);
|
||||||
});
|
});
|
||||||
|
|
||||||
it(`should show correct banner for a ${label}`, async () => {
|
it(`should show correct banner for a ${label}`, async () => {
|
||||||
|
|||||||
@@ -1,18 +1,13 @@
|
|||||||
import React, { useContext } from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
|
||||||
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||||
import { Icon } from '@edx/paragon';
|
import { Icon } from '@edx/paragon';
|
||||||
import { Institution, School } from '@edx/paragon/icons';
|
import { Institution, School } from '@edx/paragon/icons';
|
||||||
|
|
||||||
import { Routes } from '../../data/constants';
|
|
||||||
import { useShowLearnersTab } from '../data/hooks';
|
|
||||||
import messages from '../messages';
|
import messages from '../messages';
|
||||||
import { discussionsPath } from '../utils';
|
|
||||||
import { DiscussionContext } from './context';
|
|
||||||
|
|
||||||
function AuthorLabel({
|
function AuthorLabel({
|
||||||
intl,
|
intl,
|
||||||
@@ -21,11 +16,8 @@ function AuthorLabel({
|
|||||||
linkToProfile,
|
linkToProfile,
|
||||||
labelColor,
|
labelColor,
|
||||||
}) {
|
}) {
|
||||||
const location = useLocation();
|
|
||||||
const { courseId } = useContext(DiscussionContext);
|
|
||||||
let icon = null;
|
let icon = null;
|
||||||
let authorLabelMessage = null;
|
let authorLabelMessage = null;
|
||||||
|
|
||||||
if (authorLabel === 'Staff') {
|
if (authorLabel === 'Staff') {
|
||||||
icon = Institution;
|
icon = Institution;
|
||||||
authorLabelMessage = intl.formatMessage(messages.authorLabelStaff);
|
authorLabelMessage = intl.formatMessage(messages.authorLabelStaff);
|
||||||
@@ -34,26 +26,9 @@ function AuthorLabel({
|
|||||||
icon = School;
|
icon = School;
|
||||||
authorLabelMessage = intl.formatMessage(messages.authorLabelTA);
|
authorLabelMessage = intl.formatMessage(messages.authorLabelTA);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isRetiredUser = author ? author.startsWith('retired__user') : false;
|
|
||||||
|
|
||||||
const className = classNames('d-flex align-items-center', labelColor);
|
|
||||||
|
|
||||||
const showUserNameAsLink = useShowLearnersTab()
|
|
||||||
&& linkToProfile && author && author !== intl.formatMessage(messages.anonymous);
|
|
||||||
|
|
||||||
const labelContents = (
|
const labelContents = (
|
||||||
<div className={className}>
|
<>
|
||||||
<span
|
<span className="mr-1">{author}</span>
|
||||||
className={classNames('mr-1 font-size-14 font-style-normal font-family-inter font-weight-500', {
|
|
||||||
'text-primary-500': !authorLabelMessage && !isRetiredUser,
|
|
||||||
'text-gray-700': isRetiredUser,
|
|
||||||
})}
|
|
||||||
role="heading"
|
|
||||||
aria-level="2"
|
|
||||||
>
|
|
||||||
{isRetiredUser ? '[Deactivated]' : author }
|
|
||||||
</span>
|
|
||||||
{icon && (
|
{icon && (
|
||||||
<Icon
|
<Icon
|
||||||
style={{
|
style={{
|
||||||
@@ -64,35 +39,20 @@ function AuthorLabel({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{authorLabelMessage && (
|
{authorLabelMessage && (
|
||||||
<span
|
<span className="mr-3 ml-1">
|
||||||
className={classNames('mr-3 font-size-14 font-style-normal font-family-inter font-weight-500', {
|
|
||||||
'text-primary-500': !authorLabelMessage,
|
|
||||||
})}
|
|
||||||
style={{ marginLeft: '2px' }}
|
|
||||||
>
|
|
||||||
{authorLabelMessage}
|
{authorLabelMessage}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
|
const className = classNames('d-flex align-items-center', labelColor);
|
||||||
return showUserNameAsLink
|
return linkToProfile
|
||||||
? (
|
? React.createElement('a', { href: '#nowhere', className }, labelContents)
|
||||||
<Link
|
: React.createElement('div', { className }, labelContents);
|
||||||
data-testid="learner-posts-link"
|
|
||||||
id="learner-posts-link"
|
|
||||||
to={discussionsPath(Routes.LEARNERS.POSTS, { learnerUsername: author, courseId })(location)}
|
|
||||||
className="text-decoration-none"
|
|
||||||
style={{ width: 'fit-content' }}
|
|
||||||
>
|
|
||||||
{labelContents}
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
: <>{labelContents}</>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
AuthorLabel.propTypes = {
|
AuthorLabel.propTypes = {
|
||||||
intl: intlShape.isRequired,
|
intl: intlShape,
|
||||||
author: PropTypes.string.isRequired,
|
author: PropTypes.string.isRequired,
|
||||||
authorLabel: PropTypes.string,
|
authorLabel: PropTypes.string,
|
||||||
linkToProfile: PropTypes.bool,
|
linkToProfile: PropTypes.bool,
|
||||||
|
|||||||
@@ -1,107 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
import { render, screen } from '@testing-library/react';
|
|
||||||
import MockAdapter from 'axios-mock-adapter';
|
|
||||||
import { IntlProvider } from 'react-intl';
|
|
||||||
|
|
||||||
import { initializeMockApp } from '@edx/frontend-platform';
|
|
||||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
|
||||||
import { AppProvider } from '@edx/frontend-platform/react';
|
|
||||||
|
|
||||||
import { initializeStore } from '../../store';
|
|
||||||
import { executeThunk } from '../../test-utils';
|
|
||||||
import { courseConfigApiUrl } from '../data/api';
|
|
||||||
import { fetchCourseConfig } from '../data/thunks';
|
|
||||||
import AuthorLabel from './AuthorLabel';
|
|
||||||
import { DiscussionContext } from './context';
|
|
||||||
|
|
||||||
const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
|
||||||
let store;
|
|
||||||
let axiosMock;
|
|
||||||
let container;
|
|
||||||
|
|
||||||
function renderComponent(author, authorLabel, linkToProfile, labelColor) {
|
|
||||||
const wrapper = render(
|
|
||||||
<IntlProvider locale="en">
|
|
||||||
<AppProvider store={store}>
|
|
||||||
<DiscussionContext.Provider value={{ courseId }}>
|
|
||||||
<AuthorLabel
|
|
||||||
author={author}
|
|
||||||
authorLabel={authorLabel}
|
|
||||||
linkToProfile={linkToProfile}
|
|
||||||
labelColor={labelColor}
|
|
||||||
/>
|
|
||||||
</DiscussionContext.Provider>
|
|
||||||
</AppProvider>
|
|
||||||
</IntlProvider>,
|
|
||||||
);
|
|
||||||
container = wrapper.container;
|
|
||||||
return container;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('Author label', () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
initializeMockApp({
|
|
||||||
authenticatedUser: {
|
|
||||||
userId: 3,
|
|
||||||
username: 'abc123',
|
|
||||||
administrator: true,
|
|
||||||
roles: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
store = initializeStore();
|
|
||||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
|
||||||
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`).reply(200, {
|
|
||||||
learners_tab_enabled: true,
|
|
||||||
has_moderation_privileges: true,
|
|
||||||
});
|
|
||||||
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/settings`).reply(200, {});
|
|
||||||
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe.each([
|
|
||||||
['anonymous', null, false, ''],
|
|
||||||
['ta_user', 'Community TA', true, 'text-TA-color'],
|
|
||||||
['retired__user', null, false, ''],
|
|
||||||
['staff_user', 'Staff', true, 'text-staff-color'],
|
|
||||||
['learner_user', null, false, ''],
|
|
||||||
])('for %s', (
|
|
||||||
author, authorLabel, linkToProfile, labelColor,
|
|
||||||
) => {
|
|
||||||
it('it has author name text',
|
|
||||||
async () => {
|
|
||||||
renderComponent(author, authorLabel, linkToProfile, labelColor);
|
|
||||||
const authorElement = container.querySelector('[role=heading]');
|
|
||||||
const authorName = author.startsWith('retired__user') ? '[Deactivated]' : author;
|
|
||||||
|
|
||||||
expect(authorElement).toHaveTextContent(authorName);
|
|
||||||
});
|
|
||||||
|
|
||||||
it(`it is "${!linkToProfile && 'not'}" clickable when linkToProfile is ${!!linkToProfile}`,
|
|
||||||
async () => {
|
|
||||||
renderComponent(author, authorLabel, linkToProfile, labelColor);
|
|
||||||
|
|
||||||
if (linkToProfile) {
|
|
||||||
expect(screen.queryByTestId('learner-posts-link')).toBeInTheDocument();
|
|
||||||
} else {
|
|
||||||
expect(screen.queryByTestId('learner-posts-link')).not.toBeInTheDocument();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it(`it has "${!linkToProfile && 'not'}" label text and label color when linkToProfile is ${!!linkToProfile}`,
|
|
||||||
async () => {
|
|
||||||
renderComponent(author, authorLabel, linkToProfile, labelColor);
|
|
||||||
const authorElement = container.querySelector('[role=heading]');
|
|
||||||
const labelElement = authorElement.parentNode.lastChild;
|
|
||||||
const label = ['TA', 'Staff'].includes(labelElement.textContent) && labelElement.textContent;
|
|
||||||
|
|
||||||
if (linkToProfile) {
|
|
||||||
expect(authorElement.parentNode).toHaveClass(labelColor);
|
|
||||||
expect(authorElement.parentNode.lastChild).toHaveTextContent(label);
|
|
||||||
} else {
|
|
||||||
expect(authorElement.parentNode.lastChild).not.toHaveTextContent(label, { exact: true });
|
|
||||||
expect(authorElement.parentNode).not.toHaveClass(labelColor, { exact: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -15,7 +15,7 @@ function DeleteConfirmation({
|
|||||||
onDelete,
|
onDelete,
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<ModalDialog title={title} isOpen={isOpen} hasCloseButton={false} onClose={onClose} zIndex={5000}>
|
<ModalDialog title={title} isOpen={isOpen} hasCloseButton={false} onClose={onClose}>
|
||||||
<ModalDialog.Header>
|
<ModalDialog.Header>
|
||||||
<ModalDialog.Title>
|
<ModalDialog.Title>
|
||||||
{title}
|
{title}
|
||||||
|
|||||||
@@ -1,68 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
|
|
||||||
import * as timeago from 'timeago.js';
|
|
||||||
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
|
||||||
import { Alert } from '@edx/paragon';
|
|
||||||
import { CheckCircle, Verified } from '@edx/paragon/icons';
|
|
||||||
|
|
||||||
import { ThreadType } from '../../data/constants';
|
|
||||||
import { commentShape } from '../comments/comment/proptypes';
|
|
||||||
import messages from '../comments/messages';
|
|
||||||
import AuthorLabel from './AuthorLabel';
|
|
||||||
import timeLocale from './time-locale';
|
|
||||||
|
|
||||||
function EndorsedAlertBanner({
|
|
||||||
intl,
|
|
||||||
content,
|
|
||||||
postType,
|
|
||||||
}) {
|
|
||||||
timeago.register('time-locale', timeLocale);
|
|
||||||
const isQuestion = postType === ThreadType.QUESTION;
|
|
||||||
const classes = isQuestion ? 'bg-success-500 text-white' : 'bg-dark-500 text-white';
|
|
||||||
const iconClass = isQuestion ? CheckCircle : Verified;
|
|
||||||
|
|
||||||
return (
|
|
||||||
content.endorsed && (
|
|
||||||
<Alert
|
|
||||||
variant="plain"
|
|
||||||
className={`px-3 mb-0 py-10px align-items-center shadow-none ${classes}`}
|
|
||||||
style={{ borderRadius: '0.375rem 0.375rem 0 0' }}
|
|
||||||
icon={iconClass}
|
|
||||||
>
|
|
||||||
<div className="d-flex justify-content-between flex-wrap">
|
|
||||||
<strong className="lead">{intl.formatMessage(
|
|
||||||
isQuestion
|
|
||||||
? messages.answer
|
|
||||||
: messages.endorsed,
|
|
||||||
)}
|
|
||||||
</strong>
|
|
||||||
<span className="d-flex align-items-center mr-1 flex-wrap">
|
|
||||||
<span className="mr-2">
|
|
||||||
{intl.formatMessage(
|
|
||||||
isQuestion
|
|
||||||
? messages.answeredLabel
|
|
||||||
: messages.endorsedLabel,
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<AuthorLabel author={content.endorsedBy} authorLabel={content.endorsedByLabel} linkToProfile />
|
|
||||||
{intl.formatMessage(messages.time, { time: timeago.format(content.endorsedAt, 'time-locale') })}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</Alert>
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
EndorsedAlertBanner.propTypes = {
|
|
||||||
intl: intlShape.isRequired,
|
|
||||||
content: PropTypes.oneOfType([commentShape.isRequired]).isRequired,
|
|
||||||
postType: PropTypes.string,
|
|
||||||
};
|
|
||||||
|
|
||||||
EndorsedAlertBanner.defaultProps = {
|
|
||||||
postType: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default injectIntl(EndorsedAlertBanner);
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
import { render, screen } from '@testing-library/react';
|
|
||||||
import { IntlProvider } from 'react-intl';
|
|
||||||
import { Factory } from 'rosie';
|
|
||||||
|
|
||||||
import { camelCaseObject, initializeMockApp, snakeCaseObject } from '@edx/frontend-platform';
|
|
||||||
import { AppProvider } from '@edx/frontend-platform/react';
|
|
||||||
|
|
||||||
import { ThreadType } from '../../data/constants';
|
|
||||||
import { initializeStore } from '../../store';
|
|
||||||
import messages from '../comments/messages';
|
|
||||||
import { DiscussionContext } from './context';
|
|
||||||
import EndorsedAlertBanner from './EndorsedAlertBanner';
|
|
||||||
|
|
||||||
import '../comments/data/__factories__';
|
|
||||||
import '../posts/data/__factories__';
|
|
||||||
|
|
||||||
let store;
|
|
||||||
|
|
||||||
function buildTestContent(type, buildParams) {
|
|
||||||
const buildParamsSnakeCase = snakeCaseObject(buildParams);
|
|
||||||
return camelCaseObject(Factory.build(type, { ...buildParamsSnakeCase }, null));
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderComponent(
|
|
||||||
content, postType,
|
|
||||||
) {
|
|
||||||
render(
|
|
||||||
<IntlProvider locale="en">
|
|
||||||
<AppProvider store={store}>
|
|
||||||
<DiscussionContext.Provider
|
|
||||||
value={{ courseId: 'course-v1:edX+DemoX+Demo_Course' }}
|
|
||||||
>
|
|
||||||
<EndorsedAlertBanner
|
|
||||||
content={content}
|
|
||||||
postType={postType}
|
|
||||||
/>
|
|
||||||
</DiscussionContext.Provider>
|
|
||||||
</AppProvider>
|
|
||||||
</IntlProvider>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe.each([
|
|
||||||
{
|
|
||||||
label: 'Staff endorsed comment in a question thread',
|
|
||||||
type: 'comment',
|
|
||||||
postType: ThreadType.QUESTION,
|
|
||||||
props: { endorsed: true, endorsedBy: 'test-user', endorsedByLabel: 'Staff' },
|
|
||||||
expectText: [messages.answer.defaultMessage, messages.answeredLabel.defaultMessage, 'test-user', 'Staff'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'TA endorsed comment in a question thread',
|
|
||||||
type: 'comment',
|
|
||||||
postType: ThreadType.QUESTION,
|
|
||||||
props: { endorsed: true, endorsedBy: 'test-user', endorsedByLabel: 'Community TA' },
|
|
||||||
expectText: [messages.answer.defaultMessage, messages.answeredLabel.defaultMessage, 'test-user', 'TA'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'endorsed comment in a discussion thread',
|
|
||||||
type: 'comment',
|
|
||||||
postType: ThreadType.DISCUSSION,
|
|
||||||
props: { endorsed: true, endorsedBy: 'test-user' },
|
|
||||||
expectText: [messages.endorsed.defaultMessage, messages.endorsedLabel.defaultMessage, 'test-user'],
|
|
||||||
},
|
|
||||||
])('EndorsedAlertBanner', ({
|
|
||||||
label, type, postType, props, expectText,
|
|
||||||
}) => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
initializeMockApp({
|
|
||||||
authenticatedUser: {
|
|
||||||
userId: 3,
|
|
||||||
username: 'abc123',
|
|
||||||
administrator: false,
|
|
||||||
roles: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
store = initializeStore({
|
|
||||||
config: {
|
|
||||||
hasModerationPrivileges: true,
|
|
||||||
reasonCodesEnabled: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const content = buildTestContent(type, props);
|
|
||||||
renderComponent(content, postType);
|
|
||||||
});
|
|
||||||
|
|
||||||
it(`should show correct banner for a ${label}`, async () => {
|
|
||||||
expectText.forEach(message => {
|
|
||||||
expect(screen.queryAllByText(message, { exact: false }).length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -2,4 +2,3 @@ export { default as ActionsDropdown } from './ActionsDropdown';
|
|||||||
export { default as AlertBanner } from './AlertBanner';
|
export { default as AlertBanner } from './AlertBanner';
|
||||||
export { default as AuthorLabel } from './AuthorLabel';
|
export { default as AuthorLabel } from './AuthorLabel';
|
||||||
export { default as DeleteConfirmation } from './DeleteConfirmation';
|
export { default as DeleteConfirmation } from './DeleteConfirmation';
|
||||||
export { default as EndorsedAlertBanner } from './EndorsedAlertBanner';
|
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
export default function timeLocale(number, index, totalSec) {
|
|
||||||
return [
|
|
||||||
['just now', 'right now'],
|
|
||||||
['%ss', 'in %s seconds'],
|
|
||||||
['1m', 'in 1 minute'],
|
|
||||||
['%sm', 'in %s minutes'],
|
|
||||||
['1h', 'in 1 hour'],
|
|
||||||
['%sh', 'in %s hours'],
|
|
||||||
['1d', 'in 1 day'],
|
|
||||||
['%sd', 'in %s days'],
|
|
||||||
['1w', 'in 1 week'],
|
|
||||||
['%sw', 'in %s weeks'],
|
|
||||||
['4w', 'in 1 month'],
|
|
||||||
[`${number * 4}w`, 'in %s months'],
|
|
||||||
['1y', 'in 1 year'],
|
|
||||||
['%sy', 'in %s years'],
|
|
||||||
][index];
|
|
||||||
}
|
|
||||||
@@ -4,6 +4,6 @@ Factory.define('config')
|
|||||||
.attrs({
|
.attrs({
|
||||||
allow_anonymous: false,
|
allow_anonymous: false,
|
||||||
allow_anonymous_to_peers: false,
|
allow_anonymous_to_peers: false,
|
||||||
has_moderation_privileges: false,
|
user_is_privileged: false,
|
||||||
})
|
})
|
||||||
.attr('user_roles', ['has_moderation_privileges'], (hasModerationPrivileges) => (hasModerationPrivileges ? ['Student', 'Moderator'] : ['Student']));
|
.attr('user_roles', ['user_is_privileged'], (userIsPrivileged) => (userIsPrivileged ? ['Student', 'Moderator'] : ['Student']));
|
||||||
|
|||||||
@@ -1,30 +1,19 @@
|
|||||||
/* eslint-disable import/prefer-default-export */
|
/* eslint-disable import/prefer-default-export */
|
||||||
import {
|
import { useContext, useEffect, useRef } from 'react';
|
||||||
useContext, useEffect, useRef, useState,
|
|
||||||
} from 'react';
|
|
||||||
|
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
import { useHistory, useLocation, useRouteMatch } from 'react-router';
|
import { useHistory, useLocation, useRouteMatch } from 'react-router';
|
||||||
|
|
||||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
|
||||||
import { AppContext } from '@edx/frontend-platform/react';
|
import { AppContext } from '@edx/frontend-platform/react';
|
||||||
import { breakpoints, useWindowSize } from '@edx/paragon';
|
import { breakpoints, useWindowSize } from '@edx/paragon';
|
||||||
|
|
||||||
import { Routes } from '../../data/constants';
|
import { Routes } from '../../data/constants';
|
||||||
import { selectTopicsUnderCategory } from '../../data/selectors';
|
|
||||||
import { fetchCourseBlocks } from '../../data/thunks';
|
import { fetchCourseBlocks } from '../../data/thunks';
|
||||||
import { DiscussionContext } from '../common/context';
|
|
||||||
import { clearRedirect } from '../posts/data';
|
import { clearRedirect } from '../posts/data';
|
||||||
import { selectTopics } from '../topics/data/selectors';
|
import { selectTopics } from '../topics/data/selectors';
|
||||||
import { fetchCourseTopics } from '../topics/data/thunks';
|
import { fetchCourseTopics } from '../topics/data/thunks';
|
||||||
import { discussionsPath } from '../utils';
|
import { discussionsPath, postMessageToParent } from '../utils';
|
||||||
import {
|
import { selectAreThreadsFiltered, selectPostThreadCount } from './selectors';
|
||||||
selectAreThreadsFiltered, selectLearnersTabEnabled,
|
|
||||||
selectModerationSettings,
|
|
||||||
selectPostThreadCount,
|
|
||||||
selectUserHasModerationPrivileges,
|
|
||||||
selectUserIsGroupTa,
|
|
||||||
} from './selectors';
|
|
||||||
import { fetchCourseConfig } from './thunks';
|
import { fetchCourseConfig } from './thunks';
|
||||||
|
|
||||||
export function useTotalTopicThreadCount() {
|
export function useTotalTopicThreadCount() {
|
||||||
@@ -95,80 +84,60 @@ export function useRedirectToThread(courseId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useIsOnDesktop() {
|
export function useIsOnDesktop() {
|
||||||
return window.outerWidth >= breakpoints.large.minWidth;
|
const windowSize = useWindowSize();
|
||||||
|
return windowSize.width >= breakpoints.large.minWidth;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useIsOnXLDesktop() {
|
/**
|
||||||
const windowSize = useWindowSize();
|
* Given an element this attempts to get the height of the entire UI.
|
||||||
return windowSize.width >= breakpoints.extraLarge.minWidth;
|
*
|
||||||
|
* @param element
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
function getOuterHeight(element) {
|
||||||
|
// This is the height of the entire document body.
|
||||||
|
const bodyHeight = document.body.offsetHeight;
|
||||||
|
// This is the height of the container that will scroll.
|
||||||
|
const elementContainerHeight = element.parentNode.clientHeight;
|
||||||
|
// The difference between the body height and the container height is the size of the header footer etc.
|
||||||
|
// Add to that the element's own height and we get the size the UI should be to fit everything.
|
||||||
|
return bodyHeight - elementContainerHeight + element.scrollHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This hook posts a resize message to the parent window if running in an iframe
|
* This hook posts a resize message to the parent window if running in an iframe
|
||||||
* @param refContainer reference to the component whose size is to be measured
|
* @param refContainer reference to the component whose size is to be measured
|
||||||
*/
|
*/
|
||||||
export function useContainerSize(refContainer) {
|
export function useContainerSizeForParent(refContainer) {
|
||||||
|
function postResizeMessage(height) {
|
||||||
|
postMessageToParent('plugin.resize', { height });
|
||||||
|
}
|
||||||
|
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [height, setHeight] = useState();
|
const enabled = window.parent !== window;
|
||||||
|
|
||||||
const resizeObserver = useRef(new ResizeObserver(() => {
|
const resizeObserver = useRef(new ResizeObserver(() => {
|
||||||
/* istanbul ignore if: ResizeObserver isn't available in the testing env */
|
/* istanbul ignore if: ResizeObserver isn't available in the testing env */
|
||||||
if (refContainer?.current) {
|
if (refContainer.current) {
|
||||||
setHeight(refContainer?.current?.clientHeight);
|
postResizeMessage(getOuterHeight(refContainer.current));
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const container = refContainer?.current;
|
const container = refContainer.current;
|
||||||
const observer = resizeObserver?.current;
|
const observer = resizeObserver.current;
|
||||||
if (container && observer) {
|
if (container && observer && enabled) {
|
||||||
observer.observe(container);
|
observer.observe(container);
|
||||||
setHeight(container.clientHeight);
|
postResizeMessage(getOuterHeight(container));
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (container && observer) {
|
if (container && observer && enabled) {
|
||||||
observer.unobserve(container);
|
observer.unobserve(container);
|
||||||
|
// Send a message to reset the size so that navigating to another
|
||||||
|
// page doesn't cause the size to be retained
|
||||||
|
postResizeMessage(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [refContainer, resizeObserver, location]);
|
}, [refContainer, resizeObserver, location]);
|
||||||
|
|
||||||
return height;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAlertBannerVisible = (content) => {
|
|
||||||
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
|
|
||||||
const userIsGroupTa = useSelector(selectUserIsGroupTa);
|
|
||||||
const { reasonCodesEnabled } = useSelector(selectModerationSettings);
|
|
||||||
const userIsContentAuthor = getAuthenticatedUser().username === content.author;
|
|
||||||
const canSeeLastEditOrClosedAlert = (userHasModerationPrivileges || userIsContentAuthor || userIsGroupTa);
|
|
||||||
const canSeeReportedBanner = content.abuseFlagged;
|
|
||||||
|
|
||||||
return (
|
|
||||||
(reasonCodesEnabled && canSeeLastEditOrClosedAlert && (content.lastEdit?.reason || content.closed))
|
|
||||||
|| (content.abuseFlagged && canSeeReportedBanner)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useShowLearnersTab = () => useSelector(selectLearnersTabEnabled);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* React hook that gets the current topic ID from the current topic or category.
|
|
||||||
* The topicId in the DiscussionContext only return the direct topicId from the URL.
|
|
||||||
* If the URL has the current block ID it cannot get the topicID from that. This hook
|
|
||||||
* gets the topic ID from the URL if available, or from the current category otherwise.
|
|
||||||
* It only returns an ID if a single ID is available, if navigating a subsection it
|
|
||||||
* returns null.
|
|
||||||
* @returns {null|string} A topic ID if a single one available in the current context.
|
|
||||||
*/
|
|
||||||
export const useCurrentDiscussionTopic = () => {
|
|
||||||
const { topicId, category } = useContext(DiscussionContext);
|
|
||||||
const topics = useSelector(selectTopicsUnderCategory)(category);
|
|
||||||
if (topicId) {
|
|
||||||
return topicId;
|
|
||||||
}
|
|
||||||
if (topics?.length === 1) {
|
|
||||||
return topics[0];
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,85 +1,68 @@
|
|||||||
import { render } from '@testing-library/react';
|
import { useRef } from 'react';
|
||||||
import { IntlProvider } from 'react-intl';
|
|
||||||
|
|
||||||
import { initializeMockApp } from '@edx/frontend-platform';
|
import { render, waitFor } from '@testing-library/react';
|
||||||
|
import { IntlProvider } from 'react-intl';
|
||||||
|
import { Context as ResponsiveContext } from 'react-responsive';
|
||||||
|
import { MemoryRouter } from 'react-router';
|
||||||
|
|
||||||
|
import { getConfig, initializeMockApp } from '@edx/frontend-platform';
|
||||||
import { AppProvider } from '@edx/frontend-platform/react';
|
import { AppProvider } from '@edx/frontend-platform/react';
|
||||||
|
|
||||||
import { initializeStore } from '../../store';
|
import { initializeStore } from '../../store';
|
||||||
import { DiscussionContext } from '../common/context';
|
import { useContainerSizeForParent } from './hooks';
|
||||||
import { useCurrentDiscussionTopic } from './hooks';
|
|
||||||
|
|
||||||
let store;
|
let store;
|
||||||
initializeMockApp();
|
initializeMockApp();
|
||||||
describe('Hooks', () => {
|
describe('Hooks', () => {
|
||||||
describe('useCurrentDiscussionTopic', () => {
|
function ComponentWithHook() {
|
||||||
function ComponentWithHook() {
|
const refContainer = useRef(null);
|
||||||
const topic = useCurrentDiscussionTopic();
|
useContainerSizeForParent(refContainer);
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{String(topic)}
|
<div ref={refContainer} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderComponent({ topicId, category }) {
|
function renderComponent() {
|
||||||
return render(
|
return render(
|
||||||
<IntlProvider locale="en">
|
<IntlProvider locale="en">
|
||||||
|
<ResponsiveContext.Provider value={{ width: 1280 }}>
|
||||||
<AppProvider store={store}>
|
<AppProvider store={store}>
|
||||||
<DiscussionContext.Provider
|
<MemoryRouter initialEntries={['/']}>
|
||||||
value={{
|
|
||||||
topicId,
|
|
||||||
category,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ComponentWithHook />
|
<ComponentWithHook />
|
||||||
</DiscussionContext.Provider>
|
</MemoryRouter>
|
||||||
</AppProvider>
|
</AppProvider>
|
||||||
</IntlProvider>,
|
</ResponsiveContext.Provider>
|
||||||
);
|
</IntlProvider>,
|
||||||
}
|
);
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
let parent;
|
||||||
store = initializeStore({
|
beforeEach(() => {
|
||||||
blocks: {
|
store = initializeStore();
|
||||||
blocks: {
|
parent = window.parent;
|
||||||
'some-unit-key': { topics: ['some-topic-0'], parent: 'some-sequence-key' },
|
});
|
||||||
'some-sequence-key': { topics: ['some-topic-0'] },
|
afterEach(() => {
|
||||||
'another-sequence-key': { topics: ['some-topic-1', 'some-topic-2'] },
|
window.parent = parent;
|
||||||
'empty-key': { topics: [] },
|
});
|
||||||
},
|
test('useContainerSizeForParent enabled', async () => {
|
||||||
},
|
delete window.parent;
|
||||||
config: { provider: 'openedx' },
|
window.parent = { ...window, postMessage: jest.fn() };
|
||||||
});
|
const { unmount } = renderComponent();
|
||||||
});
|
// Once for LMS and one for learning MFE
|
||||||
|
await waitFor(() => expect(window.parent.postMessage).toHaveBeenCalledTimes(2));
|
||||||
test('when topicId is in context', () => {
|
// Test that size is reset on unmount
|
||||||
const { queryByText } = renderComponent({ topicId: 'some-topic' });
|
unmount();
|
||||||
expect(queryByText('some-topic')).toBeInTheDocument();
|
await waitFor(() => expect(window.parent.postMessage).toHaveBeenCalledTimes(4));
|
||||||
});
|
expect(window.parent.postMessage).toHaveBeenLastCalledWith(
|
||||||
|
{ type: 'plugin.resize', payload: { height: null } },
|
||||||
test('when the category is a unit', () => {
|
getConfig().LMS_BASE_URL,
|
||||||
const { queryByText } = renderComponent({ category: 'some-unit-key' });
|
);
|
||||||
expect(queryByText('some-topic-0')).toBeInTheDocument();
|
});
|
||||||
});
|
test('useContainerSizeForParent disabled', async () => {
|
||||||
|
window.parent.postMessage = jest.fn();
|
||||||
test('when the category is a sequence with one unit', () => {
|
renderComponent();
|
||||||
const { queryByText } = renderComponent({ category: 'some-sequence-key' });
|
await waitFor(() => expect(window.parent.postMessage).not.toHaveBeenCalled());
|
||||||
expect(queryByText('some-topic-0')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('when the category is a sequence with multiple units', () => {
|
|
||||||
const { queryByText } = renderComponent({ category: 'another-sequence-key' });
|
|
||||||
expect(queryByText('null')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('when the category is invalid', () => {
|
|
||||||
const { queryByText } = renderComponent({ category: 'invalid-key' });
|
|
||||||
expect(queryByText('null')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('when the category has no topics', () => {
|
|
||||||
const { queryByText } = renderComponent({ category: 'empty-key' });
|
|
||||||
expect(queryByText('null')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,24 +6,14 @@ export const selectAnonymousPostingConfig = state => ({
|
|||||||
allowAnonymousToPeers: state.config.allowAnonymousToPeers,
|
allowAnonymousToPeers: state.config.allowAnonymousToPeers,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const selectUserHasModerationPrivileges = state => state.config.hasModerationPrivileges;
|
export const selectUserIsPrivileged = state => state.config.userIsPrivileged;
|
||||||
|
|
||||||
export const selectUserIsStaff = state => state.config.isUserAdmin;
|
|
||||||
|
|
||||||
export const selectUserIsGroupTa = state => state.config.isGroupTa;
|
|
||||||
|
|
||||||
export const selectconfigLoadingStatus = state => state.config.status;
|
export const selectconfigLoadingStatus = state => state.config.status;
|
||||||
|
|
||||||
export const selectLearnersTabEnabled = state => state.config.learnersTabEnabled;
|
export const selectLearnersTabEnabled = state => state.config.learnersTabEnabled;
|
||||||
|
|
||||||
export const selectUserRoles = state => state.config.userRoles;
|
|
||||||
|
|
||||||
export const selectDivisionSettings = state => state.config.settings;
|
export const selectDivisionSettings = state => state.config.settings;
|
||||||
|
|
||||||
export const selectBlackoutDate = state => state.config.blackouts;
|
|
||||||
|
|
||||||
export const selectGroupAtSubsection = state => state.config.groupAtSubsection;
|
|
||||||
|
|
||||||
export const selectModerationSettings = state => ({
|
export const selectModerationSettings = state => ({
|
||||||
postCloseReasons: state.config.postCloseReasons,
|
postCloseReasons: state.config.postCloseReasons,
|
||||||
editReasons: state.config.editReasons,
|
editReasons: state.config.editReasons,
|
||||||
|
|||||||
@@ -11,10 +11,7 @@ const configSlice = createSlice({
|
|||||||
allowAnonymous: false,
|
allowAnonymous: false,
|
||||||
allowAnonymousToPeers: false,
|
allowAnonymousToPeers: false,
|
||||||
userRoles: [],
|
userRoles: [],
|
||||||
groupAtSubsection: false,
|
userIsPrivileged: false,
|
||||||
hasModerationPrivileges: false,
|
|
||||||
isGroupTa: false,
|
|
||||||
isUserAdmin: false,
|
|
||||||
learnersTabEnabled: false,
|
learnersTabEnabled: false,
|
||||||
settings: {
|
settings: {
|
||||||
divisionScheme: 'none',
|
divisionScheme: 'none',
|
||||||
|
|||||||
@@ -2,12 +2,6 @@
|
|||||||
import { camelCaseObject } from '@edx/frontend-platform';
|
import { camelCaseObject } from '@edx/frontend-platform';
|
||||||
import { logError } from '@edx/frontend-platform/logging';
|
import { logError } from '@edx/frontend-platform/logging';
|
||||||
|
|
||||||
import {
|
|
||||||
LearnersOrdering,
|
|
||||||
PostsStatusFilter,
|
|
||||||
} from '../../data/constants';
|
|
||||||
import { setSortedBy } from '../learners/data';
|
|
||||||
import { setStatusFilter } from '../posts/data';
|
|
||||||
import { getHttpErrorStatus } from '../utils';
|
import { getHttpErrorStatus } from '../utils';
|
||||||
import { getDiscussionsConfig, getDiscussionsSettings } from './api';
|
import { getDiscussionsConfig, getDiscussionsSettings } from './api';
|
||||||
import {
|
import {
|
||||||
@@ -22,23 +16,13 @@ import {
|
|||||||
export function fetchCourseConfig(courseId) {
|
export function fetchCourseConfig(courseId) {
|
||||||
return async (dispatch) => {
|
return async (dispatch) => {
|
||||||
try {
|
try {
|
||||||
let learnerSort = LearnersOrdering.BY_LAST_ACTIVITY;
|
|
||||||
const postsFilterStatus = PostsStatusFilter.ALL;
|
|
||||||
dispatch(fetchConfigRequest());
|
dispatch(fetchConfigRequest());
|
||||||
|
|
||||||
const config = await getDiscussionsConfig(courseId);
|
const config = await getDiscussionsConfig(courseId);
|
||||||
if (config.has_moderation_privileges) {
|
if (config.is_user_admin) {
|
||||||
const settings = await getDiscussionsSettings(courseId);
|
const settings = await getDiscussionsSettings(courseId);
|
||||||
Object.assign(config, { settings });
|
Object.assign(config, { settings });
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((config.has_moderation_privileges || config.is_group_ta)) {
|
|
||||||
learnerSort = LearnersOrdering.BY_FLAG;
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatch(fetchConfigSuccess(camelCaseObject(config)));
|
dispatch(fetchConfigSuccess(camelCaseObject(config)));
|
||||||
dispatch(setSortedBy(learnerSort));
|
|
||||||
dispatch(setStatusFilter(postsFilterStatus));
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (getHttpErrorStatus(error) === 403) {
|
if (getHttpErrorStatus(error) === 403) {
|
||||||
dispatch(fetchConfigDenied());
|
dispatch(fetchConfigDenied());
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
|
|
||||||
import { useSelector } from 'react-redux';
|
|
||||||
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
|
||||||
import { PageBanner } from '@edx/paragon';
|
|
||||||
|
|
||||||
import { selectBlackoutDate } from '../data/selectors';
|
|
||||||
import messages from '../messages';
|
|
||||||
import { inBlackoutDateRange } from '../utils';
|
|
||||||
|
|
||||||
function BlackoutInformationBanner({
|
|
||||||
intl,
|
|
||||||
}) {
|
|
||||||
const isDiscussionsBlackout = inBlackoutDateRange(useSelector(selectBlackoutDate));
|
|
||||||
const [showBanner, setShowBanner] = useState(true);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageBanner
|
|
||||||
variant="accentB"
|
|
||||||
show={isDiscussionsBlackout && showBanner}
|
|
||||||
dismissible
|
|
||||||
onDismiss={() => setShowBanner(false)}
|
|
||||||
>
|
|
||||||
<div className="font-weight-500">
|
|
||||||
{intl.formatMessage(messages.blackoutDiscussionInformation)}
|
|
||||||
</div>
|
|
||||||
</PageBanner>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
BlackoutInformationBanner.propTypes = {
|
|
||||||
intl: intlShape.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default injectIntl(BlackoutInformationBanner);
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
import { render, screen } from '@testing-library/react';
|
|
||||||
import { IntlProvider } from 'react-intl';
|
|
||||||
|
|
||||||
import { initializeMockApp } from '@edx/frontend-platform';
|
|
||||||
import { AppProvider } from '@edx/frontend-platform/react';
|
|
||||||
|
|
||||||
import { initializeStore } from '../../store';
|
|
||||||
import { DiscussionContext } from '../common/context';
|
|
||||||
import { fetchConfigSuccess } from '../data/slices';
|
|
||||||
import messages from '../messages';
|
|
||||||
import BlackoutInformationBanner from './BlackoutInformationBanner';
|
|
||||||
|
|
||||||
let store;
|
|
||||||
let container;
|
|
||||||
const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
|
||||||
let activeStartDate = new Date();
|
|
||||||
activeStartDate.setDate(activeStartDate.getDate() - 2);
|
|
||||||
let activeEndDate = new Date();
|
|
||||||
activeEndDate.setDate(activeEndDate.getDate() + 2);
|
|
||||||
activeStartDate = activeStartDate.toISOString();
|
|
||||||
activeEndDate = activeEndDate.toISOString();
|
|
||||||
|
|
||||||
const getConfigData = (blackouts = []) => ({
|
|
||||||
id: 'course-v1:edX+DemoX+Demo_Course',
|
|
||||||
userRoles: ['Admin', 'Student'],
|
|
||||||
hasModerationPrivileges: false,
|
|
||||||
isGroupTa: false,
|
|
||||||
isUserAdmin: false,
|
|
||||||
blackouts,
|
|
||||||
});
|
|
||||||
|
|
||||||
function renderComponent() {
|
|
||||||
const wrapper = render(
|
|
||||||
<IntlProvider locale="en">
|
|
||||||
<AppProvider store={store}>
|
|
||||||
<DiscussionContext.Provider value={{ courseId }}>
|
|
||||||
<BlackoutInformationBanner />
|
|
||||||
</DiscussionContext.Provider>
|
|
||||||
</AppProvider>
|
|
||||||
</IntlProvider>,
|
|
||||||
);
|
|
||||||
container = wrapper.container;
|
|
||||||
return container;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('Blackout Information Banner', () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
initializeMockApp({
|
|
||||||
authenticatedUser: {
|
|
||||||
userId: 3,
|
|
||||||
username: 'abc123',
|
|
||||||
administrator: false,
|
|
||||||
roles: ['Student'],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.each([
|
|
||||||
{ blackouts: [], visibility: false },
|
|
||||||
{ blackouts: ['2021-12-31T10:15', '2021-12-31T10:20'], visibility: false },
|
|
||||||
{ blackouts: [{ start: activeStartDate, end: activeEndDate }], visibility: true },
|
|
||||||
{ blackouts: [{ start: activeEndDate, end: activeEndDate }], visibility: false },
|
|
||||||
])('Test Blackout Banner is visible on app load if blackout date is active', async ({ blackouts, visibility }) => {
|
|
||||||
store = initializeStore();
|
|
||||||
await store.dispatch(fetchConfigSuccess(getConfigData(blackouts)));
|
|
||||||
renderComponent();
|
|
||||||
if (visibility) {
|
|
||||||
const element = await screen.findByRole('alert');
|
|
||||||
expect(element).toBeInTheDocument();
|
|
||||||
expect(element).toHaveTextContent(messages.blackoutDiscussionInformation.defaultMessage);
|
|
||||||
} else {
|
|
||||||
const element = await screen.queryByRole('alert');
|
|
||||||
expect(element).not.toBeInTheDocument();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,46 +1,26 @@
|
|||||||
import React, { useContext } from 'react';
|
import React, { useRef } from 'react';
|
||||||
|
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import { Route, Switch } from 'react-router';
|
import { Route, Switch } from 'react-router';
|
||||||
import { useHistory, useLocation } from 'react-router-dom';
|
|
||||||
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { Routes } from '../../data/constants';
|
||||||
import { Icon, IconButton } from '@edx/paragon';
|
|
||||||
import { ArrowBack } from '@edx/paragon/icons';
|
|
||||||
|
|
||||||
import { PostsPages, Routes } from '../../data/constants';
|
|
||||||
import { CommentsView } from '../comments';
|
import { CommentsView } from '../comments';
|
||||||
import { DiscussionContext } from '../common/context';
|
import { useContainerSizeForParent } from '../data/hooks';
|
||||||
import { useIsOnDesktop } from '../data/hooks';
|
import { LearnersContentView } from '../learners';
|
||||||
import messages from '../messages';
|
import LearnerPageHeader from '../learners/LearnerPageHeader';
|
||||||
import { PostEditor } from '../posts';
|
import { PostEditor } from '../posts';
|
||||||
import { discussionsPath } from '../utils';
|
|
||||||
|
|
||||||
function DiscussionContent({ intl }) {
|
export default function DiscussionContent() {
|
||||||
const location = useLocation();
|
const refContainer = useRef(null);
|
||||||
const history = useHistory();
|
|
||||||
const postEditorVisible = useSelector((state) => state.threads.postEditorVisible);
|
const postEditorVisible = useSelector((state) => state.threads.postEditorVisible);
|
||||||
const isOnDesktop = useIsOnDesktop();
|
useContainerSizeForParent(refContainer);
|
||||||
const {
|
|
||||||
courseId, learnerUsername, category, topicId, page,
|
|
||||||
} = useContext(DiscussionContext);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="d-flex bg-light-400 flex-column w-75 w-xs-100 w-xl-75 align-items-center">
|
<div className="d-flex bg-light-400 flex-column w-75 w-xs-100 w-xl-75 align-items-center h-100 overflow-auto">
|
||||||
<div className="d-flex flex-column w-100">
|
<Route path={Routes.LEARNERS.LEARNER}>
|
||||||
{!isOnDesktop && (
|
<LearnerPageHeader />
|
||||||
<IconButton
|
</Route>
|
||||||
src={ArrowBack}
|
<div className="d-flex flex-column w-100 mw-xl" ref={refContainer}>
|
||||||
iconAs={Icon}
|
|
||||||
style={{ padding: '18px' }}
|
|
||||||
size="inline"
|
|
||||||
className="ml-4 mt-4"
|
|
||||||
onClick={() => history.push(discussionsPath(PostsPages[page], {
|
|
||||||
courseId, learnerUsername, category, topicId,
|
|
||||||
})(location))}
|
|
||||||
alt={intl.formatMessage(messages.backAlt)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{postEditorVisible ? (
|
{postEditorVisible ? (
|
||||||
<Route path={Routes.POSTS.NEW_POST}>
|
<Route path={Routes.POSTS.NEW_POST}>
|
||||||
<PostEditor />
|
<PostEditor />
|
||||||
@@ -53,15 +33,12 @@ function DiscussionContent({ intl }) {
|
|||||||
<Route path={Routes.COMMENTS.PATH}>
|
<Route path={Routes.COMMENTS.PATH}>
|
||||||
<CommentsView />
|
<CommentsView />
|
||||||
</Route>
|
</Route>
|
||||||
|
<Route path={Routes.LEARNERS.LEARNER}>
|
||||||
|
<LearnersContentView />
|
||||||
|
</Route>
|
||||||
</Switch>
|
</Switch>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
DiscussionContent.propTypes = {
|
|
||||||
intl: intlShape.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default injectIntl(DiscussionContent);
|
|
||||||
|
|||||||
@@ -1,53 +1,26 @@
|
|||||||
import React, { useEffect, useRef } from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { useSelector } from 'react-redux';
|
|
||||||
import {
|
import {
|
||||||
Redirect, Route, Switch, useLocation,
|
Redirect, Route, Switch, useLocation,
|
||||||
} from 'react-router';
|
} from 'react-router';
|
||||||
|
|
||||||
import { useWindowSize } from '@edx/paragon';
|
import { Routes } from '../../data/constants';
|
||||||
|
import { LearnersView } from '../learners';
|
||||||
import { RequestStatus, Routes } from '../../data/constants';
|
|
||||||
import {
|
|
||||||
useContainerSize, useIsOnDesktop, useIsOnXLDesktop, useShowLearnersTab,
|
|
||||||
} from '../data/hooks';
|
|
||||||
import { selectconfigLoadingStatus } from '../data/selectors';
|
|
||||||
import { LearnerPostsView, LearnersView } from '../learners';
|
|
||||||
import { PostsView } from '../posts';
|
import { PostsView } from '../posts';
|
||||||
import { TopicsView } from '../topics';
|
import { TopicsView } from '../topics';
|
||||||
|
|
||||||
export default function DiscussionSidebar({ displaySidebar, postActionBarRef }) {
|
export default function DiscussionSidebar({ displaySidebar }) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const isOnDesktop = useIsOnDesktop();
|
|
||||||
const isOnXLDesktop = useIsOnXLDesktop();
|
|
||||||
const configStatus = useSelector(selectconfigLoadingStatus);
|
|
||||||
const redirectToLearnersTab = useShowLearnersTab();
|
|
||||||
const sidebarRef = useRef(null);
|
|
||||||
const postActionBarHeight = useContainerSize(postActionBarRef);
|
|
||||||
const { height: windowHeight } = useWindowSize();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (sidebarRef && postActionBarHeight) {
|
|
||||||
if (isOnDesktop) {
|
|
||||||
sidebarRef.current.style.maxHeight = `${windowHeight - postActionBarHeight}px`;
|
|
||||||
}
|
|
||||||
sidebarRef.current.style.minHeight = `${windowHeight - postActionBarHeight}px`;
|
|
||||||
sidebarRef.current.style.top = `${postActionBarHeight}px`;
|
|
||||||
}
|
|
||||||
}, [sidebarRef, postActionBarHeight]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={sidebarRef}
|
className={classNames('flex-column', {
|
||||||
className={classNames('flex-column min-content-height position-sticky', {
|
|
||||||
'd-none': !displaySidebar,
|
'd-none': !displaySidebar,
|
||||||
'd-flex overflow-auto': displaySidebar,
|
'd-flex w-25 w-xs-100 w-lg-25 overflow-auto h-100 pb-2': displaySidebar,
|
||||||
'w-100': !isOnDesktop,
|
|
||||||
'sidebar-desktop-width': isOnDesktop && !isOnXLDesktop,
|
|
||||||
'w-25 sidebar-XL-width': isOnXLDesktop,
|
|
||||||
})}
|
})}
|
||||||
|
style={{ minWidth: '30rem' }}
|
||||||
data-testid="sidebar"
|
data-testid="sidebar"
|
||||||
>
|
>
|
||||||
<Switch>
|
<Switch>
|
||||||
@@ -56,14 +29,7 @@ export default function DiscussionSidebar({ displaySidebar, postActionBarRef })
|
|||||||
component={PostsView}
|
component={PostsView}
|
||||||
/>
|
/>
|
||||||
<Route path={Routes.TOPICS.PATH} component={TopicsView} />
|
<Route path={Routes.TOPICS.PATH} component={TopicsView} />
|
||||||
{redirectToLearnersTab && (
|
|
||||||
<Route path={Routes.LEARNERS.POSTS} component={LearnerPostsView} />
|
|
||||||
)}
|
|
||||||
{redirectToLearnersTab && (
|
|
||||||
<Route path={Routes.LEARNERS.PATH} component={LearnersView} />
|
<Route path={Routes.LEARNERS.PATH} component={LearnersView} />
|
||||||
)}
|
|
||||||
|
|
||||||
{configStatus === RequestStatus.SUCCESSFUL && (
|
|
||||||
<Redirect
|
<Redirect
|
||||||
from={Routes.DISCUSSIONS.PATH}
|
from={Routes.DISCUSSIONS.PATH}
|
||||||
to={{
|
to={{
|
||||||
@@ -71,7 +37,6 @@ export default function DiscussionSidebar({ displaySidebar, postActionBarRef })
|
|||||||
pathname: Routes.POSTS.ALL_POSTS,
|
pathname: Routes.POSTS.ALL_POSTS,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</Switch>
|
</Switch>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -79,13 +44,8 @@ export default function DiscussionSidebar({ displaySidebar, postActionBarRef })
|
|||||||
|
|
||||||
DiscussionSidebar.defaultProps = {
|
DiscussionSidebar.defaultProps = {
|
||||||
displaySidebar: false,
|
displaySidebar: false,
|
||||||
postActionBarRef: null,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
DiscussionSidebar.propTypes = {
|
DiscussionSidebar.propTypes = {
|
||||||
displaySidebar: PropTypes.bool,
|
displaySidebar: PropTypes.bool,
|
||||||
postActionBarRef: PropTypes.oneOfType([
|
|
||||||
PropTypes.func,
|
|
||||||
PropTypes.shape({ current: PropTypes.instanceOf(Element) }),
|
|
||||||
]),
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
|||||||
import { AppProvider } from '@edx/frontend-platform/react';
|
import { AppProvider } from '@edx/frontend-platform/react';
|
||||||
|
|
||||||
import { initializeStore } from '../../store';
|
import { initializeStore } from '../../store';
|
||||||
import { DiscussionContext } from '../common/context';
|
|
||||||
import { fetchConfigSuccess } from '../data/slices';
|
|
||||||
import { threadsApiUrl } from '../posts/data/api';
|
import { threadsApiUrl } from '../posts/data/api';
|
||||||
import DiscussionSidebar from './DiscussionSidebar';
|
import DiscussionSidebar from './DiscussionSidebar';
|
||||||
|
|
||||||
@@ -28,11 +26,9 @@ function renderComponent(displaySidebar = true, location = `/${courseId}/`) {
|
|||||||
<IntlProvider locale="en">
|
<IntlProvider locale="en">
|
||||||
<ResponsiveContext.Provider value={{ width: 1280 }}>
|
<ResponsiveContext.Provider value={{ width: 1280 }}>
|
||||||
<AppProvider store={store}>
|
<AppProvider store={store}>
|
||||||
<DiscussionContext.Provider value={{ courseId }}>
|
<MemoryRouter initialEntries={[location]}>
|
||||||
<MemoryRouter initialEntries={[location]}>
|
<DiscussionSidebar displaySidebar={displaySidebar} />
|
||||||
<DiscussionSidebar displaySidebar={displaySidebar} postActionBarRef={null} />
|
</MemoryRouter>
|
||||||
</MemoryRouter>
|
|
||||||
</DiscussionContext.Provider>
|
|
||||||
</AppProvider>
|
</AppProvider>
|
||||||
</ResponsiveContext.Provider>
|
</ResponsiveContext.Provider>
|
||||||
</IntlProvider>,
|
</IntlProvider>,
|
||||||
@@ -55,7 +51,6 @@ describe('DiscussionSidebar', () => {
|
|||||||
store = initializeStore({
|
store = initializeStore({
|
||||||
blocks: { blocks: { 'test-usage-key': { topics: ['some-topic-2', 'some-topic-0'] } } },
|
blocks: { blocks: { 'test-usage-key': { topics: ['some-topic-2', 'some-topic-0'] } } },
|
||||||
});
|
});
|
||||||
store.dispatch(fetchConfigSuccess({}));
|
|
||||||
Factory.resetAll();
|
Factory.resetAll();
|
||||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||||
});
|
});
|
||||||
@@ -78,10 +73,7 @@ describe('DiscussionSidebar', () => {
|
|||||||
test('User will be redirected to "All Posts" by default', async () => {
|
test('User will be redirected to "All Posts" by default', async () => {
|
||||||
axiosMock.onGet(threadsApiUrl)
|
axiosMock.onGet(threadsApiUrl)
|
||||||
.reply(({ params }) => [200, Factory.build('threadsResult', {}, {
|
.reply(({ params }) => [200, Factory.build('threadsResult', {}, {
|
||||||
threadAttrs: {
|
threadAttrs: { title: `Thread by ${params.author || 'other users'}` },
|
||||||
title: `Thread by ${params.author || 'other users'}`,
|
|
||||||
previewBody: 'thread preview body',
|
|
||||||
},
|
|
||||||
})]);
|
})]);
|
||||||
renderComponent();
|
renderComponent();
|
||||||
await act(async () => expect(await screen.findAllByText('Thread by other users')).toBeTruthy());
|
await act(async () => expect(await screen.findAllByText('Thread by other users')).toBeTruthy());
|
||||||
@@ -93,10 +85,7 @@ describe('DiscussionSidebar', () => {
|
|||||||
axiosMock.onGet(threadsApiUrl)
|
axiosMock.onGet(threadsApiUrl)
|
||||||
.reply(({ params }) => [200, Factory.build('threadsResult', {}, {
|
.reply(({ params }) => [200, Factory.build('threadsResult', {}, {
|
||||||
count: postCount,
|
count: postCount,
|
||||||
threadAttrs: {
|
threadAttrs: { title: `Thread by ${params.author || 'other users'}` },
|
||||||
title: `Thread by ${params.author || 'other users'}`,
|
|
||||||
previewBody: 'thread preview body',
|
|
||||||
},
|
|
||||||
})]);
|
})]);
|
||||||
renderComponent();
|
renderComponent();
|
||||||
await act(async () => expect(await screen.findAllByText('Thread by other users')).toBeTruthy());
|
await act(async () => expect(await screen.findAllByText('Thread by other users')).toBeTruthy());
|
||||||
|
|||||||
@@ -1,46 +1,36 @@
|
|||||||
import React, { useEffect, useRef } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
|
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import {
|
import {
|
||||||
Route, Switch, useLocation, useRouteMatch,
|
Route, Switch, useLocation, useRouteMatch,
|
||||||
} from 'react-router';
|
} from 'react-router';
|
||||||
|
|
||||||
import Footer from '@edx/frontend-component-footer';
|
|
||||||
import { LearningHeader as Header } from '@edx/frontend-component-header';
|
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
|
||||||
|
|
||||||
import { PostActionsBar } from '../../components';
|
import { PostActionsBar } from '../../components';
|
||||||
import { CourseTabsNavigation } from '../../components/NavigationBar';
|
|
||||||
import { ALL_ROUTES, DiscussionProvider, Routes } from '../../data/constants';
|
import { ALL_ROUTES, DiscussionProvider, Routes } from '../../data/constants';
|
||||||
|
import { useTheme } from '../../theme-hooks';
|
||||||
import { DiscussionContext } from '../common/context';
|
import { DiscussionContext } from '../common/context';
|
||||||
import {
|
import {
|
||||||
useCourseDiscussionData, useIsOnDesktop, useRedirectToThread, useShowLearnersTab, useSidebarVisible,
|
useCourseDiscussionData, useIsOnDesktop, useRedirectToThread, useSidebarVisible,
|
||||||
} from '../data/hooks';
|
} from '../data/hooks';
|
||||||
import { selectDiscussionProvider } from '../data/selectors';
|
import { selectDiscussionProvider } from '../data/selectors';
|
||||||
import { EmptyLearners, EmptyPosts, EmptyTopics } from '../empty-posts';
|
import { EmptyPosts, EmptyTopics } from '../empty-posts';
|
||||||
import messages from '../messages';
|
import messages from '../messages';
|
||||||
import { BreadcrumbMenu, LegacyBreadcrumbMenu, NavigationBar } from '../navigation';
|
import { BreadcrumbMenu, LegacyBreadcrumbMenu, NavigationBar } from '../navigation';
|
||||||
import { postMessageToParent } from '../utils';
|
import { postMessageToParent } from '../utils';
|
||||||
import BlackoutInformationBanner from './BlackoutInformationBanner';
|
|
||||||
import DiscussionContent from './DiscussionContent';
|
import DiscussionContent from './DiscussionContent';
|
||||||
import DiscussionSidebar from './DiscussionSidebar';
|
import DiscussionSidebar from './DiscussionSidebar';
|
||||||
import InformationBanner from './InformationsBanner';
|
|
||||||
|
|
||||||
export default function DiscussionsHome() {
|
export default function DiscussionsHome() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const postActionBarRef = useRef(null);
|
const ready = useTheme('red_theme');
|
||||||
const postEditorVisible = useSelector(
|
const postEditorVisible = useSelector(
|
||||||
(state) => state.threads.postEditorVisible,
|
(state) => state.threads.postEditorVisible,
|
||||||
);
|
);
|
||||||
const {
|
const {
|
||||||
params: { page },
|
params: { page },
|
||||||
} = useRouteMatch(`${Routes.COMMENTS.PAGE}?`);
|
} = useRouteMatch(`${Routes.COMMENTS.PAGE}?`);
|
||||||
|
|
||||||
const { params: { path } } = useRouteMatch(`${Routes.DISCUSSIONS.PATH}/:path*`);
|
const { params: { path } } = useRouteMatch(`${Routes.DISCUSSIONS.PATH}/:path*`);
|
||||||
const { params } = useRouteMatch(ALL_ROUTES);
|
const { params } = useRouteMatch(ALL_ROUTES);
|
||||||
const isRedirectToLearners = useShowLearnersTab();
|
|
||||||
const isFeedbackBannerVisible = getConfig().DISPLAY_FEEDBACK_BANNER === 'true';
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
courseId,
|
courseId,
|
||||||
postId,
|
postId,
|
||||||
@@ -49,15 +39,14 @@ export default function DiscussionsHome() {
|
|||||||
learnerUsername,
|
learnerUsername,
|
||||||
} = params;
|
} = params;
|
||||||
const inContext = new URLSearchParams(location.search).get('inContext') !== null;
|
const inContext = new URLSearchParams(location.search).get('inContext') !== null;
|
||||||
|
|
||||||
// Display the content area if we are currently viewing/editing a post or creating one.
|
// Display the content area if we are currently viewing/editing a post or creating one.
|
||||||
const displayContentArea = postId || postEditorVisible || (learnerUsername && postId);
|
const displayContentArea = postId || postEditorVisible || learnerUsername;
|
||||||
|
|
||||||
let displaySidebar = useSidebarVisible();
|
let displaySidebar = useSidebarVisible();
|
||||||
|
|
||||||
const isOnDesktop = useIsOnDesktop();
|
const isOnDesktop = useIsOnDesktop();
|
||||||
|
|
||||||
const { courseNumber, courseTitle, org } = useSelector(
|
|
||||||
(state) => state.courseTabs,
|
|
||||||
);
|
|
||||||
if (displayContentArea) {
|
if (displayContentArea) {
|
||||||
// If the window is larger than a particular size, show the sidebar for navigating between posts/topics.
|
// 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.
|
// However, for smaller screens or embeds, only show the sidebar if the content area isn't displayed.
|
||||||
@@ -72,6 +61,9 @@ export default function DiscussionsHome() {
|
|||||||
postMessageToParent('discussions.navigate', { path });
|
postMessageToParent('discussions.navigate', { path });
|
||||||
}
|
}
|
||||||
}, [path]);
|
}, [path]);
|
||||||
|
if (!ready) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DiscussionContext.Provider value={{
|
<DiscussionContext.Provider value={{
|
||||||
@@ -84,27 +76,22 @@ export default function DiscussionsHome() {
|
|||||||
learnerUsername,
|
learnerUsername,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Header courseOrg={org} courseNumber={courseNumber} courseTitle={courseTitle} />
|
<main className="container-fluid d-flex flex-column p-0 h-100 w-100 overflow-hidden">
|
||||||
<main className="container-fluid d-flex flex-column p-0 w-100" id="main" tabIndex="-1">
|
<div
|
||||||
<CourseTabsNavigation activeTab="discussion" courseId={courseId} />
|
className="d-flex flex-row justify-content-between navbar fixed-top"
|
||||||
<div className="header-action-bar" ref={postActionBarRef}>
|
style={{ boxShadow: '0px 2px 4px rgb(0 0 0 / 15%), 0px 2px 8px rgb(0 0 0 / 15%)' }}
|
||||||
<div
|
>
|
||||||
className="d-flex flex-row justify-content-between navbar fixed-top"
|
{!inContext && (
|
||||||
>
|
<Route path={Routes.DISCUSSIONS.PATH} component={NavigationBar} />
|
||||||
{!inContext && (
|
)}
|
||||||
<Route path={Routes.DISCUSSIONS.PATH} component={NavigationBar} />
|
<PostActionsBar inContext={inContext} />
|
||||||
)}
|
|
||||||
<PostActionsBar inContext={inContext} />
|
|
||||||
</div>
|
|
||||||
{isFeedbackBannerVisible && <InformationBanner />}
|
|
||||||
<BlackoutInformationBanner />
|
|
||||||
</div>
|
</div>
|
||||||
<Route
|
<Route
|
||||||
path={[Routes.POSTS.PATH, Routes.TOPICS.CATEGORY]}
|
path={[Routes.POSTS.PATH, Routes.TOPICS.CATEGORY]}
|
||||||
component={provider === DiscussionProvider.LEGACY ? LegacyBreadcrumbMenu : BreadcrumbMenu}
|
component={provider === DiscussionProvider.LEGACY ? LegacyBreadcrumbMenu : BreadcrumbMenu}
|
||||||
/>
|
/>
|
||||||
<div className="d-flex flex-row">
|
<div className="d-flex flex-row overflow-hidden flex-grow-1">
|
||||||
<DiscussionSidebar displaySidebar={displaySidebar} postActionBarRef={postActionBarRef} />
|
<DiscussionSidebar displaySidebar={displaySidebar} />
|
||||||
{displayContentArea && <DiscussionContent />}
|
{displayContentArea && <DiscussionContent />}
|
||||||
{!displayContentArea && (
|
{!displayContentArea && (
|
||||||
<Switch>
|
<Switch>
|
||||||
@@ -114,15 +101,13 @@ export default function DiscussionsHome() {
|
|||||||
render={routeProps => <EmptyPosts {...routeProps} subTitleMessage={messages.emptyMyPosts} />}
|
render={routeProps => <EmptyPosts {...routeProps} subTitleMessage={messages.emptyMyPosts} />}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path={[Routes.POSTS.PATH, Routes.POSTS.ALL_POSTS, Routes.LEARNERS.POSTS]}
|
path={[Routes.POSTS.PATH, Routes.POSTS.ALL_POSTS]}
|
||||||
render={routeProps => <EmptyPosts {...routeProps} subTitleMessage={messages.emptyAllPosts} />}
|
render={routeProps => <EmptyPosts {...routeProps} subTitleMessage={messages.emptyAllPosts} />}
|
||||||
/>
|
/>
|
||||||
{isRedirectToLearners && <Route path={Routes.LEARNERS.PATH} component={EmptyLearners} /> }
|
|
||||||
</Switch>
|
</Switch>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<Footer />
|
|
||||||
</DiscussionContext.Provider>
|
</DiscussionContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,11 +87,4 @@ describe('DiscussionsHome', () => {
|
|||||||
await waitFor(() => expect(window.parent.postMessage).toHaveBeenCalled());
|
await waitFor(() => expect(window.parent.postMessage).toHaveBeenCalled());
|
||||||
window.parent = parent;
|
window.parent = parent;
|
||||||
});
|
});
|
||||||
|
|
||||||
test('header, course navigation bar and footer are visible', async () => {
|
|
||||||
renderComponent();
|
|
||||||
expect(screen.queryByRole('banner')).toBeInTheDocument();
|
|
||||||
expect(document.getElementById('courseTabsNavigation')).toBeInTheDocument();
|
|
||||||
expect(screen.queryByRole('contentinfo')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,136 +0,0 @@
|
|||||||
import { render, screen } from '@testing-library/react';
|
|
||||||
import { IntlProvider } from 'react-intl';
|
|
||||||
|
|
||||||
import { initializeMockApp } from '@edx/frontend-platform';
|
|
||||||
import { AppProvider } from '@edx/frontend-platform/react';
|
|
||||||
|
|
||||||
import { initializeStore } from '../../store';
|
|
||||||
import { DiscussionContext } from '../common/context';
|
|
||||||
import { fetchConfigSuccess } from '../data/slices';
|
|
||||||
import messages from '../messages';
|
|
||||||
import InformationBanner from './InformationsBanner';
|
|
||||||
|
|
||||||
import '../posts/data/__factories__';
|
|
||||||
|
|
||||||
let store;
|
|
||||||
let container;
|
|
||||||
const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
|
||||||
|
|
||||||
const getConfigData = (isAdmin = true, roles = []) => ({
|
|
||||||
id: 'course-v1:edX+DemoX+Demo_Course',
|
|
||||||
userRoles: roles,
|
|
||||||
hasModerationPrivileges: false,
|
|
||||||
isGroupTa: false,
|
|
||||||
isUserAdmin: isAdmin,
|
|
||||||
});
|
|
||||||
|
|
||||||
function renderComponent() {
|
|
||||||
const wrapper = render(
|
|
||||||
<IntlProvider locale="en">
|
|
||||||
<AppProvider store={store}>
|
|
||||||
<DiscussionContext.Provider value={{ courseId }}>
|
|
||||||
<InformationBanner />
|
|
||||||
</DiscussionContext.Provider>
|
|
||||||
</AppProvider>
|
|
||||||
</IntlProvider>,
|
|
||||||
);
|
|
||||||
container = wrapper.container;
|
|
||||||
return container;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('Information Banner learner view', () => {
|
|
||||||
let element;
|
|
||||||
beforeEach(async () => {
|
|
||||||
initializeMockApp({
|
|
||||||
authenticatedUser: {
|
|
||||||
userId: 3,
|
|
||||||
username: 'abc123',
|
|
||||||
administrator: false,
|
|
||||||
roles: ['Student'],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
store = initializeStore();
|
|
||||||
store.dispatch(fetchConfigSuccess(getConfigData(false, ['Student'])));
|
|
||||||
renderComponent(true);
|
|
||||||
element = await screen.findByRole('alert');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Test Banner is visible on app load', async () => {
|
|
||||||
expect(element).toHaveTextContent(messages.bannerMessage.defaultMessage);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Test Banner do not have learn more button', async () => {
|
|
||||||
expect(element).not.toHaveTextContent(messages.learnMoreBannerLink.defaultMessage);
|
|
||||||
});
|
|
||||||
test('Test Banner has share feedback button', async () => {
|
|
||||||
expect(element).toHaveTextContent(messages.shareFeedback.defaultMessage);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Information Banner moderators/staff/admin view', () => {
|
|
||||||
let element;
|
|
||||||
beforeEach(async () => {
|
|
||||||
initializeMockApp({
|
|
||||||
authenticatedUser: {
|
|
||||||
userId: 3,
|
|
||||||
username: 'abc123',
|
|
||||||
administrator: true,
|
|
||||||
roles: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
store = initializeStore();
|
|
||||||
store.dispatch(fetchConfigSuccess(getConfigData(true, ['Student', 'Moderator'])));
|
|
||||||
renderComponent(true);
|
|
||||||
element = await screen.findByRole('alert');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Test Banner is visible on app load', async () => {
|
|
||||||
expect(element).toHaveTextContent(messages.bannerMessage.defaultMessage);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Test Banner has learn more button', async () => {
|
|
||||||
expect(element).toHaveTextContent(messages.learnMoreBannerLink.defaultMessage);
|
|
||||||
});
|
|
||||||
test('Test Banner has share feedback button', async () => {
|
|
||||||
expect(element).toHaveTextContent(messages.shareFeedback.defaultMessage);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('User is redirected according to url according to role', () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
initializeMockApp({
|
|
||||||
authenticatedUser: {
|
|
||||||
userId: 3,
|
|
||||||
username: 'abc123',
|
|
||||||
administrator: true,
|
|
||||||
roles: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
store = initializeStore();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('TAs are redirected to learners feedback form', async () => {
|
|
||||||
store.dispatch(fetchConfigSuccess(getConfigData(false, ['Student', 'Community TA'])));
|
|
||||||
renderComponent(true);
|
|
||||||
expect(screen.getByText(messages.shareFeedback.defaultMessage)
|
|
||||||
.closest('a'))
|
|
||||||
.toHaveAttribute('href', process.env.TA_FEEDBACK_FORM);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('moderators/administrators are redirected to moderators feedback form', async () => {
|
|
||||||
store.dispatch(fetchConfigSuccess(getConfigData(false, ['Student', 'Moderator', 'Administrator'])));
|
|
||||||
renderComponent(true);
|
|
||||||
expect(screen.getByText(messages.shareFeedback.defaultMessage)
|
|
||||||
.closest('a'))
|
|
||||||
.toHaveAttribute('href', process.env.STAFF_FEEDBACK_FORM);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('user with only isAdmin true are redirected to moderators feedback form', async () => {
|
|
||||||
store.dispatch(fetchConfigSuccess(getConfigData(true, ['Student'])));
|
|
||||||
renderComponent(true);
|
|
||||||
expect(screen.getByText(messages.shareFeedback.defaultMessage)
|
|
||||||
.closest('a'))
|
|
||||||
.toHaveAttribute('href', process.env.STAFF_FEEDBACK_FORM);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
|
|
||||||
import { useSelector } from 'react-redux';
|
|
||||||
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
|
||||||
import { Hyperlink, PageBanner } from '@edx/paragon';
|
|
||||||
|
|
||||||
import { selectUserIsStaff, selectUserRoles } from '../data/selectors';
|
|
||||||
import messages from '../messages';
|
|
||||||
|
|
||||||
function InformationBanner({
|
|
||||||
intl,
|
|
||||||
}) {
|
|
||||||
const [showBanner, setShowBanner] = useState(true);
|
|
||||||
const userRoles = useSelector(selectUserRoles);
|
|
||||||
const isAdmin = useSelector(selectUserIsStaff);
|
|
||||||
const learnMoreLink = 'https://openedx.atlassian.net/wiki/spaces/COMM/pages/3509551260/Overview+New+discussions+experience';
|
|
||||||
const TAFeedbackLink = process.env.TA_FEEDBACK_FORM;
|
|
||||||
const staffFeedbackLink = process.env.STAFF_FEEDBACK_FORM;
|
|
||||||
const hideLearnMoreButton = ((userRoles.includes('Student') && userRoles.length === 1) || !userRoles.length) && !isAdmin;
|
|
||||||
const showStaffLink = isAdmin || userRoles.includes('Moderator') || userRoles.includes('Administrator');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageBanner
|
|
||||||
variant="light"
|
|
||||||
show={showBanner}
|
|
||||||
dismissible
|
|
||||||
onDismiss={() => setShowBanner(false)}
|
|
||||||
>
|
|
||||||
<div className="font-weight-500">
|
|
||||||
{intl.formatMessage(messages.bannerMessage)}
|
|
||||||
{!hideLearnMoreButton
|
|
||||||
&& (
|
|
||||||
<Hyperlink
|
|
||||||
destination={learnMoreLink}
|
|
||||||
target="_blank"
|
|
||||||
showLaunchIcon={false}
|
|
||||||
className="pl-2.5"
|
|
||||||
variant="muted"
|
|
||||||
isInline
|
|
||||||
>
|
|
||||||
{intl.formatMessage(messages.learnMoreBannerLink)}
|
|
||||||
</Hyperlink>
|
|
||||||
)}
|
|
||||||
<Hyperlink
|
|
||||||
destination={showStaffLink ? staffFeedbackLink : TAFeedbackLink}
|
|
||||||
target="_blank"
|
|
||||||
showLaunchIcon={false}
|
|
||||||
variant="muted"
|
|
||||||
className="pl-2.5"
|
|
||||||
isInline
|
|
||||||
>
|
|
||||||
{intl.formatMessage(messages.shareFeedback)}
|
|
||||||
</Hyperlink>
|
|
||||||
</div>
|
|
||||||
</PageBanner>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
InformationBanner.propTypes = {
|
|
||||||
intl: intlShape.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default injectIntl(InformationBanner);
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
|
||||||
|
|
||||||
import { useIsOnDesktop } from '../data/hooks';
|
|
||||||
import messages from '../messages';
|
|
||||||
import EmptyPage from './EmptyPage';
|
|
||||||
|
|
||||||
function EmptyLearners({ intl }) {
|
|
||||||
const isOnDesktop = useIsOnDesktop();
|
|
||||||
|
|
||||||
if (!isOnDesktop) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<EmptyPage title={intl.formatMessage(messages.emptyTitle)} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
EmptyLearners.propTypes = {
|
|
||||||
intl: intlShape.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default injectIntl(EmptyLearners);
|
|
||||||
@@ -15,7 +15,7 @@ function EmptyPage({
|
|||||||
fullWidth = false,
|
fullWidth = false,
|
||||||
}) {
|
}) {
|
||||||
const containerClasses = classNames(
|
const containerClasses = classNames(
|
||||||
'min-content-height justify-content-center align-items-center d-flex w-100 flex-column pt-5',
|
'justify-content-center align-items-center d-flex w-100 flex-column pt-5',
|
||||||
{ 'bg-light-400': !fullWidth },
|
{ 'bg-light-400': !fullWidth },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
export { default as EmptyLearners } from './EmptyLearners';
|
|
||||||
export { default as EmptyPage } from './EmptyPage';
|
export { default as EmptyPage } from './EmptyPage';
|
||||||
export { default as EmptyPosts } from './EmptyPosts';
|
export { default as EmptyPosts } from './EmptyPosts';
|
||||||
export { default as EmptyTopics } from './EmptyTopics';
|
export { default as EmptyTopics } from './EmptyTopics';
|
||||||
|
|||||||
70
src/discussions/learners/LearnerPageHeader.jsx
Normal file
70
src/discussions/learners/LearnerPageHeader.jsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import React, { useContext } from 'react';
|
||||||
|
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { useSelector } from 'react-redux';
|
||||||
|
import { generatePath, NavLink } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||||
|
import { Avatar, ButtonGroup, Icon } from '@edx/paragon';
|
||||||
|
import { Report } from '@edx/paragon/icons';
|
||||||
|
|
||||||
|
import { Routes } from '../../data/constants';
|
||||||
|
import { DiscussionContext } from '../common/context';
|
||||||
|
import { selectLearner, selectLearnerAvatar, selectLearnerProfile } from './data/selectors';
|
||||||
|
import messages from './messages';
|
||||||
|
|
||||||
|
function LearnerPageHeader({ intl }) {
|
||||||
|
const { courseId, learnerUsername } = useContext(DiscussionContext);
|
||||||
|
const params = { courseId, learnerUsername };
|
||||||
|
const learner = useSelector(selectLearner(learnerUsername));
|
||||||
|
const profile = useSelector(selectLearnerProfile(learnerUsername));
|
||||||
|
const avatar = useSelector(selectLearnerAvatar(learnerUsername));
|
||||||
|
|
||||||
|
const activeTabClass = (active) => classNames('btn', { 'btn-primary': active, 'btn-outline-primary': !active });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="d-flex flex-column w-100 bg-white shadow-sm">
|
||||||
|
<div className="d-flex flex-row align-items-center m-4">
|
||||||
|
<Avatar src={avatar} alt={learnerUsername} />
|
||||||
|
<span className="font-weight-bold mx-3">
|
||||||
|
{profile.username}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="d-flex pb-0 bg-light-200 justify-content-center p-2 flex-fill">
|
||||||
|
<ButtonGroup className="my-2 bg-white">
|
||||||
|
<NavLink
|
||||||
|
className={activeTabClass}
|
||||||
|
to={generatePath(Routes.LEARNERS.TABS.posts, params)}
|
||||||
|
>
|
||||||
|
{intl.formatMessage(messages.postsTab)} <span className="ml-3">{learner.threads}</span>
|
||||||
|
{
|
||||||
|
learner.activeFlags ? (
|
||||||
|
<span className="ml-3">
|
||||||
|
<Icon src={Report} />
|
||||||
|
</span>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
</NavLink>
|
||||||
|
<NavLink
|
||||||
|
className={activeTabClass}
|
||||||
|
to={generatePath(Routes.LEARNERS.TABS.responses, params)}
|
||||||
|
>
|
||||||
|
{intl.formatMessage(messages.responsesTab)} <span className="ml-3">{learner.responses}</span>
|
||||||
|
</NavLink>
|
||||||
|
<NavLink
|
||||||
|
className={activeTabClass}
|
||||||
|
to={generatePath(Routes.LEARNERS.TABS.comments, params)}
|
||||||
|
>
|
||||||
|
{intl.formatMessage(messages.commentsTab)} <span className="ml-3">{learner.replies}</span>
|
||||||
|
</NavLink>
|
||||||
|
</ButtonGroup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
LearnerPageHeader.propTypes = {
|
||||||
|
intl: intlShape.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default injectIntl(LearnerPageHeader);
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
import React, {
|
|
||||||
useCallback, useContext, useEffect, useMemo,
|
|
||||||
} from 'react';
|
|
||||||
|
|
||||||
import snakeCase from 'lodash.snakecase';
|
|
||||||
import capitalize from 'lodash/capitalize';
|
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
|
||||||
import { useHistory, useLocation } from 'react-router-dom';
|
|
||||||
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
|
||||||
import {
|
|
||||||
Button, Icon, IconButton, Spinner,
|
|
||||||
} from '@edx/paragon';
|
|
||||||
import { ArrowBack } from '@edx/paragon/icons';
|
|
||||||
|
|
||||||
import {
|
|
||||||
PostsStatusFilter,
|
|
||||||
RequestStatus,
|
|
||||||
Routes,
|
|
||||||
ThreadType,
|
|
||||||
} from '../../data/constants';
|
|
||||||
import { DiscussionContext } from '../common/context';
|
|
||||||
import { selectUserHasModerationPrivileges, selectUserIsStaff } from '../data/selectors';
|
|
||||||
import {
|
|
||||||
selectAllThreads,
|
|
||||||
selectThreadNextPage,
|
|
||||||
threadsLoadingStatus,
|
|
||||||
} from '../posts/data/selectors';
|
|
||||||
import { clearPostsPages } from '../posts/data/slices';
|
|
||||||
import NoResults from '../posts/NoResults';
|
|
||||||
import { PostLink } from '../posts/post';
|
|
||||||
import { discussionsPath, filterPosts } from '../utils';
|
|
||||||
import { fetchUserPosts } from './data/thunks';
|
|
||||||
import LearnerPostFilterBar from './learner-post-filter-bar/LearnerPostFilterBar';
|
|
||||||
import messages from './messages';
|
|
||||||
|
|
||||||
function LearnerPostsView({ intl }) {
|
|
||||||
const location = useLocation();
|
|
||||||
const history = useHistory();
|
|
||||||
const dispatch = useDispatch();
|
|
||||||
|
|
||||||
const posts = useSelector(selectAllThreads);
|
|
||||||
const loadingStatus = useSelector(threadsLoadingStatus());
|
|
||||||
const postFilter = useSelector(state => state.learners.postFilter);
|
|
||||||
const { courseId, learnerUsername: username } = useContext(DiscussionContext);
|
|
||||||
const nextPage = useSelector(selectThreadNextPage());
|
|
||||||
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
|
|
||||||
const userIsStaff = useSelector(selectUserIsStaff);
|
|
||||||
const countFlagged = userHasModerationPrivileges || userIsStaff;
|
|
||||||
const params = {
|
|
||||||
orderBy: snakeCase(postFilter.orderBy),
|
|
||||||
username,
|
|
||||||
page: 1,
|
|
||||||
};
|
|
||||||
if (postFilter.type !== ThreadType.ALL) {
|
|
||||||
params.threadType = postFilter.type;
|
|
||||||
}
|
|
||||||
if (postFilter.status !== PostsStatusFilter.ALL) {
|
|
||||||
const statusMapping = {
|
|
||||||
statusUnread: 'unread',
|
|
||||||
statusReported: 'flagged',
|
|
||||||
statusUnanswered: 'unanswered',
|
|
||||||
statusUnresponded: 'unresponded',
|
|
||||||
};
|
|
||||||
params.status = statusMapping[postFilter.status];
|
|
||||||
}
|
|
||||||
if (postFilter.cohort !== '') {
|
|
||||||
params.groupId = postFilter.cohort;
|
|
||||||
}
|
|
||||||
if (countFlagged) {
|
|
||||||
params.countFlagged = countFlagged;
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
dispatch(fetchUserPosts(courseId, params));
|
|
||||||
}, [courseId, username]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
dispatch(clearPostsPages());
|
|
||||||
dispatch(fetchUserPosts(courseId, params));
|
|
||||||
}, [postFilter]);
|
|
||||||
|
|
||||||
const loadMorePosts = () => (
|
|
||||||
dispatch(fetchUserPosts(courseId, {
|
|
||||||
...params,
|
|
||||||
page: nextPage,
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
|
|
||||||
const checkIsSelected = (id) => window.location.pathname.includes(id);
|
|
||||||
const pinnedPosts = useMemo(() => filterPosts(posts, 'pinned'), [posts]);
|
|
||||||
const unpinnedPosts = useMemo(() => filterPosts(posts, 'unpinned'), [posts]);
|
|
||||||
|
|
||||||
const postInstances = useCallback((sortedPosts) => (
|
|
||||||
sortedPosts.map((post, idx) => (
|
|
||||||
<PostLink
|
|
||||||
post={post}
|
|
||||||
key={post.id}
|
|
||||||
isSelected={checkIsSelected}
|
|
||||||
idx={idx}
|
|
||||||
showDivider={(sortedPosts.length - 1) !== idx}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
), []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="discussion-posts d-flex flex-column">
|
|
||||||
<div className="d-flex align-items-center justify-content-between px-2.5">
|
|
||||||
<IconButton
|
|
||||||
src={ArrowBack}
|
|
||||||
iconAs={Icon}
|
|
||||||
style={{ padding: '18px' }}
|
|
||||||
size="inline"
|
|
||||||
onClick={() => history.push(discussionsPath(Routes.LEARNERS.PATH, { courseId })(location))}
|
|
||||||
alt={intl.formatMessage(messages.back)}
|
|
||||||
/>
|
|
||||||
<div className="text-primary-500 font-style-normal font-family-inter font-weight-bold py-2.5">
|
|
||||||
{intl.formatMessage(messages.activityForLearner, { username: capitalize(username) })}
|
|
||||||
</div>
|
|
||||||
<div style={{ padding: '18px' }} />
|
|
||||||
</div>
|
|
||||||
<div className="bg-light-400 border border-light-300" />
|
|
||||||
<LearnerPostFilterBar />
|
|
||||||
<div className="list-group list-group-flush">
|
|
||||||
{postInstances(pinnedPosts)}
|
|
||||||
{postInstances(unpinnedPosts)}
|
|
||||||
{loadingStatus !== RequestStatus.IN_PROGRESS && posts?.length === 0 && <NoResults />}
|
|
||||||
{loadingStatus === RequestStatus.IN_PROGRESS ? (
|
|
||||||
<div className="d-flex justify-content-center p-4">
|
|
||||||
<Spinner animation="border" variant="primary" size="lg" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
nextPage && loadingStatus === RequestStatus.SUCCESSFUL && (
|
|
||||||
<Button onClick={() => loadMorePosts()} variant="primary" size="md">
|
|
||||||
{intl.formatMessage(messages.loadMore)}
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
LearnerPostsView.propTypes = {
|
|
||||||
intl: intlShape.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default injectIntl(LearnerPostsView);
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
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 { MemoryRouter, Route } from 'react-router';
|
|
||||||
import { Factory } from 'rosie';
|
|
||||||
|
|
||||||
import { initializeMockApp } from '@edx/frontend-platform';
|
|
||||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
|
||||||
import { AppProvider } from '@edx/frontend-platform/react';
|
|
||||||
|
|
||||||
import { initializeStore } from '../../store';
|
|
||||||
import { executeThunk } from '../../test-utils';
|
|
||||||
import { DiscussionContext } from '../common/context';
|
|
||||||
import { courseConfigApiUrl } from '../data/api';
|
|
||||||
import { fetchCourseConfig } from '../data/thunks';
|
|
||||||
import { coursesApiUrl } from './data/api';
|
|
||||||
import LearnerPostsView from './LearnerPostsView';
|
|
||||||
|
|
||||||
import './data/__factories__';
|
|
||||||
|
|
||||||
let store;
|
|
||||||
let axiosMock;
|
|
||||||
const courseId = 'course-v1:edX+TestX+Test_Course';
|
|
||||||
const username = 'abc123';
|
|
||||||
|
|
||||||
function renderComponent(path = `/${courseId}/learners/${username}/posts`) {
|
|
||||||
return render(
|
|
||||||
<IntlProvider locale="en">
|
|
||||||
<AppProvider store={store}>
|
|
||||||
<DiscussionContext.Provider
|
|
||||||
value={{
|
|
||||||
learnerUsername: username,
|
|
||||||
courseId,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MemoryRouter initialEntries={[path]}>
|
|
||||||
<Route path={path}>
|
|
||||||
<LearnerPostsView />
|
|
||||||
</Route>
|
|
||||||
</MemoryRouter>
|
|
||||||
</DiscussionContext.Provider>
|
|
||||||
</AppProvider>
|
|
||||||
</IntlProvider>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('LearnerPostsView', () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
initializeMockApp({
|
|
||||||
authenticatedUser: {
|
|
||||||
userId: 3,
|
|
||||||
username,
|
|
||||||
administrator: true,
|
|
||||||
roles: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
store = initializeStore();
|
|
||||||
Factory.resetAll();
|
|
||||||
const learnerPosts = Factory.build('learnerPosts', {}, {
|
|
||||||
abuseFlaggedCount: 1,
|
|
||||||
});
|
|
||||||
const apiUrl = `${coursesApiUrl}${courseId}/learner/`;
|
|
||||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
|
||||||
axiosMock.onGet(apiUrl, { username, count_flagged: true })
|
|
||||||
.reply(() => [200, learnerPosts]);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Basic', () => {
|
|
||||||
test('Reported icon is visible to moderator for post with reported comment', async () => {
|
|
||||||
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`).reply(200, {
|
|
||||||
has_moderation_privileges: true,
|
|
||||||
});
|
|
||||||
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/settings`).reply(200, {});
|
|
||||||
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
|
|
||||||
await act(async () => {
|
|
||||||
renderComponent();
|
|
||||||
});
|
|
||||||
expect(screen.queryAllByTestId('reported-post')[0]).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Reported icon is not visible to learner for post with reported comment', async () => {
|
|
||||||
await renderComponent();
|
|
||||||
expect(screen.queryByTestId('reported-post')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
53
src/discussions/learners/LearnersContentView.jsx
Normal file
53
src/discussions/learners/LearnersContentView.jsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import React, { useContext } from 'react';
|
||||||
|
|
||||||
|
import { useSelector } from 'react-redux';
|
||||||
|
import {
|
||||||
|
generatePath, Redirect, Route, Switch,
|
||||||
|
} from 'react-router-dom';
|
||||||
|
|
||||||
|
import { Spinner } from '@edx/paragon';
|
||||||
|
|
||||||
|
import { LearnerTabs, RequestStatus, Routes } from '../../data/constants';
|
||||||
|
import { DiscussionContext } from '../common/context';
|
||||||
|
import { learnersLoadingStatus } from './data/selectors';
|
||||||
|
import CommentsTabContent from './learner/CommentsTabContent';
|
||||||
|
import PostsTabContent from './learner/PostsTabContent';
|
||||||
|
|
||||||
|
function LearnersContentView() {
|
||||||
|
const { courseId, learnerUsername } = useContext(DiscussionContext);
|
||||||
|
const params = { courseId, learnerUsername };
|
||||||
|
const apiStatus = useSelector(learnersLoadingStatus());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="learner-content d-flex flex-column">
|
||||||
|
<Switch>
|
||||||
|
<Route path={Routes.LEARNERS.LEARNER} exact>
|
||||||
|
<Redirect to={generatePath(Routes.LEARNERS.TABS.posts, params)} />
|
||||||
|
</Route>
|
||||||
|
<Route
|
||||||
|
path={Routes.LEARNERS.TABS.posts}
|
||||||
|
component={PostsTabContent}
|
||||||
|
/>
|
||||||
|
<Route path={Routes.LEARNERS.TABS.responses}>
|
||||||
|
<CommentsTabContent tab={LearnerTabs.RESPONSES} />
|
||||||
|
</Route>
|
||||||
|
<Route path={Routes.LEARNERS.TABS.comments}>
|
||||||
|
<CommentsTabContent tab={LearnerTabs.COMMENTS} />
|
||||||
|
</Route>
|
||||||
|
</Switch>
|
||||||
|
|
||||||
|
{
|
||||||
|
apiStatus === RequestStatus.IN_PROGRESS && (
|
||||||
|
<div className="my-3 text-center">
|
||||||
|
<Spinner animation="border" className="mie-3" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
LearnersContentView.propTypes = {
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LearnersContentView;
|
||||||
176
src/discussions/learners/LearnersContentView.test.jsx
Normal file
176
src/discussions/learners/LearnersContentView.test.jsx
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { fireEvent, 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 { MemoryRouter, Route } from 'react-router';
|
||||||
|
import { Factory } from 'rosie';
|
||||||
|
|
||||||
|
import { initializeMockApp } from '@edx/frontend-platform';
|
||||||
|
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||||
|
import { AppProvider } from '@edx/frontend-platform/react';
|
||||||
|
|
||||||
|
import { LearnerTabs } from '../../data/constants';
|
||||||
|
import { initializeStore } from '../../store';
|
||||||
|
import { executeThunk } from '../../test-utils';
|
||||||
|
import { commentsApiUrl } from '../comments/data/api';
|
||||||
|
import { DiscussionContext } from '../common/context';
|
||||||
|
import DiscussionContent from '../discussions-home/DiscussionContent';
|
||||||
|
import { threadsApiUrl } from '../posts/data/api';
|
||||||
|
import { coursesApiUrl, userProfileApiUrl } from './data/api';
|
||||||
|
import { fetchLearners } from './data/thunks';
|
||||||
|
|
||||||
|
import '../comments/data/__factories__';
|
||||||
|
import '../posts/data/__factories__';
|
||||||
|
import './data/__factories__';
|
||||||
|
|
||||||
|
let store;
|
||||||
|
let axiosMock;
|
||||||
|
const courseId = 'course-v1:edX+TestX+Test_Course';
|
||||||
|
const testUsername = 'leaner-1';
|
||||||
|
|
||||||
|
function renderComponent(username = testUsername) {
|
||||||
|
return render(
|
||||||
|
<IntlProvider locale="en">
|
||||||
|
<AppProvider store={store}>
|
||||||
|
<DiscussionContext.Provider value={{ learnerUsername: username, courseId }}>
|
||||||
|
<MemoryRouter initialEntries={[`/${courseId}/learners/${username}/${LearnerTabs.POSTS}`]}>
|
||||||
|
<Route path="/:courseId/learners/:learnerUsername">
|
||||||
|
<DiscussionContent />
|
||||||
|
</Route>
|
||||||
|
</MemoryRouter>
|
||||||
|
</DiscussionContext.Provider>
|
||||||
|
</AppProvider>
|
||||||
|
</IntlProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('LearnersContentView', () => {
|
||||||
|
const learnerCount = 1;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
initializeMockApp({
|
||||||
|
authenticatedUser: {
|
||||||
|
userId: 3,
|
||||||
|
username: 'abc123',
|
||||||
|
administrator: true,
|
||||||
|
roles: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||||
|
store = initializeStore({});
|
||||||
|
Factory.resetAll();
|
||||||
|
|
||||||
|
axiosMock.onGet(`${coursesApiUrl}${courseId}/activity_stats/`)
|
||||||
|
.reply(
|
||||||
|
200,
|
||||||
|
Factory.build('learnersResult', {}, {
|
||||||
|
count: learnerCount,
|
||||||
|
pageSize: 5,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
axiosMock.onGet(`${userProfileApiUrl}?username=${testUsername}`)
|
||||||
|
.reply(
|
||||||
|
200,
|
||||||
|
Factory.build('learnersProfile', {}, {
|
||||||
|
username: [testUsername],
|
||||||
|
}).profiles,
|
||||||
|
);
|
||||||
|
await executeThunk(fetchLearners(courseId), store.dispatch, store.getState);
|
||||||
|
|
||||||
|
axiosMock.onGet(threadsApiUrl)
|
||||||
|
.reply(200, Factory.build('threadsResult', {}, {
|
||||||
|
topicId: undefined,
|
||||||
|
count: 6,
|
||||||
|
pageSize: 5,
|
||||||
|
}));
|
||||||
|
|
||||||
|
axiosMock.onGet(commentsApiUrl)
|
||||||
|
.reply(200, Factory.build('commentsResult', {}, {
|
||||||
|
count: 9,
|
||||||
|
pageSize: 8,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it loads the posts view by default', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
await renderComponent();
|
||||||
|
});
|
||||||
|
expect(screen.queryAllByTestId('post')).toHaveLength(5);
|
||||||
|
expect(screen.queryAllByText('This is Thread', { exact: false })).toHaveLength(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it renders all the comments WITHOUT parent id in responses tab', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
await renderComponent();
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByText('Responses', { exact: false }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.queryAllByText('comment number', { exact: false })).toHaveLength(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it renders all the comments with parent id in comments tab', async () => {
|
||||||
|
axiosMock.onGet(commentsApiUrl)
|
||||||
|
.reply(200, Factory.build('commentsResult', {}, {
|
||||||
|
count: 4,
|
||||||
|
parentId: 'test_parent_id',
|
||||||
|
}));
|
||||||
|
await act(async () => {
|
||||||
|
await renderComponent();
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByRole('link', { name: /Comments \d+/i }));
|
||||||
|
});
|
||||||
|
expect(screen.queryAllByText('comment number', { exact: false })).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it can switch back to the posts tab', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
await renderComponent();
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByRole('link', { name: /Responses \d+/i }));
|
||||||
|
});
|
||||||
|
expect(screen.queryAllByText('comment number', { exact: false })).toHaveLength(8);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByRole('link', { name: /Posts \d+/i }));
|
||||||
|
});
|
||||||
|
expect(screen.queryAllByTestId('post')).toHaveLength(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Posts Tab Button', () => {
|
||||||
|
it('does not show Report Icon when the learner has NO active flags', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
await renderComponent('leaner-2');
|
||||||
|
});
|
||||||
|
const button = screen.getByRole('link', { name: /Posts/i });
|
||||||
|
expect(button.innerHTML).not.toContain('svg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the Report Icon when the learner has active Flags', async () => {
|
||||||
|
axiosMock.onGet(`${coursesApiUrl}${courseId}/activity_stats/`)
|
||||||
|
.reply(() => [200, Factory.build('learnersResult', {}, {
|
||||||
|
count: 1,
|
||||||
|
pageSize: 5,
|
||||||
|
activeFlags: 1,
|
||||||
|
})]);
|
||||||
|
axiosMock.onGet(`${userProfileApiUrl}?username=leaner-2`)
|
||||||
|
.reply(() => [200, Factory.build('learnersProfile', {}, {
|
||||||
|
username: ['leaner-2'],
|
||||||
|
}).profiles]);
|
||||||
|
await executeThunk(fetchLearners(courseId), store.dispatch, store.getState);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await renderComponent('leaner-2');
|
||||||
|
});
|
||||||
|
const button = screen.getByRole('link', { name: /Posts/i });
|
||||||
|
expect(button.innerHTML).toContain('svg');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,70 +5,49 @@ import {
|
|||||||
Redirect, useLocation, useParams,
|
Redirect, useLocation, useParams,
|
||||||
} from 'react-router';
|
} from 'react-router';
|
||||||
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { Spinner } from '@edx/paragon';
|
||||||
import { Button, Spinner } from '@edx/paragon';
|
|
||||||
|
|
||||||
import SearchInfo from '../../components/SearchInfo';
|
import ScrollThreshold from '../../components/ScrollThreshold';
|
||||||
import { RequestStatus, Routes } from '../../data/constants';
|
import { RequestStatus, Routes } from '../../data/constants';
|
||||||
import { selectconfigLoadingStatus, selectLearnersTabEnabled } from '../data/selectors';
|
import { selectconfigLoadingStatus, selectLearnersTabEnabled } from '../data/selectors';
|
||||||
import NoResults from '../posts/NoResults';
|
|
||||||
import {
|
import {
|
||||||
learnersLoadingStatus,
|
learnersLoadingStatus,
|
||||||
selectAllLearners,
|
selectAllLearners,
|
||||||
selectLearnerNextPage,
|
selectLearnerNextPage,
|
||||||
selectLearnerSorting,
|
selectLearnerSorting,
|
||||||
selectUsernameSearch,
|
|
||||||
} from './data/selectors';
|
} from './data/selectors';
|
||||||
import { setUsernameSearch } from './data/slices';
|
|
||||||
import { fetchLearners } from './data/thunks';
|
import { fetchLearners } from './data/thunks';
|
||||||
import { LearnerCard, LearnerFilterBar } from './learner';
|
import { LearnerCard } from './learner';
|
||||||
import messages from './messages';
|
|
||||||
|
|
||||||
function LearnersView({ intl }) {
|
function LearnersView() {
|
||||||
const { courseId } = useParams();
|
const {
|
||||||
|
courseId,
|
||||||
|
} = useParams();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const orderBy = useSelector(selectLearnerSorting());
|
const orderBy = useSelector(selectLearnerSorting());
|
||||||
const nextPage = useSelector(selectLearnerNextPage());
|
const nextPage = useSelector(selectLearnerNextPage());
|
||||||
const loadingStatus = useSelector(learnersLoadingStatus());
|
const loadingStatus = useSelector(learnersLoadingStatus());
|
||||||
const usernameSearch = useSelector(selectUsernameSearch());
|
|
||||||
const courseConfigLoadingStatus = useSelector(selectconfigLoadingStatus);
|
const courseConfigLoadingStatus = useSelector(selectconfigLoadingStatus);
|
||||||
const learnersTabEnabled = useSelector(selectLearnersTabEnabled);
|
const learnersTabEnabled = useSelector(selectLearnersTabEnabled);
|
||||||
const learners = useSelector(selectAllLearners);
|
const learners = useSelector(selectAllLearners);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (learnersTabEnabled) {
|
if (learnersTabEnabled) {
|
||||||
if (usernameSearch) {
|
dispatch(fetchLearners(courseId, { orderBy }));
|
||||||
dispatch(fetchLearners(courseId, { orderBy, usernameSearch }));
|
|
||||||
} else {
|
|
||||||
dispatch(fetchLearners(courseId, { orderBy }));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [courseId, orderBy, learnersTabEnabled, usernameSearch]);
|
}, [courseId, orderBy, learnersTabEnabled]);
|
||||||
|
|
||||||
const loadPage = async () => {
|
const loadPage = async () => {
|
||||||
if (nextPage) {
|
if (nextPage) {
|
||||||
dispatch(fetchLearners(courseId, {
|
dispatch(fetchLearners(courseId, {
|
||||||
orderBy,
|
orderBy,
|
||||||
page: nextPage,
|
page: nextPage,
|
||||||
usernameSearch,
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="d-flex flex-column border-right border-light-400">
|
<div className="d-flex flex-column border-right border-light-300 h-100">
|
||||||
{!usernameSearch && <LearnerFilterBar /> }
|
<div className="list-group list-group-flush ">
|
||||||
<div className="border-bottom border-light-400" />
|
|
||||||
{usernameSearch && (
|
|
||||||
<SearchInfo
|
|
||||||
text={usernameSearch}
|
|
||||||
count={learners.length}
|
|
||||||
loadingStatus={loadingStatus}
|
|
||||||
onClear={() => dispatch(setUsernameSearch(''))}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div className="list-group list-group-flush learner" role="list">
|
|
||||||
{courseConfigLoadingStatus === RequestStatus.SUCCESSFUL && !learnersTabEnabled && (
|
{courseConfigLoadingStatus === RequestStatus.SUCCESSFUL && !learnersTabEnabled && (
|
||||||
<Redirect
|
<Redirect
|
||||||
to={{
|
to={{
|
||||||
@@ -77,31 +56,21 @@ function LearnersView({ intl }) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{courseConfigLoadingStatus === RequestStatus.SUCCESSFUL
|
{courseConfigLoadingStatus === RequestStatus.SUCCESSFUL && learnersTabEnabled && learners.map((learner) => (
|
||||||
&& learnersTabEnabled
|
<LearnerCard learner={learner} key={learner.username} courseId={courseId} />
|
||||||
&& learners.map((learner, index) => (
|
))}
|
||||||
// eslint-disable-next-line react/no-array-index-key
|
|
||||||
<LearnerCard learner={learner} key={index} courseId={courseId} />
|
|
||||||
))}
|
|
||||||
{loadingStatus === RequestStatus.IN_PROGRESS ? (
|
{loadingStatus === RequestStatus.IN_PROGRESS ? (
|
||||||
<div className="d-flex justify-content-center p-4">
|
<div className="d-flex justify-content-center p-4">
|
||||||
<Spinner animation="border" variant="primary" size="lg" />
|
<Spinner animation="border" variant="primary" size="lg" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
nextPage && loadingStatus === RequestStatus.SUCCESSFUL && (
|
nextPage && (
|
||||||
<Button onClick={() => loadPage()} variant="primary" size="md">
|
<ScrollThreshold onScroll={loadPage} />
|
||||||
{intl.formatMessage(messages.loadMore)}
|
|
||||||
</Button>
|
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
{ usernameSearch !== '' && learners.length === 0 && loadingStatus === RequestStatus.SUCCESSFUL && <NoResults />}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
LearnersView.propTypes = {
|
export default LearnersView;
|
||||||
intl: intlShape.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default injectIntl(LearnersView);
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user