Compare commits
3 Commits
inf-763
...
jawayria/n
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32a100ea41 | ||
|
|
876211388a | ||
|
|
c409514766 |
5
.env
5
.env
@@ -15,11 +15,8 @@ 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=''
|
|
||||||
TA_FEEDBACK_FORM= ''
|
|
||||||
STAFF_FEEDBACK_FORM= ''
|
|
||||||
DISPLAY_FEEDBACK_BANNER='false'
|
|
||||||
|
|||||||
@@ -16,11 +16,8 @@ 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'
|
|
||||||
|
|||||||
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: [
|
||||||
|
|||||||
24
.github/pull_request_template.md
vendored
24
.github/pull_request_template.md
vendored
@@ -1,24 +0,0 @@
|
|||||||
### Description
|
|
||||||
|
|
||||||
Include a description of your changes here, along with a link to any relevant Jira tickets and/or GitHub issues.
|
|
||||||
|
|
||||||
#### How Has This Been Tested?
|
|
||||||
|
|
||||||
Please describe in detail how you tested your changes.
|
|
||||||
|
|
||||||
#### Screenshots/sandbox (optional):
|
|
||||||
Include a link to the sandbox for design changes or screenshot for before and after. **Remove this section if it's not applicable.**
|
|
||||||
|
|
||||||
|Before|After|
|
|
||||||
|-------|-----|
|
|
||||||
| | |
|
|
||||||
|
|
||||||
#### Merge Checklist
|
|
||||||
|
|
||||||
* [ ] If your update includes visual changes, have they been reviewed by a designer? Send them a link to the Sandbox, if applicable.
|
|
||||||
* [ ] Is there adequate test coverage for your changes?
|
|
||||||
|
|
||||||
#### Post-merge Checklist
|
|
||||||
|
|
||||||
* [ ] Deploy the changes to prod after verifying on stage or ask **@openedx/edx-infinity** to do it.
|
|
||||||
* [ ] 🎉 🙌 Celebrate! Thanks for your contribution.
|
|
||||||
4
.github/workflows/ci.yml
vendored
4
.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
|
||||||
@@ -34,4 +34,4 @@ jobs:
|
|||||||
- name: i18n_extract
|
- name: i18n_extract
|
||||||
run: npm run i18n_extract
|
run: npm run i18n_extract
|
||||||
- name: Coverage
|
- name: Coverage
|
||||||
uses: codecov/codecov-action@v3
|
uses: codecov/codecov-action@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
|
|
||||||
27
Makefile
27
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,15 +38,15 @@ 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:
|
||||||
|
|||||||
55
README.rst
55
README.rst
@@ -1,19 +1,20 @@
|
|||||||
|
|Build Status| |Codecov| |license|
|
||||||
|
|
||||||
frontend-app-discussions
|
frontend-app-discussions
|
||||||
========================
|
========================
|
||||||
|
|
||||||
|Build Status| |Codecov| |license|
|
Please tag **@edx/fedx-team** on any PRs or issues. Thanks.
|
||||||
|
|
||||||
Purpose
|
Introduction
|
||||||
-------
|
------------
|
||||||
|
|
||||||
This repository is a React-based micro frontend for the Open edX discussion forums.
|
This repository is a React-based micro frontend for the Open edX discussion forums.
|
||||||
|
|
||||||
Getting Started
|
**Installation and Startup**
|
||||||
---------------
|
|
||||||
|
|
||||||
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:
|
||||||
|
|
||||||
@@ -25,48 +26,10 @@ Getting Started
|
|||||||
|
|
||||||
The dev server is running at `http://localhost:2002 <http://localhost:2002>`_.
|
The dev server is running at `http://localhost:2002 <http://localhost:2002>`_.
|
||||||
|
|
||||||
Getting Help
|
|
||||||
------------
|
|
||||||
Please tag **@openedx/edx-infinity ** on any PRs or issues. Thanks.
|
|
||||||
|
|
||||||
If you're having trouble, we have discussion forums at https://discuss.openedx.org where you can connect with others in the community.
|
|
||||||
For anything non-trivial, the best path is to open an issue in this repository with as many details about the issue you are facing as you can provide.
|
|
||||||
|
|
||||||
https://github.com/openedx/frontend-app-discussions/issues
|
|
||||||
|
|
||||||
For more information about these options, see the `Getting Help`_ page.
|
|
||||||
|
|
||||||
.. _Getting Help: https://openedx.org/getting-help
|
|
||||||
|
|
||||||
How to Contribute
|
|
||||||
-----------------
|
|
||||||
Details about how to become a contributor to the Open edX project may be found in the wiki at `How to contribute`_
|
|
||||||
|
|
||||||
.. _How to contribute: https://edx.readthedocs.io/projects/edx-developer-guide/en/latest/process/index.html
|
|
||||||
|
|
||||||
PR description template should be automatically applied if you are sending PR from github interface; otherwise you
|
|
||||||
can find it it at `PULL_REQUEST_TEMPLATE.md <https://github.com/openedx/frontend-app-discussions/blob/master/.github/pull_request_template.md>`_
|
|
||||||
|
|
||||||
This project is currently accepting all types of contributions, bug fixes and security fixes
|
|
||||||
|
|
||||||
The Open edX Code of Conduct
|
|
||||||
----------------------------
|
|
||||||
All community members should familiarize themselves with the `Open edX Code of Conduct`_.
|
|
||||||
|
|
||||||
.. _Open edX Code of Conduct: https://openedx.org/code-of-conduct/
|
|
||||||
|
|
||||||
People
|
|
||||||
------
|
|
||||||
The assigned maintainers for this component and other project details may be found in Backstage or from inspecting catalog-info.yaml.
|
|
||||||
|
|
||||||
Reporting Security Issues
|
|
||||||
-------------------------
|
|
||||||
Please do not report security issues in public. Please email security@edx.org.
|
|
||||||
|
|
||||||
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
|
||||||
-------------------
|
-------------------
|
||||||
@@ -78,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
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
# This file records information about this repo. Its use is described in OEP-55:
|
|
||||||
# https://open-edx-proposals.readthedocs.io/en/latest/processes/oep-0055-proc-project-maintainers.html
|
|
||||||
|
|
||||||
apiVersion: backstage.io/v1alpha1
|
|
||||||
kind: Component
|
|
||||||
metadata:
|
|
||||||
name: 'frontend-app-discussions'
|
|
||||||
description: "The discussion forum for openEdx discussions"
|
|
||||||
links:
|
|
||||||
- url: "https://github.com/openedx/frontend-app-discussions"
|
|
||||||
title: "Frontend app discussions"
|
|
||||||
icon: "Web"
|
|
||||||
annotations:
|
|
||||||
openedx.org/arch-interest-groups: ""
|
|
||||||
spec:
|
|
||||||
owner: group:edx-infinity
|
|
||||||
type: 'website'
|
|
||||||
lifecycle: 'production'
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ 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',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -8,4 +8,5 @@ openedx-release:
|
|||||||
# The openedx-release key is described in OEP-10:
|
# The openedx-release key is described in OEP-10:
|
||||||
# https://open-edx-proposals.readthedocs.io/en/latest/oep-0010-proc-openedx-releases.html
|
# https://open-edx-proposals.readthedocs.io/en/latest/oep-0010-proc-openedx-releases.html
|
||||||
# The FAQ might also be helpful: https://openedx.atlassian.net/wiki/spaces/COMM/pages/1331268879/Open+edX+Release+FAQ
|
# The FAQ might also be helpful: https://openedx.atlassian.net/wiki/spaces/COMM/pages/1331268879/Open+edX+Release+FAQ
|
||||||
|
maybe: true # Delete this "maybe" line when you have decided about Open edX inclusion.
|
||||||
ref: master
|
ref: master
|
||||||
|
|||||||
37514
package-lock.json
generated
37514
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
25
package.json
25
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,25 +27,22 @@
|
|||||||
},
|
},
|
||||||
"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",
|
||||||
"classnames": "2.3.1",
|
"classnames": "2.3.1",
|
||||||
"core-js": "3.21.1",
|
"core-js": "3.21.1",
|
||||||
"dompurify": "^2.4.3",
|
|
||||||
"formik": "2.2.9",
|
"formik": "2.2.9",
|
||||||
"lodash.snakecase": "4.1.1",
|
"lodash.snakecase": "4.1.1",
|
||||||
"prop-types": "15.8.1",
|
"prop-types": "15.8.1",
|
||||||
@@ -60,18 +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",
|
||||||
|
"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,53 +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"
|
|
||||||
/>
|
|
||||||
<script>
|
|
||||||
window.MathJax = {
|
|
||||||
tex: {
|
|
||||||
inlineMath: [
|
|
||||||
['$', '$'],
|
|
||||||
['\\\\(', '\\\\)'],
|
|
||||||
['\\(', '\\)'],
|
|
||||||
['[mathjaxinline]', '[/mathjaxinline]'],
|
|
||||||
['\\begin{math}', '\\end{math}'],
|
|
||||||
],
|
|
||||||
displayMath: [
|
|
||||||
['[mathjax]', '[/mathjax]'],
|
|
||||||
['$$', '$$'],
|
|
||||||
['\\\\[', '\\\\]'],
|
|
||||||
['\\[', '\\]'],
|
|
||||||
['\\begin{displaymath}', '\\end{displaymath}'],
|
|
||||||
['\\begin{equation}', '\\end{equation}'],
|
|
||||||
],
|
|
||||||
processEscapes: true,
|
|
||||||
processEnvironments: true,
|
|
||||||
autoload: {
|
|
||||||
color: [],
|
|
||||||
colorv2: ['color']
|
|
||||||
},
|
|
||||||
packages: {'[+]': ['noerrors']}
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
ignoreHtmlClass: 'tex2jax_ignore',
|
|
||||||
processHtmlClass: 'tex2jax_process'
|
|
||||||
},
|
|
||||||
loader: {
|
|
||||||
load: ['input/asciimath', '[tex]/noerrors']
|
|
||||||
}
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
<script async src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js" id="MathJax-script"></script>
|
|
||||||
|
|
||||||
</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,200 +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.postType,
|
|
||||||
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
|
|
||||||
key={value.name}
|
|
||||||
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
|
|
||||||
key={element.id}
|
|
||||||
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,57 +0,0 @@
|
|||||||
import React, { useEffect, useRef } from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
|
|
||||||
import DOMPurify from 'dompurify';
|
|
||||||
|
|
||||||
import { logError } from '@edx/frontend-platform/logging';
|
|
||||||
|
|
||||||
import { useDebounce } from '../discussions/data/hooks';
|
|
||||||
|
|
||||||
const defaultSanitizeOptions = {
|
|
||||||
USE_PROFILES: { html: true },
|
|
||||||
ADD_ATTR: ['columnalign'],
|
|
||||||
};
|
|
||||||
|
|
||||||
function HTMLLoader({
|
|
||||||
htmlNode, componentId, cssClassName, testId,
|
|
||||||
}) {
|
|
||||||
const sanitizedMath = DOMPurify.sanitize(htmlNode, { ...defaultSanitizeOptions });
|
|
||||||
const previewRef = useRef();
|
|
||||||
|
|
||||||
const debouncedPostContent = useDebounce(htmlNode, 500);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let promise = Promise.resolve(); // Used to hold chain of typesetting calls
|
|
||||||
function typeset(code) {
|
|
||||||
promise = promise.then(() => window.MathJax?.typesetPromise(code()))
|
|
||||||
.catch((err) => logError(`Typeset failed: ${err.message}`));
|
|
||||||
return promise;
|
|
||||||
}
|
|
||||||
if (debouncedPostContent) {
|
|
||||||
typeset(() => {
|
|
||||||
previewRef.current.innerHTML = sanitizedMath;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [debouncedPostContent]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={previewRef} className={cssClassName} id={componentId} data-testid={testId} />
|
|
||||||
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
HTMLLoader.propTypes = {
|
|
||||||
htmlNode: PropTypes.node,
|
|
||||||
componentId: PropTypes.string,
|
|
||||||
cssClassName: PropTypes.string,
|
|
||||||
testId: PropTypes.string,
|
|
||||||
};
|
|
||||||
|
|
||||||
HTMLLoader.defaultProps = {
|
|
||||||
htmlNode: '',
|
|
||||||
componentId: null,
|
|
||||||
cssClassName: '',
|
|
||||||
testId: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
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 { getApiBaseUrl } 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 = `${getApiBaseUrl()}/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,3 +0,0 @@
|
|||||||
/* eslint-disable import/prefer-default-export */
|
|
||||||
|
|
||||||
export const selectCourseTabs = state => state.courseTabs;
|
|
||||||
@@ -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" componentId="post-preview" testId="post-preview" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="d-flex justify-content-end">
|
|
||||||
{!showPreviewPane && (
|
|
||||||
<Button
|
|
||||||
variant="link"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setShowPreviewPane(true)}
|
|
||||||
className={`text-primary-500 font-style p-0 ${editExisting && 'mb-4.5'}`}
|
|
||||||
style={{ lineHeight: '26px' }}
|
|
||||||
>
|
|
||||||
{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,17 +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';
|
|
||||||
import 'tinymce/plugins/paste';
|
|
||||||
/* 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';
|
||||||
@@ -41,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',
|
||||||
@@ -57,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 });
|
||||||
@@ -95,57 +72,30 @@ 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,
|
||||||
paste_data_images: false,
|
browser_spellcheck: true,
|
||||||
contextmenu: false,
|
a11y_advanced_options: true,
|
||||||
browser_spellcheck: true,
|
autosave_interval: '1s',
|
||||||
a11y_advanced_options: true,
|
autosave_restore_when_empty: true,
|
||||||
autosave_interval: '1s',
|
plugins: 'autosave codesample link lists image imagetools code',
|
||||||
autosave_restore_when_empty: false,
|
toolbar: 'formatselect | bold italic underline'
|
||||||
plugins: 'autoresize autosave codesample link lists image imagetools code emoticons charmap paste',
|
+ ' | link blockquote openedx_code image'
|
||||||
toolbar: 'undo redo'
|
+ ' | bullist numlist outdent indent'
|
||||||
+ ' | formatselect | bold italic underline'
|
+ ' | removeformat'
|
||||||
+ ' | link blockquote openedx_code image'
|
+ ' | openedx_html'
|
||||||
+ ' | bullist numlist outdent indent'
|
+ ' | undo redo',
|
||||||
+ ' | removeformat'
|
content_css: false,
|
||||||
+ ' | openedx_html'
|
content_style: contentStyle,
|
||||||
+ ' | emoticons'
|
body_class: 'm-2',
|
||||||
+ ' | charmap',
|
images_upload_handler: uploadHandler,
|
||||||
content_css: false,
|
setup,
|
||||||
content_style: contentStyle,
|
}}
|
||||||
body_class: 'm-2 text-editor',
|
{...props}
|
||||||
convert_urls: false,
|
/>
|
||||||
relative_urls: false,
|
|
||||||
default_link_target: '_blank',
|
|
||||||
target_list: false,
|
|
||||||
images_upload_handler: uploadHandler,
|
|
||||||
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,108 +0,0 @@
|
|||||||
/* eslint react/prop-types: 0 */
|
|
||||||
import React from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
|
|
||||||
import { useSelector } from 'react-redux';
|
|
||||||
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
|
||||||
import { Icon, OverlayTrigger, Tooltip } from '@edx/paragon';
|
|
||||||
import { HelpOutline, PostOutline, Report } from '@edx/paragon/icons';
|
|
||||||
|
|
||||||
import {
|
|
||||||
selectUserHasModerationPrivileges,
|
|
||||||
selectUserIsGroupTa,
|
|
||||||
} from '../discussions/data/selectors';
|
|
||||||
import messages from '../discussions/in-context-topics/messages';
|
|
||||||
|
|
||||||
function TopicStats({
|
|
||||||
threadCounts,
|
|
||||||
activeFlags,
|
|
||||||
inactiveFlags,
|
|
||||||
intl,
|
|
||||||
}) {
|
|
||||||
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
|
|
||||||
const userIsGroupTa = useSelector(selectUserIsGroupTa);
|
|
||||||
const canSeeReportedStats = (activeFlags || inactiveFlags) && (userHasModerationPrivileges || userIsGroupTa);
|
|
||||||
return (
|
|
||||||
<div className="d-flex align-items-center mt-2.5" style={{ marginBottom: '2px' }}>
|
|
||||||
<OverlayTrigger
|
|
||||||
overlay={(
|
|
||||||
<Tooltip>
|
|
||||||
<div className="d-flex flex-column align-items-start">
|
|
||||||
{intl.formatMessage(messages.discussions, {
|
|
||||||
count: threadCounts?.discussion || 0,
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="d-flex align-items-center mr-3.5">
|
|
||||||
<Icon src={PostOutline} className="icon-size mr-2" />
|
|
||||||
{threadCounts?.discussion || 0}
|
|
||||||
</div>
|
|
||||||
</OverlayTrigger>
|
|
||||||
<OverlayTrigger
|
|
||||||
overlay={(
|
|
||||||
<Tooltip>
|
|
||||||
<div className="d-flex flex-column align-items-start">
|
|
||||||
{intl.formatMessage(messages.questions, {
|
|
||||||
count: threadCounts?.question || 0,
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="d-flex align-items-center mr-3.5">
|
|
||||||
<Icon src={HelpOutline} className="icon-size mr-2" />
|
|
||||||
{threadCounts?.question || 0}
|
|
||||||
</div>
|
|
||||||
</OverlayTrigger>
|
|
||||||
{Boolean(canSeeReportedStats) && (
|
|
||||||
<OverlayTrigger
|
|
||||||
overlay={(
|
|
||||||
<Tooltip>
|
|
||||||
<div className="d-flex flex-column align-items-start">
|
|
||||||
{Boolean(activeFlags) && (
|
|
||||||
<span>
|
|
||||||
{intl.formatMessage(messages.reported, { reported: activeFlags })}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{Boolean(inactiveFlags) && (
|
|
||||||
<span>
|
|
||||||
{intl.formatMessage(messages.previouslyReported, { previouslyReported: inactiveFlags })}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="d-flex align-items-center">
|
|
||||||
<Icon src={Report} className="icon-size mr-2 text-danger" />
|
|
||||||
{activeFlags}{Boolean(inactiveFlags) && `/${inactiveFlags}`}
|
|
||||||
</div>
|
|
||||||
</OverlayTrigger>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
TopicStats.propTypes = {
|
|
||||||
threadCounts: PropTypes.shape({
|
|
||||||
discussions: PropTypes.number,
|
|
||||||
questions: PropTypes.number,
|
|
||||||
}),
|
|
||||||
activeFlags: PropTypes.number,
|
|
||||||
inactiveFlags: PropTypes.number,
|
|
||||||
intl: intlShape.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
TopicStats.defaultProps = {
|
|
||||||
threadCounts: {
|
|
||||||
discussions: 0,
|
|
||||||
questions: 0,
|
|
||||||
},
|
|
||||||
activeFlags: null,
|
|
||||||
inactiveFlags: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default injectIntl(TopicStats);
|
|
||||||
@@ -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,4 +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';
|
||||||
export { default as TopicStats } from './TopicStats';
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
/* eslint-disable import/prefer-default-export */
|
/* eslint-disable import/prefer-default-export */
|
||||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||||
|
|
||||||
import { getApiBaseUrl } from './constants';
|
import { API_BASE_URL } from './constants';
|
||||||
|
|
||||||
export const getBlocksAPIURL = () => `${getApiBaseUrl()}/api/courses/v1/blocks/`;
|
export const blocksAPIURL = `${API_BASE_URL}/api/courses/v1/blocks/`;
|
||||||
export async function getCourseBlocks(courseId, username) {
|
export async function getCourseBlocks(courseId, username) {
|
||||||
const params = {
|
const params = {
|
||||||
course_id: courseId,
|
course_id: courseId,
|
||||||
@@ -14,6 +14,6 @@ export async function getCourseBlocks(courseId, username) {
|
|||||||
student_view_data: 'discussion',
|
student_view_data: 'discussion',
|
||||||
};
|
};
|
||||||
const { data } = await getAuthenticatedHttpClient()
|
const { data } = await getAuthenticatedHttpClient()
|
||||||
.get(getBlocksAPIURL(), { params });
|
.get(blocksAPIURL, { params });
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { getConfig } from '@edx/frontend-platform';
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
|
|
||||||
export const getApiBaseUrl = () => getConfig().LMS_BASE_URL;
|
export const API_BASE_URL = getConfig().LMS_BASE_URL;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enum for thread types.
|
* Enum for thread types.
|
||||||
@@ -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',
|
||||||
@@ -63,7 +62,6 @@ export const ContentActions = {
|
|||||||
* @enum {string}
|
* @enum {string}
|
||||||
*/
|
*/
|
||||||
export const RequestStatus = {
|
export const RequestStatus = {
|
||||||
IDLE: 'idle',
|
|
||||||
IN_PROGRESS: 'in-progress',
|
IN_PROGRESS: 'in-progress',
|
||||||
SUCCESSFUL: 'successful',
|
SUCCESSFUL: 'successful',
|
||||||
FAILED: 'failed',
|
FAILED: 'failed',
|
||||||
@@ -75,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',
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -91,6 +89,16 @@ export const ThreadOrdering = {
|
|||||||
BY_VOTE_COUNT: 'voteCount',
|
BY_VOTE_COUNT: 'voteCount',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enum for thread view status filtering.
|
||||||
|
* @readonly
|
||||||
|
* @enum {string}
|
||||||
|
*/
|
||||||
|
export const ThreadViewStatus = {
|
||||||
|
UNREAD: 'unread',
|
||||||
|
UNANSWERED: 'unanswered',
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enum for filtering posts by status.
|
* Enum for filtering posts by status.
|
||||||
* @readonly
|
* @readonly
|
||||||
@@ -102,7 +110,6 @@ export const PostsStatusFilter = {
|
|||||||
FOLLOWING: 'statusFollowing',
|
FOLLOWING: 'statusFollowing',
|
||||||
REPORTED: 'statusReported',
|
REPORTED: 'statusReported',
|
||||||
UNANSWERED: 'statusUnanswered',
|
UNANSWERED: 'statusUnanswered',
|
||||||
UNRESPONDED: 'statusUnresponded',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -125,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',
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -145,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`,
|
||||||
@@ -157,59 +179,38 @@ export const Routes = {
|
|||||||
`${BASE_PATH}`,
|
`${BASE_PATH}`,
|
||||||
],
|
],
|
||||||
EDIT_POST: [
|
EDIT_POST: [
|
||||||
`${BASE_PATH}/category/:category/posts/:postId/edit`,
|
|
||||||
`${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: {
|
||||||
PATH: [
|
PATH: [
|
||||||
`${BASE_PATH}/category/:category/posts/:postId`,
|
|
||||||
`${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: {
|
||||||
category: `${BASE_PATH}/category/:category/posts/:postId`,
|
|
||||||
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: {
|
||||||
PATH: [
|
PATH: [
|
||||||
`${BASE_PATH}/topics/:topicId?`,
|
`${BASE_PATH}/topics/:topicId?`,
|
||||||
`${BASE_PATH}/category/:category`,
|
|
||||||
`${BASE_PATH}/topics`,
|
|
||||||
],
|
],
|
||||||
ALL: `${BASE_PATH}/topics`,
|
ALL: `${BASE_PATH}/topics`,
|
||||||
CATEGORY: `${BASE_PATH}/category/:category`,
|
CATEGORY: `${BASE_PATH}/category/:category`,
|
||||||
CATEGORY_POST: `${BASE_PATH}/category/:category/posts/:postId`,
|
|
||||||
TOPIC: `${BASE_PATH}/topics/:topicId`,
|
TOPIC: `${BASE_PATH}/topics/:topicId`,
|
||||||
TOPIC_POST: `${BASE_PATH}/topics/:topicId/posts/:postId`,
|
|
||||||
TOPIC_POST_EDIT: `${BASE_PATH}/topics/:topicId/posts/:postId/edit`,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
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])
|
||||||
.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;
|
|
||||||
|
|||||||
@@ -7,11 +7,9 @@ import { initializeMockApp } from '@edx/frontend-platform/testing';
|
|||||||
import { initializeStore } from '../store';
|
import { initializeStore } from '../store';
|
||||||
import { executeThunk } from '../test-utils';
|
import { executeThunk } from '../test-utils';
|
||||||
import { getBlocksAPIResponse } from './__factories__';
|
import { getBlocksAPIResponse } from './__factories__';
|
||||||
import { getBlocksAPIURL } from './api';
|
import { blocksAPIURL } from './api';
|
||||||
import { RequestStatus } from './constants';
|
|
||||||
import { fetchCourseBlocks } from './thunks';
|
import { fetchCourseBlocks } from './thunks';
|
||||||
|
|
||||||
const blocksAPIURL = getBlocksAPIURL();
|
|
||||||
const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||||
|
|
||||||
let axiosMock;
|
let axiosMock;
|
||||||
@@ -37,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());
|
||||||
|
|
||||||
@@ -79,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,
|
||||||
@@ -38,10 +26,10 @@ export const selectTopicsUnderCategory = createSelector(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const selectArchivedTopics = createSelector(
|
export const selectSequences = createSelector(
|
||||||
state => state.topics.topics,
|
selectChapters,
|
||||||
state => state.topics.archivedIds || [],
|
selectBlocks,
|
||||||
(topics, ids) => ids.map(id => topics[id]),
|
(chapterIds, blocks) => chapterIds?.flatMap(cId => blocks[cId].children.map(seqId => blocks[seqId])) || [],
|
||||||
);
|
);
|
||||||
|
|
||||||
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,
|
};
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ ensureConfig([
|
|||||||
'LMS_BASE_URL',
|
'LMS_BASE_URL',
|
||||||
], 'Comments API service');
|
], 'Comments API service');
|
||||||
|
|
||||||
export const getCohortsApiUrl = courseId => `${getConfig().LMS_BASE_URL}/api/cohorts/v1/courses/${courseId}/cohorts/`;
|
const apiBaseUrl = getConfig().LMS_BASE_URL;
|
||||||
|
|
||||||
|
export const getCohortsApiUrl = courseId => `${apiBaseUrl}/api/cohorts/v1/courses/${courseId}/cohorts/`;
|
||||||
|
|
||||||
export async function getCourseCohorts(courseId) {
|
export async function getCourseCohorts(courseId) {
|
||||||
const params = snakeCaseObject({ courseId });
|
const params = snakeCaseObject({ courseId });
|
||||||
|
|||||||
162
src/discussions/comments/CommentsView.jsx
Normal file
162
src/discussions/comments/CommentsView.jsx
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
import { useParams } from 'react-router';
|
||||||
|
|
||||||
|
import { ensureConfig, getConfig } from '@edx/frontend-platform';
|
||||||
|
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||||
|
import { Button, Spinner } from '@edx/paragon';
|
||||||
|
|
||||||
|
import { EndorsementStatus, ThreadType } from '../../data/constants';
|
||||||
|
import { useDispatchWithState } from '../../data/hooks';
|
||||||
|
import { Post } from '../posts';
|
||||||
|
import { selectThread } from '../posts/data/selectors';
|
||||||
|
import { markThreadAsRead } from '../posts/data/thunks';
|
||||||
|
import { selectThreadComments, selectThreadCurrentPage, selectThreadHasMorePages } from './data/selectors';
|
||||||
|
import { fetchThreadComments } from './data/thunks';
|
||||||
|
import { Comment, ResponseEditor } from './comment';
|
||||||
|
import messages from './messages';
|
||||||
|
|
||||||
|
ensureConfig(['POST_MARK_AS_READ_DELAY'], 'Comment thread view');
|
||||||
|
|
||||||
|
function usePost(postId) {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const thread = useSelector(selectThread(postId));
|
||||||
|
useEffect(() => {
|
||||||
|
const markReadTimer = setTimeout(() => {
|
||||||
|
if (thread && !thread.read) {
|
||||||
|
dispatch(markThreadAsRead(postId));
|
||||||
|
}
|
||||||
|
}, getConfig().POST_MARK_AS_READ_DELAY);
|
||||||
|
return () => {
|
||||||
|
clearTimeout(markReadTimer);
|
||||||
|
};
|
||||||
|
}, [postId]);
|
||||||
|
return thread;
|
||||||
|
}
|
||||||
|
|
||||||
|
function usePostComments(postId, endorsed = null) {
|
||||||
|
const [isLoading, dispatch] = useDispatchWithState();
|
||||||
|
const comments = useSelector(selectThreadComments(postId, endorsed));
|
||||||
|
const hasMorePages = useSelector(selectThreadHasMorePages(postId, endorsed));
|
||||||
|
const currentPage = useSelector(selectThreadCurrentPage(postId, endorsed));
|
||||||
|
const handleLoadMoreResponses = async () => dispatch(fetchThreadComments(postId, {
|
||||||
|
endorsed,
|
||||||
|
page: currentPage + 1,
|
||||||
|
}));
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(fetchThreadComments(postId, {
|
||||||
|
endorsed,
|
||||||
|
page: 1,
|
||||||
|
}));
|
||||||
|
}, [postId]);
|
||||||
|
return {
|
||||||
|
comments,
|
||||||
|
hasMorePages,
|
||||||
|
isLoading,
|
||||||
|
handleLoadMoreResponses,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function DiscussionCommentsView({
|
||||||
|
postType,
|
||||||
|
postId,
|
||||||
|
intl,
|
||||||
|
endorsed,
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
comments,
|
||||||
|
hasMorePages,
|
||||||
|
isLoading,
|
||||||
|
handleLoadMoreResponses,
|
||||||
|
} = usePostComments(postId, endorsed);
|
||||||
|
return (
|
||||||
|
<div className="m-3">
|
||||||
|
<div className="my-3">
|
||||||
|
{endorsed === EndorsementStatus.ENDORSED
|
||||||
|
? intl.formatMessage(messages.endorsedResponseCount, { num: comments.length })
|
||||||
|
: intl.formatMessage(messages.responseCount, { num: comments.length })}
|
||||||
|
</div>
|
||||||
|
{comments.map(comment => (
|
||||||
|
<Comment comment={comment} key={comment.id} postType={postType} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{hasMorePages && !isLoading && (
|
||||||
|
<Button
|
||||||
|
onClick={handleLoadMoreResponses}
|
||||||
|
variant="link"
|
||||||
|
block="true"
|
||||||
|
className="card p-4"
|
||||||
|
data-testid="load-more-comments"
|
||||||
|
>
|
||||||
|
{intl.formatMessage(messages.loadMoreResponses)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{isLoading
|
||||||
|
&& (
|
||||||
|
<div className="card my-4 p-4 d-flex align-items-center">
|
||||||
|
<Spinner animation="border" variant="primary" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
DiscussionCommentsView.propTypes = {
|
||||||
|
postId: PropTypes.string.isRequired,
|
||||||
|
postType: PropTypes.string.isRequired,
|
||||||
|
intl: intlShape.isRequired,
|
||||||
|
endorsed: PropTypes.oneOf([
|
||||||
|
EndorsementStatus.ENDORSED, EndorsementStatus.UNENDORSED, EndorsementStatus.DISCUSSION,
|
||||||
|
]).isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
function CommentsView({ intl }) {
|
||||||
|
const { postId } = useParams();
|
||||||
|
const thread = usePost(postId);
|
||||||
|
if (!thread) {
|
||||||
|
return (
|
||||||
|
<Spinner animation="border" variant="primary" data-testid="loading-indicator" />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="discussion-comments d-flex flex-column mt-3 mb-0 mx-3 p-4 card">
|
||||||
|
<Post post={thread} />
|
||||||
|
<ResponseEditor postId={postId} />
|
||||||
|
</div>
|
||||||
|
{thread.type === ThreadType.DISCUSSION
|
||||||
|
&& (
|
||||||
|
<DiscussionCommentsView
|
||||||
|
postId={postId}
|
||||||
|
intl={intl}
|
||||||
|
postType={thread.type}
|
||||||
|
endorsed={EndorsementStatus.DISCUSSION}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{thread.type === ThreadType.QUESTION && (
|
||||||
|
<>
|
||||||
|
<DiscussionCommentsView
|
||||||
|
postId={postId}
|
||||||
|
intl={intl}
|
||||||
|
postType={thread.type}
|
||||||
|
endorsed={EndorsementStatus.ENDORSED}
|
||||||
|
/>
|
||||||
|
<DiscussionCommentsView
|
||||||
|
postId={postId}
|
||||||
|
intl={intl}
|
||||||
|
postType={thread.type}
|
||||||
|
endorsed={EndorsementStatus.UNENDORSED}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
CommentsView.propTypes = {
|
||||||
|
intl: intlShape.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default injectIntl(CommentsView);
|
||||||
@@ -12,30 +12,23 @@ 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 { getCourseConfigApiUrl } 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';
|
||||||
import { getThreadsApiUrl } from '../posts/data/api';
|
import { threadsApiUrl } from '../posts/data/api';
|
||||||
import { fetchThreads } from '../posts/data/thunks';
|
import { fetchThreads } from '../posts/data/thunks';
|
||||||
import { getCommentsApiUrl } from './data/api';
|
import { commentsApiUrl } from './data/api';
|
||||||
import { removeComment } from './data/thunks';
|
|
||||||
|
|
||||||
import '../posts/data/__factories__';
|
import '../posts/data/__factories__';
|
||||||
import './data/__factories__';
|
import './data/__factories__';
|
||||||
|
|
||||||
const courseConfigApiUrl = getCourseConfigApiUrl();
|
|
||||||
const commentsApiUrl = getCommentsApiUrl();
|
|
||||||
const threadsApiUrl = getThreadsApiUrl();
|
|
||||||
const discussionPostId = 'thread-1';
|
const discussionPostId = 'thread-1';
|
||||||
const questionPostId = 'thread-2';
|
const questionPostId = 'thread-2';
|
||||||
const closedPostId = 'thread-2';
|
const closedPostId = 'thread-2';
|
||||||
const courseId = 'course-v1:edX+TestX+Test_Course';
|
const courseId = 'course-v1:edX+TestX+Test_Course';
|
||||||
const reverseOrder = false;
|
|
||||||
let store;
|
let store;
|
||||||
let axiosMock;
|
let axiosMock;
|
||||||
let testLocation;
|
let testLocation;
|
||||||
let container;
|
|
||||||
|
|
||||||
function mockAxiosReturnPagedComments() {
|
function mockAxiosReturnPagedComments() {
|
||||||
[null, false, true].forEach(endorsed => {
|
[null, false, true].forEach(endorsed => {
|
||||||
@@ -49,7 +42,6 @@ function mockAxiosReturnPagedComments() {
|
|||||||
page_size: undefined,
|
page_size: undefined,
|
||||||
requested_fields: 'profile_image',
|
requested_fields: 'profile_image',
|
||||||
endorsed,
|
endorsed,
|
||||||
reverse_order: reverseOrder,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.reply(200, Factory.build('commentsResult', { can_delete: true }, {
|
.reply(200, Factory.build('commentsResult', { can_delete: true }, {
|
||||||
@@ -86,31 +78,26 @@ function mockAxiosReturnPagedCommentsResponses() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderComponent(postId) {
|
function renderComponent(postId) {
|
||||||
const wrapper = 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>,
|
||||||
);
|
);
|
||||||
container = wrapper.container;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('ThreadView', () => {
|
describe('CommentsView', () => {
|
||||||
beforeEach(() => {
|
beforeEach(async () => {
|
||||||
initializeMockApp({
|
initializeMockApp({
|
||||||
authenticatedUser: {
|
authenticatedUser: {
|
||||||
userId: 3,
|
userId: 3,
|
||||||
@@ -155,7 +142,7 @@ describe('ThreadView', () => {
|
|||||||
)];
|
)];
|
||||||
});
|
});
|
||||||
|
|
||||||
executeThunk(fetchThreads(courseId), store.dispatch, store.getState);
|
await executeThunk(fetchThreads(courseId), store.dispatch, store.getState);
|
||||||
mockAxiosReturnPagedComments();
|
mockAxiosReturnPagedComments();
|
||||||
mockAxiosReturnPagedCommentsResponses();
|
mockAxiosReturnPagedCommentsResponses();
|
||||||
});
|
});
|
||||||
@@ -167,12 +154,10 @@ describe('ThreadView', () => {
|
|||||||
|
|
||||||
it('should show and hide the editor', async () => {
|
it('should show and hide the editor', async () => {
|
||||||
renderComponent(discussionPostId);
|
renderComponent(discussionPostId);
|
||||||
const post = screen.getByTestId('post-thread-1');
|
await waitFor(() => screen.findByText('comment number 1', { exact: false }));
|
||||||
const hoverCard = within(post).getByTestId('hover-card-thread-1');
|
|
||||||
const addResponseButton = within(hoverCard).getByRole('button', { name: /Add response/i });
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
addResponseButton,
|
screen.getByRole('button', { name: /add a response/i }),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
expect(screen.queryByTestId('tinymce-editor')).toBeInTheDocument();
|
expect(screen.queryByTestId('tinymce-editor')).toBeInTheDocument();
|
||||||
@@ -181,72 +166,53 @@ describe('ThreadView', () => {
|
|||||||
});
|
});
|
||||||
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);
|
||||||
const post = await screen.findByTestId('post-thread-1');
|
await waitFor(() => screen.findByText('comment number 1', { exact: false }));
|
||||||
const hoverCard = within(post).getByTestId('hover-card-thread-1');
|
|
||||||
const addResponseButton = within(hoverCard).getByRole('button', { name: /Add response/i });
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
addResponseButton,
|
screen.getByRole('button', { name: /add a response/i }),
|
||||||
);
|
|
||||||
});
|
|
||||||
await act(() => {
|
|
||||||
fireEvent.change(screen.getByTestId('tinymce-editor'), { target: { value: 'testing123' } });
|
|
||||||
});
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
fireEvent.click(
|
|
||||||
screen.getByText(/submit/i),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
expect(screen.queryByTestId('tinymce-editor')).not.toBeInTheDocument();
|
|
||||||
await waitFor(async () => expect(await screen.findByTestId('comment-1')).toBeInTheDocument());
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not allow posting a response on a closed post', async () => {
|
|
||||||
renderComponent(closedPostId);
|
|
||||||
const post = screen.getByTestId('post-thread-2');
|
|
||||||
const hoverCard = within(post).getByTestId('hover-card-thread-2');
|
|
||||||
expect(within(hoverCard).getByRole('button', { name: /Add response/i })).toBeDisabled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should allow posting a comment', async () => {
|
|
||||||
renderComponent(discussionPostId);
|
|
||||||
const comment = await waitFor(() => screen.findByTestId('comment-comment-1'));
|
|
||||||
const hoverCard = within(comment).getByTestId('hover-card-comment-1');
|
|
||||||
await act(async () => {
|
|
||||||
fireEvent.click(
|
|
||||||
within(hoverCard).getByRole('button', { name: /Add comment/i }),
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
act(() => {
|
act(() => {
|
||||||
fireEvent.change(screen.getByTestId('tinymce-editor'), { target: { value: 'testing123' } });
|
fireEvent.change(screen.getByTestId('tinymce-editor'), { target: { value: 'testing123' } });
|
||||||
});
|
});
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
screen.getByText(/submit/i),
|
screen.getByText(/submit/i),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
expect(screen.queryByTestId('tinymce-editor')).not.toBeInTheDocument();
|
expect(screen.queryByTestId('tinymce-editor')).not.toBeInTheDocument();
|
||||||
await waitFor(async () => expect(await screen.findByTestId('reply-comment-7')).toBeInTheDocument());
|
await waitFor(async () => expect(await screen.findByText('testing123', { exact: false })).toBeInTheDocument());
|
||||||
});
|
});
|
||||||
|
it('should allow posting a comment', async () => {
|
||||||
it('should not allow posting a comment on a closed post', async () => {
|
|
||||||
renderComponent(closedPostId);
|
|
||||||
const comment = await waitFor(() => screen.findByTestId('comment-comment-3'));
|
|
||||||
const hoverCard = within(comment).getByTestId('hover-card-comment-3');
|
|
||||||
expect(within(hoverCard).getByRole('button', { name: /Add comment/i })).toBeDisabled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should allow editing an existing comment', async () => {
|
|
||||||
renderComponent(discussionPostId);
|
renderComponent(discussionPostId);
|
||||||
const comment = await waitFor(() => screen.findByTestId('comment-comment-1'));
|
await waitFor(() => screen.findByText('comment number 1', { exact: false }));
|
||||||
const hoverCard = within(comment).getByTestId('hover-card-comment-1');
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
within(hoverCard).getByRole('button', { name: /actions menu/i }),
|
screen.getAllByRole('button', { name: /add a comment/i })[0],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
act(() => {
|
||||||
|
fireEvent.change(screen.getByTestId('tinymce-editor'), { target: { value: 'testing123' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByText(/submit/i),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(screen.queryByTestId('tinymce-editor')).not.toBeInTheDocument();
|
||||||
|
await waitFor(async () => expect(await screen.findByText('testing123', { exact: false })).toBeInTheDocument());
|
||||||
|
});
|
||||||
|
it('should allow editing an existing comment', async () => {
|
||||||
|
renderComponent(discussionPostId);
|
||||||
|
await waitFor(() => screen.findByText('comment number 1', { exact: false }));
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(
|
||||||
|
// The first edit menu is for the post, the second will be for the first comment.
|
||||||
|
screen.getAllByRole('button', { name: /actions menu/i })[1],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -259,13 +225,13 @@ describe('ThreadView', () => {
|
|||||||
fireEvent.click(screen.getByRole('button', { name: /submit/i }));
|
fireEvent.click(screen.getByRole('button', { name: /submit/i }));
|
||||||
});
|
});
|
||||||
await waitFor(async () => {
|
await waitFor(async () => {
|
||||||
expect(await screen.findByTestId('comment-comment-1')).toBeInTheDocument();
|
expect(await screen.findByText('testing123', { exact: false })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
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' },
|
||||||
@@ -283,11 +249,11 @@ describe('ThreadView', () => {
|
|||||||
it('should show reason codes when editing an existing comment', async () => {
|
it('should show reason codes when editing an existing comment', async () => {
|
||||||
setupCourseConfig();
|
setupCourseConfig();
|
||||||
renderComponent(discussionPostId);
|
renderComponent(discussionPostId);
|
||||||
const comment = await waitFor(() => screen.findByTestId('comment-comment-1'));
|
await waitFor(() => screen.findByText('comment number 1', { exact: false }));
|
||||||
const hoverCard = within(comment).getByTestId('hover-card-comment-1');
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
within(hoverCard).getByRole('button', { name: /actions menu/i }),
|
// The first edit menu is for the post, the second will be for the first comment.
|
||||||
|
screen.getAllByRole('button', { name: /actions menu/i })[1],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -296,12 +262,10 @@ describe('ThreadView', () => {
|
|||||||
expect(screen.queryByRole('combobox', { name: /reason for editing/i })).toBeInTheDocument();
|
expect(screen.queryByRole('combobox', { name: /reason for editing/i })).toBeInTheDocument();
|
||||||
expect(screen.getAllByRole('option', { name: /reason \d/i })).toHaveLength(2);
|
expect(screen.getAllByRole('option', { name: /reason \d/i })).toHaveLength(2);
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.change(screen.queryByRole('combobox', { name: /reason for editing/i }),
|
fireEvent.change(screen.queryByRole('combobox', { name: /reason for editing/i }), { target: { value: null } });
|
||||||
{ target: { value: null } });
|
|
||||||
});
|
});
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.change(screen.queryByRole('combobox',
|
fireEvent.change(screen.queryByRole('combobox', { name: /reason for editing/i }), { target: { value: 'reason-1' } });
|
||||||
{ name: /reason for editing/i }), { target: { value: 'reason-1' } });
|
|
||||||
});
|
});
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.change(screen.getByTestId('tinymce-editor'), { target: { value: 'testing123' } });
|
fireEvent.change(screen.getByTestId('tinymce-editor'), { target: { value: 'testing123' } });
|
||||||
@@ -315,11 +279,12 @@ describe('ThreadView', () => {
|
|||||||
it('should show reason codes when closing a post', async () => {
|
it('should show reason codes when closing a post', async () => {
|
||||||
setupCourseConfig();
|
setupCourseConfig();
|
||||||
renderComponent(discussionPostId);
|
renderComponent(discussionPostId);
|
||||||
const post = await screen.findByTestId('post-thread-1');
|
|
||||||
const hoverCard = within(post).getByTestId('hover-card-thread-1');
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
within(hoverCard).getByRole('button', { name: /actions menu/i }),
|
// The first edit menu is for the post
|
||||||
|
screen.getAllByRole('button', {
|
||||||
|
name: /actions menu/i,
|
||||||
|
})[0],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
expect(screen.queryByRole('dialog', { name: /close post/i })).not.toBeInTheDocument();
|
expect(screen.queryByRole('dialog', { name: /close post/i })).not.toBeInTheDocument();
|
||||||
@@ -342,11 +307,10 @@ describe('ThreadView', () => {
|
|||||||
it('should close the post directly if reason codes are not enabled', async () => {
|
it('should close the post directly if reason codes are not enabled', async () => {
|
||||||
setupCourseConfig(false);
|
setupCourseConfig(false);
|
||||||
renderComponent(discussionPostId);
|
renderComponent(discussionPostId);
|
||||||
const post = await screen.findByTestId('post-thread-1');
|
|
||||||
const hoverCard = within(post).getByTestId('hover-card-thread-1');
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
within(hoverCard).getByRole('button', { name: /actions menu/i }),
|
// The first edit menu is for the post
|
||||||
|
screen.getAllByRole('button', { name: /actions menu/i })[0],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
expect(screen.queryByRole('dialog', { name: /close post/i })).not.toBeInTheDocument();
|
expect(screen.queryByRole('dialog', { name: /close post/i })).not.toBeInTheDocument();
|
||||||
@@ -362,11 +326,10 @@ describe('ThreadView', () => {
|
|||||||
async (reasonCodesEnabled) => {
|
async (reasonCodesEnabled) => {
|
||||||
setupCourseConfig(reasonCodesEnabled);
|
setupCourseConfig(reasonCodesEnabled);
|
||||||
renderComponent(closedPostId);
|
renderComponent(closedPostId);
|
||||||
const post = screen.getByTestId('post-thread-2');
|
|
||||||
const hoverCard = within(post).getByTestId('hover-card-thread-2');
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
within(hoverCard).getByRole('button', { name: /actions menu/i }),
|
// The first edit menu is for the post
|
||||||
|
screen.getAllByRole('button', { name: /actions menu/i })[0],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
expect(screen.queryByRole('dialog', { name: /close post/i })).not.toBeInTheDocument();
|
expect(screen.queryByRole('dialog', { name: /close post/i })).not.toBeInTheDocument();
|
||||||
@@ -381,11 +344,10 @@ describe('ThreadView', () => {
|
|||||||
it('should show the editor if the post is edited', async () => {
|
it('should show the editor if the post is edited', async () => {
|
||||||
setupCourseConfig(false);
|
setupCourseConfig(false);
|
||||||
renderComponent(discussionPostId);
|
renderComponent(discussionPostId);
|
||||||
const post = await screen.findByTestId('post-thread-1');
|
|
||||||
const hoverCard = within(post).getByTestId('hover-card-thread-1');
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
within(hoverCard).getByRole('button', { name: /actions menu/i }),
|
// The first edit menu is for the post
|
||||||
|
screen.getAllByRole('button', { name: /actions menu/i })[0],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -393,14 +355,12 @@ describe('ThreadView', () => {
|
|||||||
});
|
});
|
||||||
expect(testLocation.pathname).toBe(`/${courseId}/posts/${discussionPostId}/edit`);
|
expect(testLocation.pathname).toBe(`/${courseId}/posts/${discussionPostId}/edit`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should allow pinning the post', async () => {
|
it('should allow pinning the post', async () => {
|
||||||
renderComponent(discussionPostId);
|
renderComponent(discussionPostId);
|
||||||
const post = await screen.findByTestId('post-thread-1');
|
|
||||||
const hoverCard = within(post).getByTestId('hover-card-thread-1');
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
within(hoverCard).getByRole('button', { name: /actions menu/i }),
|
// The first edit menu is for the post
|
||||||
|
screen.getAllByRole('button', { name: /actions menu/i })[0],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -408,105 +368,72 @@ describe('ThreadView', () => {
|
|||||||
});
|
});
|
||||||
assertLastUpdateData({ pinned: false });
|
assertLastUpdateData({ pinned: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should allow reporting the post', async () => {
|
it('should allow reporting the post', async () => {
|
||||||
renderComponent(discussionPostId);
|
renderComponent(discussionPostId);
|
||||||
const post = await screen.findByTestId('post-thread-1');
|
|
||||||
const hoverCard = within(post).getByTestId('hover-card-thread-1');
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
within(hoverCard).getByRole('button', { name: /actions menu/i }),
|
// The first edit menu is for the post
|
||||||
|
screen.getAllByRole('button', { name: /actions menu/i })[0],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(screen.getByRole('button', { name: /report/i }));
|
fireEvent.click(screen.getByRole('button', { name: /report/i }));
|
||||||
});
|
});
|
||||||
expect(screen.queryByRole('dialog', { name: /Report \w+/i, exact: false })).toBeInTheDocument();
|
|
||||||
await act(async () => {
|
|
||||||
fireEvent.click(screen.queryByRole('button', { name: /Confirm/i }));
|
|
||||||
});
|
|
||||||
expect(screen.queryByRole('dialog', { name: /Report \w+/i, exact: false })).not.toBeInTheDocument();
|
|
||||||
assertLastUpdateData({ abuse_flagged: true });
|
assertLastUpdateData({ abuse_flagged: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles liking a post', async () => {
|
|
||||||
renderComponent(discussionPostId);
|
|
||||||
|
|
||||||
const post = await screen.findByTestId('post-thread-1');
|
|
||||||
const hoverCard = within(post).getByTestId('hover-card-thread-1');
|
|
||||||
await act(async () => {
|
|
||||||
fireEvent.click(
|
|
||||||
within(hoverCard).getByRole('button', { name: /like/i }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
expect(axiosMock.history.patch).toHaveLength(2);
|
|
||||||
expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject({ voted: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles liking a comment', async () => {
|
it('handles liking a comment', async () => {
|
||||||
renderComponent(discussionPostId);
|
renderComponent(discussionPostId);
|
||||||
|
|
||||||
const comment = await waitFor(() => screen.findByTestId('comment-comment-1'));
|
// Wait for the content to load
|
||||||
const hoverCard = within(comment).getByTestId('hover-card-comment-1');
|
await screen.findByText('comment number 7', { exact: false });
|
||||||
|
const view = screen.getByTestId('comment-comment-1');
|
||||||
|
|
||||||
|
const likeButton = within(view).getByRole('button', { name: /like/i });
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(
|
fireEvent.click(likeButton);
|
||||||
within(hoverCard).getByRole('button', { name: /like/i }),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
expect(axiosMock.history.patch).toHaveLength(2);
|
expect(axiosMock.history.patch).toHaveLength(2);
|
||||||
expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject({ voted: true });
|
expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject({ voted: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles endorsing comments', async () => {
|
it.each([
|
||||||
|
['endorsing comments', 'Endorse', { endorsed: true }],
|
||||||
|
['reporting comments', 'Report', { abuse_flagged: true }],
|
||||||
|
])('handles %s', async (label, buttonLabel, patchData) => {
|
||||||
renderComponent(discussionPostId);
|
renderComponent(discussionPostId);
|
||||||
|
|
||||||
// Wait for the content to load
|
// Wait for the content to load
|
||||||
const comment = await waitFor(() => screen.findByTestId('comment-comment-1'));
|
await screen.findByText('comment number 7', { exact: false });
|
||||||
const hoverCard = within(comment).getByTestId('hover-card-comment-1');
|
|
||||||
|
// There should be three buttons, one for the post, the second for the
|
||||||
|
// comment and the third for a response to that comment
|
||||||
|
const actionButtons = screen.queryAllByRole('button', { name: /actions menu/i });
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(within(hoverCard).getByRole('button', { name: /Endorse/i }));
|
fireEvent.click(actionButtons[1]);
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: buttonLabel }));
|
||||||
});
|
});
|
||||||
expect(axiosMock.history.patch).toHaveLength(2);
|
expect(axiosMock.history.patch).toHaveLength(2);
|
||||||
expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject({ endorsed: true });
|
expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject(patchData);
|
||||||
});
|
|
||||||
|
|
||||||
it('handles reporting comments', async () => {
|
|
||||||
renderComponent(discussionPostId);
|
|
||||||
// Wait for the content to load
|
|
||||||
const comment = await waitFor(() => screen.findByTestId('comment-comment-1'));
|
|
||||||
const hoverCard = within(comment).getByTestId('hover-card-comment-1');
|
|
||||||
await act(async () => {
|
|
||||||
fireEvent.click(
|
|
||||||
within(hoverCard).getByRole('button', { name: /actions menu/i }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: /Report/i }));
|
|
||||||
});
|
|
||||||
expect(screen.queryByRole('dialog', { name: /Report \w+/i, exact: false })).toBeInTheDocument();
|
|
||||||
await act(async () => {
|
|
||||||
fireEvent.click(screen.queryByRole('button', { name: /Confirm/i }));
|
|
||||||
});
|
|
||||||
expect(screen.queryByRole('dialog', { name: /Report \w+/i, exact: false })).not.toBeInTheDocument();
|
|
||||||
expect(axiosMock.history.patch).toHaveLength(2);
|
|
||||||
expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject({ abuse_flagged: true });
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('for discussion thread', () => {
|
describe('for discussion thread', () => {
|
||||||
const findLoadMoreCommentsButton = () => screen.findByTestId('load-more-comments');
|
const findLoadMoreCommentsButton = () => screen.findByTestId('load-more-comments');
|
||||||
|
|
||||||
it('shown post not found when post id does not belong to course', async () => {
|
it('shown spinner when post isn\'t loaded', async () => {
|
||||||
renderComponent('unloaded-id');
|
renderComponent('unloaded-id');
|
||||||
expect(await screen.findByText('Thread not found', { exact: true }))
|
expect(await screen.findByTestId('loading-indicator'))
|
||||||
.toBeInTheDocument();
|
.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('initially loads only the first page', async () => {
|
it('initially loads only the first page', async () => {
|
||||||
renderComponent(discussionPostId);
|
renderComponent(discussionPostId);
|
||||||
expect(await screen.findByTestId('comment-comment-1'))
|
expect(await screen.findByText('comment number 1', { exact: false }))
|
||||||
.toBeInTheDocument();
|
.toBeInTheDocument();
|
||||||
expect(screen.queryByTestId('comment-comment-2'))
|
expect(screen.queryByText('comment number 2', { exact: false }))
|
||||||
.not
|
.not
|
||||||
.toBeInTheDocument();
|
.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -517,8 +444,8 @@ describe('ThreadView', () => {
|
|||||||
const loadMoreButton = await findLoadMoreCommentsButton();
|
const loadMoreButton = await findLoadMoreCommentsButton();
|
||||||
fireEvent.click(loadMoreButton);
|
fireEvent.click(loadMoreButton);
|
||||||
|
|
||||||
await screen.findByTestId('comment-comment-1');
|
await screen.findByText('comment number 1', { exact: false });
|
||||||
await screen.findByTestId('comment-comment-2');
|
await screen.findByText('comment number 2', { exact: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('newly loaded comments are appended to the old ones', async () => {
|
it('newly loaded comments are appended to the old ones', async () => {
|
||||||
@@ -527,9 +454,9 @@ describe('ThreadView', () => {
|
|||||||
const loadMoreButton = await findLoadMoreCommentsButton();
|
const loadMoreButton = await findLoadMoreCommentsButton();
|
||||||
fireEvent.click(loadMoreButton);
|
fireEvent.click(loadMoreButton);
|
||||||
|
|
||||||
await screen.findByTestId('comment-comment-1');
|
await screen.findByText('comment number 1', { exact: false });
|
||||||
// check that comments from the first page are also displayed
|
// check that comments from the first page are also displayed
|
||||||
expect(screen.queryByTestId('comment-comment-2'))
|
expect(screen.queryByText('comment number 2', { exact: false }))
|
||||||
.toBeInTheDocument();
|
.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -542,7 +469,7 @@ describe('ThreadView', () => {
|
|||||||
fireEvent.click(loadMoreButton);
|
fireEvent.click(loadMoreButton);
|
||||||
}
|
}
|
||||||
|
|
||||||
await screen.findByTestId('comment-comment-2');
|
await screen.findByText('comment number 2', { exact: false });
|
||||||
await expect(findLoadMoreCommentsButton())
|
await expect(findLoadMoreCommentsButton())
|
||||||
.rejects
|
.rejects
|
||||||
.toThrow();
|
.toThrow();
|
||||||
@@ -554,11 +481,11 @@ describe('ThreadView', () => {
|
|||||||
|
|
||||||
it('initially loads only the first page', async () => {
|
it('initially loads only the first page', async () => {
|
||||||
act(() => renderComponent(questionPostId));
|
act(() => renderComponent(questionPostId));
|
||||||
expect(await screen.findByTestId('comment-comment-3'))
|
expect(await screen.findByText('comment number 3', { exact: false }))
|
||||||
.toBeInTheDocument();
|
.toBeInTheDocument();
|
||||||
expect(await screen.findByTestId('comment-comment-5'))
|
expect(await screen.findByText('endorsed comment number 5', { exact: false }))
|
||||||
.toBeInTheDocument();
|
.toBeInTheDocument();
|
||||||
expect(screen.queryByTestId('comment-comment-4'))
|
expect(screen.queryByText('comment number 4', { exact: false }))
|
||||||
.not
|
.not
|
||||||
.toBeInTheDocument();
|
.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -571,15 +498,15 @@ describe('ThreadView', () => {
|
|||||||
const [loadMoreButtonEndorsed, loadMoreButtonUnendorsed] = await findLoadMoreCommentsButtons();
|
const [loadMoreButtonEndorsed, loadMoreButtonUnendorsed] = await findLoadMoreCommentsButtons();
|
||||||
// Both load more buttons should show
|
// Both load more buttons should show
|
||||||
expect(await findLoadMoreCommentsButtons()).toHaveLength(2);
|
expect(await findLoadMoreCommentsButtons()).toHaveLength(2);
|
||||||
expect(await screen.findByTestId('comment-comment-3'))
|
expect(await screen.findByText('unendorsed comment number 3', { exact: false }))
|
||||||
.toBeInTheDocument();
|
.toBeInTheDocument();
|
||||||
expect(await screen.findByTestId('comment-comment-5'))
|
expect(await screen.findByText('endorsed comment number 5', { exact: false }))
|
||||||
.toBeInTheDocument();
|
.toBeInTheDocument();
|
||||||
// Comments from next page should not be loaded yet.
|
// Comments from next page should not be loaded yet.
|
||||||
expect(await screen.queryByTestId('comment-comment-6'))
|
expect(await screen.queryByText('endorsed comment number 6', { exact: false }))
|
||||||
.not
|
.not
|
||||||
.toBeInTheDocument();
|
.toBeInTheDocument();
|
||||||
expect(await screen.queryByTestId('comment-comment-4'))
|
expect(await screen.queryByText('unendorsed comment number 4', { exact: false }))
|
||||||
.not
|
.not
|
||||||
.toBeInTheDocument();
|
.toBeInTheDocument();
|
||||||
|
|
||||||
@@ -587,10 +514,10 @@ describe('ThreadView', () => {
|
|||||||
fireEvent.click(loadMoreButtonEndorsed);
|
fireEvent.click(loadMoreButtonEndorsed);
|
||||||
});
|
});
|
||||||
// Endorsed comment from next page should be loaded now.
|
// Endorsed comment from next page should be loaded now.
|
||||||
await waitFor(() => expect(screen.queryByTestId('comment-comment-6'))
|
await waitFor(() => expect(screen.queryByText('endorsed comment number 6', { exact: false }))
|
||||||
.toBeInTheDocument());
|
.toBeInTheDocument());
|
||||||
// Unendorsed comment from next page should not be loaded yet.
|
// Unendorsed comment from next page should not be loaded yet.
|
||||||
expect(await screen.queryByTestId('comment-comment-4'))
|
expect(await screen.queryByText('unendorsed comment number 4', { exact: false }))
|
||||||
.not
|
.not
|
||||||
.toBeInTheDocument();
|
.toBeInTheDocument();
|
||||||
// Now only one load more buttons should show, for unendorsed comments
|
// Now only one load more buttons should show, for unendorsed comments
|
||||||
@@ -599,20 +526,20 @@ describe('ThreadView', () => {
|
|||||||
fireEvent.click(loadMoreButtonUnendorsed);
|
fireEvent.click(loadMoreButtonUnendorsed);
|
||||||
});
|
});
|
||||||
// Unendorsed comment from next page should be loaded now.
|
// Unendorsed comment from next page should be loaded now.
|
||||||
await waitFor(() => expect(screen.queryByTestId('comment-comment-4'))
|
await waitFor(() => expect(screen.queryByText('unendorsed comment number 4', { exact: false }))
|
||||||
.toBeInTheDocument());
|
.toBeInTheDocument());
|
||||||
await expect(findLoadMoreCommentsButtons()).rejects.toThrow();
|
await expect(findLoadMoreCommentsButtons()).rejects.toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('for comments replies', () => {
|
describe('comments responses', () => {
|
||||||
const findLoadMoreCommentsResponsesButton = () => screen.findByTestId('load-more-comments-responses');
|
const findLoadMoreCommentsResponsesButton = () => screen.findByTestId('load-more-comments-responses');
|
||||||
|
|
||||||
it('initially loads only the first page', async () => {
|
it('initially loads only the first page', async () => {
|
||||||
renderComponent(discussionPostId);
|
renderComponent(discussionPostId);
|
||||||
|
|
||||||
await waitFor(() => screen.findByTestId('reply-comment-7'));
|
await waitFor(() => screen.findByText('comment number 7', { exact: false }));
|
||||||
expect(screen.queryByTestId('reply-comment-8')).not.toBeInTheDocument();
|
expect(screen.queryByText('comment number 8', { exact: false })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('pressing load more button will load next page of responses', async () => {
|
it('pressing load more button will load next page of responses', async () => {
|
||||||
@@ -623,7 +550,7 @@ describe('ThreadView', () => {
|
|||||||
fireEvent.click(loadMoreButton);
|
fireEvent.click(loadMoreButton);
|
||||||
});
|
});
|
||||||
|
|
||||||
await screen.findByTestId('reply-comment-8');
|
await screen.findByText('comment number 8', { exact: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('newly loaded responses are appended to the old ones', async () => {
|
it('newly loaded responses are appended to the old ones', async () => {
|
||||||
@@ -634,9 +561,9 @@ describe('ThreadView', () => {
|
|||||||
fireEvent.click(loadMoreButton);
|
fireEvent.click(loadMoreButton);
|
||||||
});
|
});
|
||||||
|
|
||||||
await screen.findByTestId('reply-comment-8');
|
await screen.findByText('comment number 8', { exact: false });
|
||||||
// check that comments from the first page are also displayed
|
// check that comments from the first page are also displayed
|
||||||
expect(screen.queryByTestId('reply-comment-7')).toBeInTheDocument();
|
expect(screen.queryByText('comment number 7', { exact: false })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('load more button is hidden when no more responses pages to load', async () => {
|
it('load more button is hidden when no more responses pages to load', async () => {
|
||||||
@@ -650,116 +577,77 @@ describe('ThreadView', () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await screen.findByTestId('reply-comment-8');
|
await screen.findByText('comment number 8', { exact: false });
|
||||||
await expect(findLoadMoreCommentsResponsesButton())
|
await expect(findLoadMoreCommentsResponsesButton())
|
||||||
.rejects
|
.rejects
|
||||||
.toThrow();
|
.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('handles liking a comment', async () => {
|
||||||
|
renderComponent(discussionPostId);
|
||||||
|
|
||||||
|
// Wait for the content to load
|
||||||
|
await screen.findByText('comment number 7', { exact: false });
|
||||||
|
const view = screen.getByTestId('comment-comment-1');
|
||||||
|
|
||||||
|
const likeButton = within(view).getByRole('button', { name: /like/i });
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(likeButton);
|
||||||
|
});
|
||||||
|
expect(axiosMock.history.patch).toHaveLength(2);
|
||||||
|
expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject({ voted: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['endorsing comments', 'Endorse', { endorsed: true }],
|
||||||
|
['reporting comments', 'Report', { abuse_flagged: true }],
|
||||||
|
])('handles %s', async (label, buttonLabel, patchData) => {
|
||||||
|
renderComponent(discussionPostId);
|
||||||
|
|
||||||
|
// Wait for the content to load
|
||||||
|
await screen.findByText('comment number 7', { exact: false });
|
||||||
|
|
||||||
|
// There should be three buttons, one for the post, the second for the
|
||||||
|
// comment and the third for a response to that comment
|
||||||
|
const actionButtons = screen.queryAllByRole('button', { name: /actions menu/i });
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(actionButtons[1]);
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: buttonLabel }));
|
||||||
|
});
|
||||||
|
expect(axiosMock.history.patch).toHaveLength(2);
|
||||||
|
expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject(patchData);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe.each([
|
describe.each([
|
||||||
{ component: 'post', testId: 'post-thread-1', cardId: 'hover-card-thread-1' },
|
{ component: 'post', testId: 'post-thread-1' },
|
||||||
{ component: 'comment', testId: 'comment-comment-1', cardId: 'hover-card-comment-1' },
|
{ component: 'comment', testId: 'comment-comment-1' },
|
||||||
|
{ component: 'reply', testId: 'reply-comment-7' },
|
||||||
])('delete confirmation modal', ({
|
])('delete confirmation modal', ({
|
||||||
component,
|
component,
|
||||||
testId,
|
testId,
|
||||||
cardId,
|
|
||||||
}) => {
|
}) => {
|
||||||
test(`for ${component}`, async () => {
|
test(`for ${component}`, async () => {
|
||||||
renderComponent(discussionPostId);
|
renderComponent(discussionPostId);
|
||||||
// Wait for the content to load
|
// Wait for the content to load
|
||||||
const post = await screen.findByTestId(testId);
|
await waitFor(() => expect(screen.queryByText('comment number 7', { exact: false })).toBeInTheDocument());
|
||||||
const hoverCard = within(post).getByTestId(cardId);
|
const content = screen.getByTestId(testId);
|
||||||
expect(screen.queryByRole('dialog', { name: /Delete response/i, exact: false })).not.toBeInTheDocument();
|
const actionsButton = within(content).getAllByRole('button', { name: /actions menu/i })[0];
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(
|
fireEvent.click(actionsButton);
|
||||||
within(hoverCard).getByRole('button', { name: /actions menu/i }),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
expect(screen.queryByRole('dialog', { name: /delete \w+/i, exact: false })).not.toBeInTheDocument();
|
||||||
|
const deleteButton = within(content).queryByRole('button', { name: /delete/i });
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(screen.queryByRole('button', { name: /Delete/i }));
|
fireEvent.click(deleteButton);
|
||||||
});
|
});
|
||||||
expect(screen.queryByRole('dialog', { name: /Delete/i, exact: false })).toBeInTheDocument();
|
expect(screen.queryByRole('dialog', { name: /delete \w+/i, exact: false })).toBeInTheDocument();
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('for comments replies', () => {
|
|
||||||
it('shows delete confirmation modal', async () => {
|
|
||||||
renderComponent(discussionPostId);
|
|
||||||
const reply = await waitFor(() => screen.findByTestId('reply-comment-7'));
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(
|
fireEvent.click(screen.queryByRole('button', { name: /delete/i }));
|
||||||
within(reply).getByRole('button', { name: /actions menu/i }),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
await act(async () => {
|
expect(screen.queryByRole('dialog', { name: /delete \w+/i, exact: false })).not.toBeInTheDocument();
|
||||||
fireEvent.click(screen.queryByRole('button', { name: /Delete/i }));
|
|
||||||
});
|
|
||||||
expect(screen.queryByRole('dialog', { name: /Delete/i, exact: false })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('for comments sort', () => {
|
|
||||||
it('should show sort dropdown if there are endorse or unendorsed comments', async () => {
|
|
||||||
renderComponent(discussionPostId);
|
|
||||||
|
|
||||||
const comment = await waitFor(() => screen.findByTestId('comment-comment-1'));
|
|
||||||
const sortWrapper = container.querySelector('.comments-sort');
|
|
||||||
const sortDropDown = within(sortWrapper).getByRole('button', { name: /Oldest first/i });
|
|
||||||
|
|
||||||
expect(comment).toBeInTheDocument();
|
|
||||||
expect(sortDropDown).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not show sort dropdown if there is no response', async () => {
|
|
||||||
const commentId = 'comment-1';
|
|
||||||
renderComponent(discussionPostId);
|
|
||||||
|
|
||||||
await waitFor(() => screen.findByTestId('comment-comment-1'));
|
|
||||||
axiosMock.onDelete(`${commentsApiUrl}${commentId}/`).reply(201);
|
|
||||||
await executeThunk(removeComment(commentId, discussionPostId), store.dispatch, store.getState);
|
|
||||||
|
|
||||||
expect(await waitFor(() => screen.findByText('No responses', { exact: true }))).toBeInTheDocument();
|
|
||||||
expect(container.querySelector('.comments-sort')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have only two options', async () => {
|
|
||||||
renderComponent(discussionPostId);
|
|
||||||
|
|
||||||
await waitFor(() => screen.findByTestId('comment-comment-1'));
|
|
||||||
await act(async () => { fireEvent.click(screen.getByRole('button', { name: /Oldest first/i })); });
|
|
||||||
const dropdown = await waitFor(() => screen.findByTestId('comment-sort-dropdown-modal-popup'));
|
|
||||||
|
|
||||||
expect(dropdown).toBeInTheDocument();
|
|
||||||
expect(await within(dropdown).getAllByRole('button')).toHaveLength(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should be selected Oldest first and auto focus', async () => {
|
|
||||||
renderComponent(discussionPostId);
|
|
||||||
|
|
||||||
await waitFor(() => screen.findByTestId('comment-comment-1'));
|
|
||||||
await act(async () => { fireEvent.click(screen.getByRole('button', { name: /Oldest first/i })); });
|
|
||||||
const dropdown = await waitFor(() => screen.findByTestId('comment-sort-dropdown-modal-popup'));
|
|
||||||
|
|
||||||
expect(dropdown).toBeInTheDocument();
|
|
||||||
expect(within(dropdown).getByRole('button', { name: /Oldest first/i })).toBeInTheDocument();
|
|
||||||
expect(within(dropdown).getByRole('button', { name: /Oldest first/i })).toHaveFocus();
|
|
||||||
expect(within(dropdown).getByRole('button', { name: /Newest first/i })).not.toHaveFocus();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('successfully handles sort state update', async () => {
|
|
||||||
renderComponent(discussionPostId);
|
|
||||||
|
|
||||||
expect(store.getState().comments.sortOrder).toBeFalsy();
|
|
||||||
|
|
||||||
await waitFor(() => screen.findByTestId('comment-comment-1'));
|
|
||||||
await act(async () => { fireEvent.click(screen.getByRole('button', { name: /Oldest first/i })); });
|
|
||||||
const dropdown = await waitFor(() => screen.findByTestId('comment-sort-dropdown-modal-popup'));
|
|
||||||
await act(async () => {
|
|
||||||
fireEvent.click(within(dropdown).getByRole('button', { name: /Newest first/i }));
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(store.getState().comments.sortOrder).toBeTruthy();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
43
src/discussions/comments/comment-icons/CommentIcons.jsx
Normal file
43
src/discussions/comments/comment-icons/CommentIcons.jsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
|
import { useDispatch } from 'react-redux';
|
||||||
|
import * as timeago from 'timeago.js';
|
||||||
|
|
||||||
|
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
|
import LikeButton from '../../posts/post/LikeButton';
|
||||||
|
import { editComment } from '../data/thunks';
|
||||||
|
|
||||||
|
function CommentIcons({
|
||||||
|
comment,
|
||||||
|
intl,
|
||||||
|
}) {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const handleLike = () => dispatch(editComment(comment.id, { voted: !comment.voted }));
|
||||||
|
return (
|
||||||
|
<div className="d-flex flex-row align-items-center">
|
||||||
|
<LikeButton
|
||||||
|
count={comment.voteCount}
|
||||||
|
onClick={handleLike}
|
||||||
|
voted={comment.voted}
|
||||||
|
/>
|
||||||
|
<div className="d-flex flex-fill text-gray-500 justify-content-end mt-2" title={comment.createdAt}>
|
||||||
|
{timeago.format(comment.createdAt, intl.locale)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
CommentIcons.propTypes = {
|
||||||
|
comment: PropTypes.shape({
|
||||||
|
id: PropTypes.string,
|
||||||
|
voteCount: PropTypes.number,
|
||||||
|
following: PropTypes.bool,
|
||||||
|
voted: PropTypes.bool,
|
||||||
|
createdAt: PropTypes.string,
|
||||||
|
}).isRequired,
|
||||||
|
intl: intlShape.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default injectIntl(CommentIcons);
|
||||||
140
src/discussions/comments/comment/Comment.jsx
Normal file
140
src/discussions/comments/comment/Comment.jsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import React, { useEffect, useState } 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 { Button, useToggle } from '@edx/paragon';
|
||||||
|
|
||||||
|
import { ContentActions } from '../../../data/constants';
|
||||||
|
import { AlertBanner, DeleteConfirmation } from '../../common';
|
||||||
|
import { fetchThread } from '../../posts/data/thunks';
|
||||||
|
import CommentIcons from '../comment-icons/CommentIcons';
|
||||||
|
import { selectCommentCurrentPage, selectCommentHasMorePages, selectCommentResponses } from '../data/selectors';
|
||||||
|
import { editComment, fetchCommentResponses, removeComment } from '../data/thunks';
|
||||||
|
import messages from '../messages';
|
||||||
|
import CommentEditor from './CommentEditor';
|
||||||
|
import CommentHeader from './CommentHeader';
|
||||||
|
import { commentShape } from './proptypes';
|
||||||
|
import Reply from './Reply';
|
||||||
|
|
||||||
|
function Comment({
|
||||||
|
postType,
|
||||||
|
comment,
|
||||||
|
showFullThread = true,
|
||||||
|
intl,
|
||||||
|
}) {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const hasChildren = comment.childCount > 0;
|
||||||
|
const isNested = Boolean(comment.parentId);
|
||||||
|
const inlineReplies = useSelector(selectCommentResponses(comment.id));
|
||||||
|
const [isEditing, setEditing] = useState(false);
|
||||||
|
const [isDeleting, showDeleteConfirmation, hideDeleteConfirmation] = useToggle(false);
|
||||||
|
const [isReplying, setReplying] = useState(false);
|
||||||
|
const hasMorePages = useSelector(selectCommentHasMorePages(comment.id));
|
||||||
|
const currentPage = useSelector(selectCommentCurrentPage(comment.id));
|
||||||
|
useEffect(() => {
|
||||||
|
// If the comment has a parent comment, it won't have any children, so don't fetch them.
|
||||||
|
if (hasChildren && !currentPage && showFullThread) {
|
||||||
|
dispatch(fetchCommentResponses(comment.id, { page: 1 }));
|
||||||
|
}
|
||||||
|
}, [comment.id]);
|
||||||
|
const actionHandlers = {
|
||||||
|
[ContentActions.EDIT_CONTENT]: () => setEditing(true),
|
||||||
|
[ContentActions.ENDORSE]: async () => {
|
||||||
|
await dispatch(editComment(comment.id, { endorsed: !comment.endorsed }));
|
||||||
|
await dispatch(fetchThread(comment.threadId));
|
||||||
|
},
|
||||||
|
[ContentActions.DELETE]: showDeleteConfirmation,
|
||||||
|
[ContentActions.REPORT]: () => dispatch(editComment(comment.id, { flagged: !comment.abuseFlagged })),
|
||||||
|
};
|
||||||
|
const handleLoadMoreComments = () => (
|
||||||
|
dispatch(fetchCommentResponses(comment.id, { page: currentPage + 1 }))
|
||||||
|
);
|
||||||
|
const commentClasses = classNames('d-flex flex-column card', { 'my-3': showFullThread });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={commentClasses} data-testid={`comment-${comment.id}`}>
|
||||||
|
<DeleteConfirmation
|
||||||
|
isOpen={isDeleting}
|
||||||
|
title={intl.formatMessage(messages.deleteResponseTitle)}
|
||||||
|
description={intl.formatMessage(messages.deleteResponseDescription)}
|
||||||
|
onClose={hideDeleteConfirmation}
|
||||||
|
onDelete={() => {
|
||||||
|
dispatch(removeComment(comment.id));
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
<div className="d-flex my-2 flex-column">
|
||||||
|
{/* 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
|
||||||
|
onClick={handleLoadMoreComments}
|
||||||
|
variant="link"
|
||||||
|
block="true"
|
||||||
|
className="my-4"
|
||||||
|
data-testid="load-more-comments-responses"
|
||||||
|
>
|
||||||
|
{intl.formatMessage(messages.loadMoreResponses)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{!isNested && showFullThread
|
||||||
|
&& (
|
||||||
|
isReplying
|
||||||
|
? (
|
||||||
|
<CommentEditor
|
||||||
|
comment={{
|
||||||
|
threadId: comment.threadId,
|
||||||
|
parentId: comment.id,
|
||||||
|
}}
|
||||||
|
onCloseEditor={() => setReplying(false)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<Button className="d-flex flex-grow " variant="outline-secondary" onClick={() => setReplying(true)}>
|
||||||
|
{intl.formatMessage(messages.addComment)}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Comment.propTypes = {
|
||||||
|
postType: PropTypes.oneOf(['discussion', 'question']).isRequired,
|
||||||
|
comment: commentShape.isRequired,
|
||||||
|
showFullThread: PropTypes.bool,
|
||||||
|
intl: intlShape.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
Comment.defaultProps = {
|
||||||
|
showFullThread: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default injectIntl(Comment);
|
||||||
@@ -9,67 +9,26 @@ import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
|||||||
import { AppContext } from '@edx/frontend-platform/react';
|
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 { useDispatchWithState } from '../../../data/hooks';
|
||||||
import PostPreviewPane from '../../../../components/PostPreviewPane';
|
import { selectModerationSettings, selectUserIsPrivileged } from '../../data/selectors';
|
||||||
import { useDispatchWithState } from '../../../../data/hooks';
|
import { formikCompatibleHandler, isFormikFieldInvalid } from '../../utils';
|
||||||
import {
|
import { addComment, editComment } from '../data/thunks';
|
||||||
selectModerationSettings,
|
import messages from '../messages';
|
||||||
selectUserHasModerationPrivileges,
|
|
||||||
selectUserIsGroupTa,
|
|
||||||
selectUserIsStaff,
|
|
||||||
} from '../../../data/selectors';
|
|
||||||
import { formikCompatibleHandler, isFormikFieldInvalid } from '../../../utils';
|
|
||||||
import { addComment, editComment } from '../../data/thunks';
|
|
||||||
import messages from '../../messages';
|
|
||||||
|
|
||||||
function CommentEditor({
|
function CommentEditor({
|
||||||
intl,
|
intl,
|
||||||
comment,
|
comment,
|
||||||
onCloseEditor,
|
onCloseEditor,
|
||||||
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 +36,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 +61,15 @@ 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,
|
<Form.Group>
|
||||||
touched,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<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 +85,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 +108,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 +139,9 @@ 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,
|
|
||||||
formClasses: PropTypes.string,
|
|
||||||
};
|
|
||||||
|
|
||||||
CommentEditor.defaultProps = {
|
|
||||||
edit: true,
|
|
||||||
formClasses: '',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(CommentEditor);
|
export default injectIntl(CommentEditor);
|
||||||
56
src/discussions/comments/comment/CommentHeader.jsx
Normal file
56
src/discussions/comments/comment/CommentHeader.jsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
|
import { useSelector } from 'react-redux';
|
||||||
|
|
||||||
|
import { injectIntl } from '@edx/frontend-platform/i18n';
|
||||||
|
import { Avatar, Icon } from '@edx/paragon';
|
||||||
|
import { CheckCircle, Verified } from '@edx/paragon/icons';
|
||||||
|
|
||||||
|
import { AvatarBorderAndLabelColors, ThreadType } from '../../../data/constants';
|
||||||
|
import { AuthorLabel } from '../../common';
|
||||||
|
import ActionsDropdown from '../../common/ActionsDropdown';
|
||||||
|
import { selectAuthorAvatars } from '../../posts/data/selectors';
|
||||||
|
import { commentShape } from './proptypes';
|
||||||
|
|
||||||
|
function CommentHeader({
|
||||||
|
comment,
|
||||||
|
postType,
|
||||||
|
actionHandlers,
|
||||||
|
}) {
|
||||||
|
const authorAvatars = useSelector(selectAuthorAvatars(comment.author));
|
||||||
|
const colorClass = AvatarBorderAndLabelColors[comment.authorLabel];
|
||||||
|
return (
|
||||||
|
<div className="d-flex flex-row justify-content-between">
|
||||||
|
<div className="align-items-center d-flex flex-row">
|
||||||
|
<Avatar
|
||||||
|
className={`m-2 ${colorClass && `border-${colorClass}`}`}
|
||||||
|
style={{ borderWidth: '2px' }}
|
||||||
|
alt={comment.author}
|
||||||
|
src={authorAvatars?.imageUrlSmall}
|
||||||
|
/>
|
||||||
|
<AuthorLabel author={comment.author} authorLabel={comment.authorLabel} labelColor={colorClass && `text-${colorClass}`} />
|
||||||
|
</div>
|
||||||
|
<div className="d-flex align-items-center">
|
||||||
|
{comment.endorsed && (postType === 'question'
|
||||||
|
? <Icon src={CheckCircle} className="text-success" data-testid="check-icon" />
|
||||||
|
: <Icon src={Verified} data-testid="verified-icon" />)}
|
||||||
|
<ActionsDropdown
|
||||||
|
commentOrPost={{
|
||||||
|
...comment,
|
||||||
|
postType,
|
||||||
|
}}
|
||||||
|
actionHandlers={actionHandlers}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
CommentHeader.propTypes = {
|
||||||
|
comment: commentShape.isRequired,
|
||||||
|
actionHandlers: PropTypes.objectOf(PropTypes.func).isRequired,
|
||||||
|
postType: PropTypes.oneOf([ThreadType.QUESTION, ThreadType.DISCUSSION]).isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default injectIntl(CommentHeader);
|
||||||
52
src/discussions/comments/comment/CommentHeader.test.jsx
Normal file
52
src/discussions/comments/comment/CommentHeader.test.jsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
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 CommentHeader from './CommentHeader';
|
||||||
|
|
||||||
|
let store;
|
||||||
|
|
||||||
|
function renderComponent(comment, postType, actionHandlers) {
|
||||||
|
return render(
|
||||||
|
<IntlProvider locale="en">
|
||||||
|
<AppProvider store={store}>
|
||||||
|
<CommentHeader comment={comment} postType={postType} actionHandlers={actionHandlers} />
|
||||||
|
</AppProvider>
|
||||||
|
</IntlProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockComment = {
|
||||||
|
author: 'abc123',
|
||||||
|
authorLabel: 'ABC 123',
|
||||||
|
endorsed: true,
|
||||||
|
editableFields: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Comment Header', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
initializeMockApp({
|
||||||
|
authenticatedUser: {
|
||||||
|
userId: 3,
|
||||||
|
username: 'abc123',
|
||||||
|
administrator: true,
|
||||||
|
roles: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
store = initializeStore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render verified icon for endorsed discussion posts', () => {
|
||||||
|
renderComponent(mockComment, 'discussion', {});
|
||||||
|
expect(screen.queryAllByTestId('verified-icon')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
it('should render check icon for endorsed question posts', () => {
|
||||||
|
renderComponent(mockComment, 'question', {});
|
||||||
|
expect(screen.queryAllByTestId('check-icon')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
90
src/discussions/comments/comment/Reply.jsx
Normal file
90
src/discussions/comments/comment/Reply.jsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
import * as timeago from 'timeago.js';
|
||||||
|
|
||||||
|
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||||
|
import { Avatar, useToggle } from '@edx/paragon';
|
||||||
|
|
||||||
|
import { AvatarBorderAndLabelColors, ContentActions } from '../../../data/constants';
|
||||||
|
import {
|
||||||
|
ActionsDropdown, AlertBanner, AuthorLabel, DeleteConfirmation,
|
||||||
|
} from '../../common';
|
||||||
|
import { selectAuthorAvatars } from '../../posts/data/selectors';
|
||||||
|
import { editComment, removeComment } from '../data/thunks';
|
||||||
|
import messages from '../messages';
|
||||||
|
import CommentEditor from './CommentEditor';
|
||||||
|
import { commentShape } from './proptypes';
|
||||||
|
|
||||||
|
function Reply({
|
||||||
|
reply,
|
||||||
|
postType,
|
||||||
|
intl,
|
||||||
|
}) {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const [isEditing, setEditing] = useState(false);
|
||||||
|
const [isDeleting, showDeleteConfirmation, hideDeleteConfirmation] = useToggle(false);
|
||||||
|
const actionHandlers = {
|
||||||
|
[ContentActions.EDIT_CONTENT]: () => setEditing(true),
|
||||||
|
[ContentActions.ENDORSE]: () => dispatch(editComment(reply.id, { endorsed: !reply.endorsed })),
|
||||||
|
[ContentActions.DELETE]: showDeleteConfirmation,
|
||||||
|
[ContentActions.REPORT]: () => dispatch(editComment(reply.id, { flagged: !reply.abuseFlagged })),
|
||||||
|
};
|
||||||
|
const authorAvatars = useSelector(selectAuthorAvatars(reply.author));
|
||||||
|
const colorClass = AvatarBorderAndLabelColors[reply.authorLabel];
|
||||||
|
return (
|
||||||
|
<div className="d-flex my-2 flex-column" data-testid={`reply-${reply.id}`}>
|
||||||
|
<DeleteConfirmation
|
||||||
|
isOpen={isDeleting}
|
||||||
|
title={intl.formatMessage(messages.deleteCommentTitle)}
|
||||||
|
description={intl.formatMessage(messages.deleteCommentDescription)}
|
||||||
|
onClose={hideDeleteConfirmation}
|
||||||
|
onDelete={() => {
|
||||||
|
dispatch(removeComment(reply.id));
|
||||||
|
hideDeleteConfirmation();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="d-flex flex-fill ml-6">
|
||||||
|
<AlertBanner postType={null} content={reply} intl={intl} />
|
||||||
|
</div>
|
||||||
|
<div className="d-flex">
|
||||||
|
|
||||||
|
<div className="d-flex m-3">
|
||||||
|
<Avatar
|
||||||
|
className={`m-2 ${colorClass && `border-${colorClass}`}`}
|
||||||
|
style={{ borderWidth: '2px' }}
|
||||||
|
alt={reply.author}
|
||||||
|
src={authorAvatars?.imageUrlSmall}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="rounded bg-light-300 px-4 py-2 flex-fill">
|
||||||
|
<div className="d-flex flex-row justify-content-between align-items-center">
|
||||||
|
<AuthorLabel author={reply.author} authorLabel={reply.authorLabel} labelColor={colorClass && `text-${colorClass}`} />
|
||||||
|
<ActionsDropdown
|
||||||
|
commentOrPost={{
|
||||||
|
...reply,
|
||||||
|
postType,
|
||||||
|
}}
|
||||||
|
actionHandlers={actionHandlers}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{isEditing
|
||||||
|
? <CommentEditor comment={reply} onCloseEditor={() => setEditing(false)} />
|
||||||
|
// eslint-disable-next-line react/no-danger
|
||||||
|
: <div dangerouslySetInnerHTML={{ __html: reply.renderedBody }} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-500 align-self-end mt-2" title={reply.createdAt}>
|
||||||
|
{timeago.format(reply.createdAt, intl.locale)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Reply.propTypes = {
|
||||||
|
postType: PropTypes.oneOf(['discussion', 'question']).isRequired,
|
||||||
|
reply: commentShape.isRequired,
|
||||||
|
intl: intlShape.isRequired,
|
||||||
|
};
|
||||||
|
export default injectIntl(Reply);
|
||||||
32
src/discussions/comments/comment/ResponseEditor.jsx
Normal file
32
src/discussions/comments/comment/ResponseEditor.jsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
|
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||||
|
import { Button } from '@edx/paragon';
|
||||||
|
|
||||||
|
import messages from '../messages';
|
||||||
|
import CommentEditor from './CommentEditor';
|
||||||
|
|
||||||
|
function ResponseEditor({
|
||||||
|
postId,
|
||||||
|
intl,
|
||||||
|
}) {
|
||||||
|
const [addingResponse, setAddingResponse] = useState(false);
|
||||||
|
return addingResponse
|
||||||
|
? (
|
||||||
|
<CommentEditor comment={{ threadId: postId }} onCloseEditor={() => setAddingResponse(false)} />
|
||||||
|
) : (
|
||||||
|
<div className="actions d-flex">
|
||||||
|
<Button variant="primary" onClick={() => setAddingResponse(true)}>
|
||||||
|
{intl.formatMessage(messages.addResponse)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ResponseEditor.propTypes = {
|
||||||
|
postId: PropTypes.string.isRequired,
|
||||||
|
intl: intlShape.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default injectIntl(ResponseEditor);
|
||||||
@@ -8,7 +8,9 @@ ensureConfig([
|
|||||||
'LMS_BASE_URL',
|
'LMS_BASE_URL',
|
||||||
], 'Comments API service');
|
], 'Comments API service');
|
||||||
|
|
||||||
export const getCommentsApiUrl = () => `${getConfig().LMS_BASE_URL}/api/discussion/v1/comments/`;
|
const apiBaseUrl = getConfig().LMS_BASE_URL;
|
||||||
|
|
||||||
|
export const commentsApiUrl = `${apiBaseUrl}/api/discussion/v1/comments/`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns all the comments for the specified thread.
|
* Returns all the comments for the specified thread.
|
||||||
@@ -23,7 +25,6 @@ export async function getThreadComments(
|
|||||||
endorsed,
|
endorsed,
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
reverseOrder,
|
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
const params = snakeCaseObject({
|
const params = snakeCaseObject({
|
||||||
@@ -31,12 +32,11 @@ export async function getThreadComments(
|
|||||||
endorsed: EndorsementValue[endorsed],
|
endorsed: EndorsementValue[endorsed],
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
reverseOrder,
|
|
||||||
requestedFields: 'profile_image',
|
requestedFields: 'profile_image',
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data } = await getAuthenticatedHttpClient()
|
const { data } = await getAuthenticatedHttpClient()
|
||||||
.get(getCommentsApiUrl(), { params });
|
.get(commentsApiUrl, { params });
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ export async function getCommentResponses(
|
|||||||
pageSize,
|
pageSize,
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
const url = `${getCommentsApiUrl()}${commentId}/`;
|
const url = `${commentsApiUrl}${commentId}/`;
|
||||||
const params = snakeCaseObject({
|
const params = snakeCaseObject({
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
@@ -73,7 +73,7 @@ export async function getCommentResponses(
|
|||||||
*/
|
*/
|
||||||
export async function postComment(comment, threadId, parentId = null) {
|
export async function postComment(comment, threadId, parentId = null) {
|
||||||
const { data } = await getAuthenticatedHttpClient()
|
const { data } = await getAuthenticatedHttpClient()
|
||||||
.post(getCommentsApiUrl(), snakeCaseObject({ threadId, raw_body: comment, parentId }));
|
.post(commentsApiUrl, snakeCaseObject({ threadId, raw_body: comment, parentId }));
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,7 +94,7 @@ export async function updateComment(commentId, {
|
|||||||
endorsed,
|
endorsed,
|
||||||
editReasonCode,
|
editReasonCode,
|
||||||
}) {
|
}) {
|
||||||
const url = `${getCommentsApiUrl()}${commentId}/`;
|
const url = `${commentsApiUrl}${commentId}/`;
|
||||||
const postData = snakeCaseObject({
|
const postData = snakeCaseObject({
|
||||||
raw_body: comment,
|
raw_body: comment,
|
||||||
voted,
|
voted,
|
||||||
@@ -113,7 +113,32 @@ export async function updateComment(commentId, {
|
|||||||
* @param {string} commentId ID of comment to delete
|
* @param {string} commentId ID of comment to delete
|
||||||
*/
|
*/
|
||||||
export async function deleteComment(commentId) {
|
export async function deleteComment(commentId) {
|
||||||
const url = `${getCommentsApiUrl()}${commentId}/`;
|
const url = `${commentsApiUrl}${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) {
|
||||||
|
const { data } = await getAuthenticatedHttpClient()
|
||||||
|
.get(commentsApiUrl, {
|
||||||
|
params: {
|
||||||
|
course_id: courseId,
|
||||||
|
username,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
@@ -7,14 +7,13 @@ import { initializeMockApp } from '@edx/frontend-platform/testing';
|
|||||||
import { EndorsementStatus } from '../../../data/constants';
|
import { EndorsementStatus } from '../../../data/constants';
|
||||||
import { initializeStore } from '../../../store';
|
import { initializeStore } from '../../../store';
|
||||||
import { executeThunk } from '../../../test-utils';
|
import { executeThunk } from '../../../test-utils';
|
||||||
import { getCommentsApiUrl } from './api';
|
import { commentsApiUrl } from './api';
|
||||||
import {
|
import {
|
||||||
addComment, editComment, fetchCommentResponses, fetchThreadComments, removeComment,
|
addComment, editComment, fetchCommentResponses, fetchThreadComments, removeComment,
|
||||||
} from './thunks';
|
} from './thunks';
|
||||||
|
|
||||||
import './__factories__';
|
import './__factories__';
|
||||||
|
|
||||||
const commentsApiUrl = getCommentsApiUrl();
|
|
||||||
let axiosMock;
|
let axiosMock;
|
||||||
let store;
|
let store;
|
||||||
|
|
||||||
@@ -276,7 +275,8 @@ describe('Comments/Responses data layer tests', () => {
|
|||||||
const commentId = 'comment-1';
|
const commentId = 'comment-1';
|
||||||
|
|
||||||
// This will generate 3 comments, so the responses will start at id = 'comment-4'
|
// This will generate 3 comments, so the responses will start at id = 'comment-4'
|
||||||
axiosMock.onGet(commentsApiUrl).reply(200, Factory.build('commentsResult'));
|
axiosMock.onGet(commentsApiUrl)
|
||||||
|
.reply(200, Factory.build('commentsResult'));
|
||||||
await executeThunk(fetchThreadComments(threadId), store.dispatch, store.getState);
|
await executeThunk(fetchThreadComments(threadId), store.dispatch, store.getState);
|
||||||
|
|
||||||
// Build all comments first, so we can paginate over them and they
|
// Build all comments first, so we can paginate over them and they
|
||||||
@@ -300,7 +300,8 @@ describe('Comments/Responses data layer tests', () => {
|
|||||||
parent_id: commentId,
|
parent_id: commentId,
|
||||||
});
|
});
|
||||||
allResponses.push(comment);
|
allResponses.push(comment);
|
||||||
axiosMock.onPost(commentsApiUrl).reply(200, comment);
|
axiosMock.onPost(commentsApiUrl)
|
||||||
|
.reply(200, comment);
|
||||||
await executeThunk(addComment('Test Comment', threadId, null), store.dispatch, store.getState);
|
await executeThunk(addComment('Test Comment', threadId, null), store.dispatch, store.getState);
|
||||||
|
|
||||||
// Someone else posted a new response now
|
// Someone else posted a new response now
|
||||||
@@ -314,14 +315,15 @@ describe('Comments/Responses data layer tests', () => {
|
|||||||
});
|
});
|
||||||
await executeThunk(fetchCommentResponses(commentId, { page: 2 }), store.dispatch, store.getState);
|
await executeThunk(fetchCommentResponses(commentId, { page: 2 }), store.dispatch, store.getState);
|
||||||
|
|
||||||
// sorting is implemented on backend
|
|
||||||
expect(store.getState().comments.commentsInComments[commentId])
|
expect(store.getState().comments.commentsInComments[commentId])
|
||||||
.toEqual([
|
.toEqual([
|
||||||
'comment-4',
|
'comment-4',
|
||||||
'comment-5',
|
'comment-5',
|
||||||
'comment-6',
|
'comment-6',
|
||||||
'comment-8',
|
|
||||||
'comment-7',
|
'comment-7',
|
||||||
|
// our comment was pushed down
|
||||||
|
'comment-8',
|
||||||
|
// the newer comment is placed correctly
|
||||||
'comment-9',
|
'comment-9',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -353,7 +355,8 @@ describe('Comments/Responses data layer tests', () => {
|
|||||||
// Post new comment
|
// Post new comment
|
||||||
const comment = Factory.build('comment', { thread_id: threadId });
|
const comment = Factory.build('comment', { thread_id: threadId });
|
||||||
allComments.push(comment);
|
allComments.push(comment);
|
||||||
axiosMock.onPost(commentsApiUrl).reply(200, comment);
|
axiosMock.onPost(commentsApiUrl)
|
||||||
|
.reply(200, comment);
|
||||||
await executeThunk(addComment('Test Comment', threadId, null), store.dispatch, store.getState);
|
await executeThunk(addComment('Test Comment', threadId, null), store.dispatch, store.getState);
|
||||||
|
|
||||||
// Somebody else posted a new response now
|
// Somebody else posted a new response now
|
||||||
@@ -367,14 +370,15 @@ describe('Comments/Responses data layer tests', () => {
|
|||||||
});
|
});
|
||||||
await executeThunk(fetchThreadComments(threadId, { page: 2, endorsed }), store.dispatch, store.getState);
|
await executeThunk(fetchThreadComments(threadId, { page: 2, endorsed }), store.dispatch, store.getState);
|
||||||
|
|
||||||
// sorting is implemented on backend
|
|
||||||
expect(store.getState().comments.commentsInThreads[threadId][endorsed])
|
expect(store.getState().comments.commentsInThreads[threadId][endorsed])
|
||||||
.toEqual([
|
.toEqual([
|
||||||
'comment-1',
|
'comment-1',
|
||||||
'comment-2',
|
'comment-2',
|
||||||
'comment-3',
|
'comment-3',
|
||||||
'comment-5',
|
|
||||||
'comment-4',
|
'comment-4',
|
||||||
|
// our comment was pushed down
|
||||||
|
'comment-5',
|
||||||
|
// the newer comment is placed correctly
|
||||||
'comment-6',
|
'comment-6',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -36,6 +36,4 @@ export const selectCommentCurrentPage = commentId => (
|
|||||||
state => state.comments.responsesPagination[commentId]?.currentPage || null
|
state => state.comments.responsesPagination[commentId]?.currentPage || null
|
||||||
);
|
);
|
||||||
|
|
||||||
export const selectCommentsStatus = state => state.comments.status;
|
export const commentsStatus = state => state.comments.status;
|
||||||
|
|
||||||
export const selectCommentSortOrder = state => state.comments.sortOrder;
|
|
||||||
@@ -22,7 +22,6 @@ const commentsSlice = createSlice({
|
|||||||
postStatus: RequestStatus.SUCCESSFUL,
|
postStatus: RequestStatus.SUCCESSFUL,
|
||||||
pagination: {},
|
pagination: {},
|
||||||
responsesPagination: {},
|
responsesPagination: {},
|
||||||
sortOrder: false,
|
|
||||||
},
|
},
|
||||||
reducers: {
|
reducers: {
|
||||||
fetchCommentsRequest: (state) => {
|
fetchCommentsRequest: (state) => {
|
||||||
@@ -57,6 +56,15 @@ const commentsSlice = createSlice({
|
|||||||
hasMorePages: Boolean(payload.pagination.next),
|
hasMorePages: Boolean(payload.pagination.next),
|
||||||
};
|
};
|
||||||
state.commentsById = { ...state.commentsById, ...payload.commentsById };
|
state.commentsById = { ...state.commentsById, ...payload.commentsById };
|
||||||
|
// We sort the comments by creation time.
|
||||||
|
// This way our new comments are pushed down to the correct
|
||||||
|
// position when more pages of older comments are loaded.
|
||||||
|
state.commentsInThreads[threadId][endorsed].sort(
|
||||||
|
(a, b) => (
|
||||||
|
Date.parse(state.commentsById[a].createdAt)
|
||||||
|
- Date.parse(state.commentsById[b].createdAt)
|
||||||
|
),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
fetchCommentsFailed: (state) => {
|
fetchCommentsFailed: (state) => {
|
||||||
state.status = RequestStatus.FAILED;
|
state.status = RequestStatus.FAILED;
|
||||||
@@ -82,6 +90,12 @@ const commentsSlice = createSlice({
|
|||||||
]),
|
]),
|
||||||
];
|
];
|
||||||
state.commentsById = { ...state.commentsById, ...payload.commentsById };
|
state.commentsById = { ...state.commentsById, ...payload.commentsById };
|
||||||
|
state.commentsInComments[payload.commentId].sort(
|
||||||
|
(a, b) => (
|
||||||
|
Date.parse(state.commentsById[a].createdAt)
|
||||||
|
- Date.parse(state.commentsById[b].createdAt)
|
||||||
|
),
|
||||||
|
);
|
||||||
state.responsesPagination[payload.commentId] = {
|
state.responsesPagination[payload.commentId] = {
|
||||||
currentPage: payload.page,
|
currentPage: payload.page,
|
||||||
totalPages: payload.pagination.numPages,
|
totalPages: payload.pagination.numPages,
|
||||||
@@ -131,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;
|
||||||
},
|
},
|
||||||
@@ -167,9 +169,6 @@ const commentsSlice = createSlice({
|
|||||||
}
|
}
|
||||||
delete state.commentsById[commentId];
|
delete state.commentsById[commentId];
|
||||||
},
|
},
|
||||||
setCommentSortOrder: (state, { payload }) => {
|
|
||||||
state.sortOrder = payload;
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -190,12 +189,10 @@ export const {
|
|||||||
updateCommentFailed,
|
updateCommentFailed,
|
||||||
updateCommentRequest,
|
updateCommentRequest,
|
||||||
updateCommentSuccess,
|
updateCommentSuccess,
|
||||||
updateCommentsList,
|
|
||||||
deleteCommentDenied,
|
deleteCommentDenied,
|
||||||
deleteCommentFailed,
|
deleteCommentFailed,
|
||||||
deleteCommentRequest,
|
deleteCommentRequest,
|
||||||
deleteCommentSuccess,
|
deleteCommentSuccess,
|
||||||
setCommentSortOrder,
|
|
||||||
} = commentsSlice.actions;
|
} = commentsSlice.actions;
|
||||||
|
|
||||||
export const commentsReducer = commentsSlice.reducer;
|
export const commentsReducer = commentsSlice.reducer;
|
||||||
@@ -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';
|
||||||
|
|
||||||
@@ -74,18 +73,11 @@ function normaliseComments(data) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchThreadComments(
|
export function fetchThreadComments(threadId, { page = 1, endorsed = EndorsementStatus.DISCUSSION } = {}) {
|
||||||
threadId,
|
|
||||||
{
|
|
||||||
page = 1,
|
|
||||||
reverseOrder,
|
|
||||||
endorsed = EndorsementStatus.DISCUSSION,
|
|
||||||
} = {},
|
|
||||||
) {
|
|
||||||
return async (dispatch) => {
|
return async (dispatch) => {
|
||||||
try {
|
try {
|
||||||
dispatch(fetchCommentsRequest());
|
dispatch(fetchCommentsRequest());
|
||||||
const data = await getThreadComments(threadId, { page, reverseOrder, endorsed });
|
const data = await getThreadComments(threadId, { page, endorsed });
|
||||||
dispatch(fetchCommentsSuccess({
|
dispatch(fetchCommentsSuccess({
|
||||||
...normaliseComments(camelCaseObject(data)),
|
...normaliseComments(camelCaseObject(data)),
|
||||||
endorsed,
|
endorsed,
|
||||||
@@ -124,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());
|
||||||
2
src/discussions/comments/index.js
Normal file
2
src/discussions/comments/index.js
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
/* eslint-disable import/prefer-default-export */
|
||||||
|
export { default as CommentsView } from './CommentsView';
|
||||||
@@ -1,26 +1,21 @@
|
|||||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
addComment: {
|
|
||||||
id: 'discussions.comments.comment.addComment',
|
|
||||||
defaultMessage: 'Add comment',
|
|
||||||
description: 'Button to add a comment to a response',
|
|
||||||
},
|
|
||||||
addResponse: {
|
addResponse: {
|
||||||
id: 'discussions.comments.comment.addResponse',
|
id: 'discussions.comments.comment.addResponse',
|
||||||
defaultMessage: 'Add a response',
|
defaultMessage: 'Add a response',
|
||||||
description: 'Button to add a response to a response',
|
description: 'Button to add a response in a thread of forum posts',
|
||||||
|
},
|
||||||
|
addComment: {
|
||||||
|
id: 'discussions.comments.comment.addComment',
|
||||||
|
defaultMessage: 'Add a comment',
|
||||||
|
description: 'Button to add a comment to a response',
|
||||||
},
|
},
|
||||||
abuseFlaggedMessage: {
|
abuseFlaggedMessage: {
|
||||||
id: 'discussions.comments.comment.abuseFlaggedMessage',
|
id: 'discussions.comments.comment.abuseFlaggedMessage',
|
||||||
defaultMessage: 'Content reported for staff to review',
|
defaultMessage: 'Content reported for staff to review',
|
||||||
description: 'Alert banner over comment that has been reported for abuse',
|
description: 'Alert banner over comment that has been reported for abuse',
|
||||||
},
|
},
|
||||||
backAlt: {
|
|
||||||
id: 'discussions.actions.back.alt',
|
|
||||||
defaultMessage: 'Back to list',
|
|
||||||
description: 'Back to Posts list button text',
|
|
||||||
},
|
|
||||||
responseCount: {
|
responseCount: {
|
||||||
id: 'discussions.comments.comment.responseCount',
|
id: 'discussions.comments.comment.responseCount',
|
||||||
defaultMessage: `{num, plural,
|
defaultMessage: `{num, plural,
|
||||||
@@ -60,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,51 +142,16 @@ const messages = defineMessages({
|
|||||||
defaultMessage: 'Are you sure you want to permanently delete this comment?',
|
defaultMessage: 'Are you sure you want to permanently delete this comment?',
|
||||||
description: 'Text displayed in confirmation dialog when deleting a comment',
|
description: 'Text displayed in confirmation dialog when deleting a comment',
|
||||||
},
|
},
|
||||||
deleteConfirmationDelete: {
|
|
||||||
id: 'discussions.delete.confirmation.button.delete',
|
|
||||||
defaultMessage: 'Delete',
|
|
||||||
description: 'Delete button shown on delete confirmation dialog',
|
|
||||||
},
|
|
||||||
reportResponseTitle: {
|
|
||||||
id: 'discussions.editor.response.response.title',
|
|
||||||
defaultMessage: 'Report inappropriate content?',
|
|
||||||
description: 'Title of confirmation dialog shown when reporting a response',
|
|
||||||
},
|
|
||||||
reportResponseDescription: {
|
|
||||||
id: 'discussions.editor.response.description',
|
|
||||||
defaultMessage: 'The discussion moderation team will review this content and take appropriate action.',
|
|
||||||
description: 'Text displayed in confirmation dialog when deleting a response',
|
|
||||||
},
|
|
||||||
reportCommentTitle: {
|
|
||||||
id: 'discussions.editor.report.comment.title',
|
|
||||||
defaultMessage: 'Report inappropriate content?',
|
|
||||||
description: 'Title of confirmation dialog shown when reporting a comment',
|
|
||||||
},
|
|
||||||
reportCommentDescription: {
|
|
||||||
id: 'discussions.editor.report.comment.description',
|
|
||||||
defaultMessage: 'The discussion moderation team will review this content and take appropriate action.',
|
|
||||||
description: 'Text displayed in confirmation dialog when deleting a response',
|
|
||||||
},
|
|
||||||
editReasonCode: {
|
editReasonCode: {
|
||||||
id: 'discussions.editor.comments.editReasonCode',
|
id: 'discussions.editor.comments.editReasonCode',
|
||||||
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',
|
||||||
description: 'Text shown to users to indicate who edited a post. Followed by the username of editor.',
|
description: 'Text shown to users to indicate who edited a post. Followed by the username of editor.',
|
||||||
},
|
},
|
||||||
fullStop: {
|
|
||||||
id: 'discussions.comment.comments.fullStop',
|
|
||||||
defaultMessage: '•',
|
|
||||||
description: 'Fullstop shown to users to indicate who edited a post. Followed by a reason.',
|
|
||||||
},
|
|
||||||
reason: {
|
reason: {
|
||||||
id: 'discussions.comment.comments.reason',
|
id: 'discussions.comment.comments.reason',
|
||||||
defaultMessage: 'Reason',
|
defaultMessage: 'Reason',
|
||||||
@@ -202,25 +161,6 @@ const messages = defineMessages({
|
|||||||
id: 'discussions.post.closedBy',
|
id: 'discussions.post.closedBy',
|
||||||
defaultMessage: 'Post closed by',
|
defaultMessage: 'Post closed by',
|
||||||
},
|
},
|
||||||
time: {
|
|
||||||
id: 'discussion.comment.time',
|
|
||||||
defaultMessage: '{time} ago',
|
|
||||||
description: 'Time text for endorse banner',
|
|
||||||
},
|
|
||||||
noThreadFound: {
|
|
||||||
id: 'discussion.thread.notFound',
|
|
||||||
defaultMessage: 'Thread not found',
|
|
||||||
description: 'message to show on screen if the request thread is not found in course',
|
|
||||||
},
|
|
||||||
commentSort: {
|
|
||||||
id: 'discussions.comment.sortFilterStatus',
|
|
||||||
defaultMessage: `{sort, select,
|
|
||||||
false {Oldest first}
|
|
||||||
true {Newest first}
|
|
||||||
other {{sort}}
|
|
||||||
}`,
|
|
||||||
description: 'sort message showing current sorting',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export default messages;
|
export default messages;
|
||||||
@@ -1,35 +1,28 @@
|
|||||||
import React, { useContext, 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 { selectBlackoutDate } from '../data/selectors';
|
import { commentShape } from '../comments/comment/proptypes';
|
||||||
import messages from '../messages';
|
import messages from '../messages';
|
||||||
import { commentShape } from '../post-comments/comments/comment/proptypes';
|
|
||||||
import { postShape } from '../posts/post/proptypes';
|
import { postShape } from '../posts/post/proptypes';
|
||||||
import { inBlackoutDateRange, useActions } from '../utils';
|
import { useActions } from '../utils';
|
||||||
import { DiscussionContext } from './context';
|
|
||||||
|
|
||||||
function ActionsDropdown({
|
function ActionsDropdown({
|
||||||
intl,
|
intl,
|
||||||
commentOrPost,
|
commentOrPost,
|
||||||
disabled,
|
disabled,
|
||||||
actionHandlers,
|
actionHandlers,
|
||||||
iconSize,
|
|
||||||
dropDownIconSize,
|
|
||||||
}) {
|
}) {
|
||||||
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 { enableInContextSidebar } = useContext(DiscussionContext);
|
|
||||||
const handleActions = (action) => {
|
const handleActions = (action) => {
|
||||||
const actionFunction = actionHandlers[action];
|
const actionFunction = actionHandlers[action];
|
||||||
if (actionFunction) {
|
if (actionFunction) {
|
||||||
@@ -38,56 +31,48 @@ 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={iconSize}
|
disabled={disabled}
|
||||||
ref={setTarget}
|
/>
|
||||||
iconClassNames={dropDownIconSize ? 'dropdown-icon-dimentions' : ''}
|
</span>
|
||||||
/>
|
<ModalPopup
|
||||||
<div className="actions-dropdown">
|
onClose={() => setOpen(false)}
|
||||||
<ModalPopup
|
positionRef={dropdownIconRef}
|
||||||
onClose={close}
|
isOpen={isOpen}
|
||||||
positionRef={target}
|
placement="auto-start"
|
||||||
isOpen={isOpen}
|
>
|
||||||
placement={enableInContextSidebar ? 'left' : 'auto-start'}
|
<div
|
||||||
|
className="bg-white p-1 shadow d-flex flex-column"
|
||||||
|
data-testid="actions-dropdown-modal-popup"
|
||||||
>
|
>
|
||||||
<div
|
{actions.map(action => (
|
||||||
className="bg-white p-1 shadow d-flex flex-column"
|
<React.Fragment key={action.id}>
|
||||||
data-testid="actions-dropdown-modal-popup"
|
{action.action === ContentActions.DELETE
|
||||||
>
|
&& <Dropdown.Divider />}
|
||||||
{actions.map(action => (
|
|
||||||
<React.Fragment key={action.id}>
|
|
||||||
{(action.action === ContentActions.DELETE)
|
|
||||||
&& <Dropdown.Divider />}
|
|
||||||
|
|
||||||
<Dropdown.Item
|
<Dropdown.Item
|
||||||
as={Button}
|
as={Button}
|
||||||
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"
|
||||||
>
|
>
|
||||||
<Icon src={action.icon} className="mr-1" /> {intl.formatMessage(action.label)}
|
<Icon src={action.icon} className="mr-1" /> {intl.formatMessage(action.label)}
|
||||||
</Dropdown.Item>
|
</Dropdown.Item>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</ModalPopup>
|
</ModalPopup>
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -97,14 +82,10 @@ ActionsDropdown.propTypes = {
|
|||||||
commentOrPost: PropTypes.oneOfType([commentShape, postShape]).isRequired,
|
commentOrPost: PropTypes.oneOfType([commentShape, postShape]).isRequired,
|
||||||
disabled: PropTypes.bool,
|
disabled: PropTypes.bool,
|
||||||
actionHandlers: PropTypes.objectOf(PropTypes.func).isRequired,
|
actionHandlers: PropTypes.objectOf(PropTypes.func).isRequired,
|
||||||
iconSize: PropTypes.string,
|
|
||||||
dropDownIconSize: PropTypes.bool,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
ActionsDropdown.defaultProps = {
|
ActionsDropdown.defaultProps = {
|
||||||
disabled: false,
|
disabled: false,
|
||||||
iconSize: 'sm',
|
|
||||||
dropDownIconSize: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(ActionsDropdown);
|
export default injectIntl(ActionsDropdown);
|
||||||
|
|||||||
@@ -9,12 +9,11 @@ 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';
|
||||||
|
|
||||||
import '../post-comments/data/__factories__';
|
import '../comments/data/__factories__';
|
||||||
import '../posts/data/__factories__';
|
import '../posts/data/__factories__';
|
||||||
|
|
||||||
let store;
|
let store;
|
||||||
@@ -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,80 +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 { Report } from '@edx/paragon/icons';
|
import { CheckCircle, Error, Verified } from '@edx/paragon/icons';
|
||||||
|
|
||||||
import {
|
import { ThreadType } from '../../data/constants';
|
||||||
selectModerationSettings, selectUserHasModerationPrivileges, selectUserIsGroupTa, selectUserIsStaff,
|
import { commentShape } from '../comments/comment/proptypes';
|
||||||
} from '../data/selectors';
|
import messages from '../comments/messages';
|
||||||
import { commentShape } from '../post-comments/comments/comment/proptypes';
|
import { selectModerationSettings, selectUserIsPrivileged } from '../data/selectors';
|
||||||
import messages from '../post-comments/messages';
|
|
||||||
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 userIsGlobalStaff = useSelector(selectUserIsStaff);
|
const iconClass = isQuestion ? CheckCircle : Verified;
|
||||||
|
const userIsPrivileged = useSelector(selectUserIsPrivileged);
|
||||||
const { reasonCodesEnabled } = useSelector(selectModerationSettings);
|
const { reasonCodesEnabled } = useSelector(selectModerationSettings);
|
||||||
const userIsContentAuthor = getAuthenticatedUser().username === content.author;
|
|
||||||
const canSeeReportedBanner = content?.abuseFlagged;
|
|
||||||
const canSeeLastEditOrClosedAlert = (userHasModerationPrivileges || userIsGroupTa
|
|
||||||
|| userIsGlobalStaff || userIsContentAuthor
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{canSeeReportedBanner && (
|
{content.endorsed && (
|
||||||
<Alert icon={Report} variant="danger" className="px-3 mb-1 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-1 py-10px bg-light-200">
|
{intl.formatMessage(messages.editedBy)}
|
||||||
<div className="d-flex align-items-center flex-wrap text-gray-700 font-style">
|
<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 postOrComment />
|
{intl.formatMessage(messages.reason)}: {content.lastEdit.reason}
|
||||||
</span>
|
</div>
|
||||||
<span
|
</Alert>
|
||||||
className="mx-1.5 font-size-8 font-style text-light-700"
|
)}
|
||||||
style={{ lineHeight: '15px' }}
|
{reasonCodesEnabled && content.closed && (
|
||||||
>
|
<Alert variant="info" className="p-3 m-0 shadow-none mb-1 bg-light-200">
|
||||||
{intl.formatMessage(messages.fullStop)}
|
<div className="d-flex align-items-center">
|
||||||
</span>
|
{intl.formatMessage(messages.closedBy)}
|
||||||
{intl.formatMessage(messages.reason)}: {content.lastEdit.reason}
|
<span className="ml-1 ">
|
||||||
</div>
|
<AuthorLabel author={content.closedBy} linkToProfile />
|
||||||
</Alert>
|
</span>
|
||||||
)}
|
<span className="mx-1" />
|
||||||
{content.closed && (
|
{intl.formatMessage(messages.reason)}: {content.closeReason}
|
||||||
<Alert variant="info" className="px-3 shadow-none mb-1 py-10px bg-light-200">
|
</div>
|
||||||
<div className="d-flex align-items-center flex-wrap text-gray-700 font-style">
|
</Alert>
|
||||||
{intl.formatMessage(messages.closedBy)}
|
|
||||||
<span className="ml-1 ">
|
|
||||||
<AuthorLabel author={content.closedBy} linkToProfile postOrComment />
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className="mx-1.5 font-size-8 font-style text-light-700"
|
|
||||||
style={{ lineHeight: '15px' }}
|
|
||||||
>
|
|
||||||
{intl.formatMessage(messages.fullStop)}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{content.closeReason && (`${intl.formatMessage(messages.reason)}: ${content.closeReason}`)}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -84,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);
|
||||||
|
|||||||
@@ -7,11 +7,10 @@ import { AppProvider } from '@edx/frontend-platform/react';
|
|||||||
|
|
||||||
import { ThreadType } from '../../data/constants';
|
import { ThreadType } from '../../data/constants';
|
||||||
import { initializeStore } from '../../store';
|
import { initializeStore } from '../../store';
|
||||||
import messages from '../post-comments/messages';
|
import messages from '../comments/messages';
|
||||||
import AlertBanner from './AlertBanner';
|
import AlertBanner from './AlertBanner';
|
||||||
import { DiscussionContext } from './context';
|
|
||||||
|
|
||||||
import '../post-comments/data/__factories__';
|
import '../comments/data/__factories__';
|
||||||
import '../posts/data/__factories__';
|
import '../posts/data/__factories__';
|
||||||
|
|
||||||
let store;
|
let store;
|
||||||
@@ -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,20 +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 * as timeago from 'timeago.js';
|
|
||||||
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||||
import { Icon, OverlayTrigger, Tooltip } 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';
|
|
||||||
import timeLocale from './time-locale';
|
|
||||||
|
|
||||||
function AuthorLabel({
|
function AuthorLabel({
|
||||||
intl,
|
intl,
|
||||||
@@ -22,17 +15,9 @@ function AuthorLabel({
|
|||||||
authorLabel,
|
authorLabel,
|
||||||
linkToProfile,
|
linkToProfile,
|
||||||
labelColor,
|
labelColor,
|
||||||
alert,
|
|
||||||
postCreatedAt,
|
|
||||||
authorToolTip,
|
|
||||||
postOrComment,
|
|
||||||
}) {
|
}) {
|
||||||
const location = useLocation();
|
|
||||||
const { courseId } = useContext(DiscussionContext);
|
|
||||||
let icon = null;
|
let icon = null;
|
||||||
let authorLabelMessage = null;
|
let authorLabelMessage = null;
|
||||||
timeago.register('time-locale', timeLocale);
|
|
||||||
|
|
||||||
if (authorLabel === 'Staff') {
|
if (authorLabel === 'Staff') {
|
||||||
icon = Institution;
|
icon = Institution;
|
||||||
authorLabelMessage = intl.formatMessage(messages.authorLabelStaff);
|
authorLabelMessage = intl.formatMessage(messages.authorLabelStaff);
|
||||||
@@ -41,116 +26,43 @@ 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 showTextPrimary = !authorLabelMessage && !isRetiredUser && !alert;
|
|
||||||
|
|
||||||
const className = classNames('d-flex align-items-center', { 'mb-0.5': !postOrComment }, labelColor);
|
|
||||||
|
|
||||||
const showUserNameAsLink = useShowLearnersTab()
|
|
||||||
&& linkToProfile && author && author !== intl.formatMessage(messages.anonymous);
|
|
||||||
|
|
||||||
const labelContents = (
|
const labelContents = (
|
||||||
<div className={className}>
|
<>
|
||||||
{!alert && (
|
<span className="mr-1">{author}</span>
|
||||||
<span
|
{icon && (
|
||||||
className={classNames('mr-1.5 font-size-14 font-style font-weight-500', {
|
<Icon
|
||||||
'text-gray-700': isRetiredUser,
|
style={{
|
||||||
'text-primary-500': !authorLabelMessage && !isRetiredUser,
|
width: '1rem',
|
||||||
})}
|
height: '1rem',
|
||||||
role="heading"
|
}}
|
||||||
aria-level="2"
|
src={icon}
|
||||||
>
|
/>
|
||||||
{isRetiredUser ? '[Deactivated]' : author}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<OverlayTrigger
|
|
||||||
overlay={(
|
|
||||||
<Tooltip id={`endorsed-by-${author}-tooltip`}>
|
|
||||||
{author}
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
|
|
||||||
trigger={['hover', 'focus']}
|
|
||||||
>
|
|
||||||
<div className={classNames('d-flex flex-row align-items-center', {
|
|
||||||
'disable-div': !authorToolTip,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
style={{
|
|
||||||
width: '1rem',
|
|
||||||
height: '1rem',
|
|
||||||
}}
|
|
||||||
src={icon}
|
|
||||||
data-testid="author-icon"
|
|
||||||
/>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</OverlayTrigger>
|
|
||||||
{authorLabelMessage && (
|
{authorLabelMessage && (
|
||||||
<span
|
<span className="mr-3 ml-1">
|
||||||
className={classNames('mr-1.5 font-size-14 font-style font-weight-500', {
|
|
||||||
'text-primary-500': showTextPrimary,
|
|
||||||
'text-gray-700': isRetiredUser,
|
|
||||||
})}
|
|
||||||
style={{ marginLeft: '2px' }}
|
|
||||||
>
|
|
||||||
{authorLabelMessage}
|
{authorLabelMessage}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{postCreatedAt && (
|
</>
|
||||||
<span
|
|
||||||
title={postCreatedAt}
|
|
||||||
className={classNames('font-family-inter align-content-center', {
|
|
||||||
'text-white': alert,
|
|
||||||
'text-gray-500': !alert,
|
|
||||||
})}
|
|
||||||
style={{ lineHeight: '20px', fontSize: '12px', marginBottom: '-2.3px' }}
|
|
||||||
>
|
|
||||||
{timeago.format(postCreatedAt, 'time-locale')}
|
|
||||||
</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,
|
||||||
labelColor: PropTypes.string,
|
labelColor: PropTypes.string,
|
||||||
alert: PropTypes.bool,
|
|
||||||
postCreatedAt: PropTypes.string,
|
|
||||||
authorToolTip: PropTypes.bool,
|
|
||||||
postOrComment: PropTypes.bool,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
AuthorLabel.defaultProps = {
|
AuthorLabel.defaultProps = {
|
||||||
linkToProfile: false,
|
linkToProfile: false,
|
||||||
authorLabel: null,
|
authorLabel: null,
|
||||||
labelColor: '',
|
labelColor: '',
|
||||||
alert: false,
|
|
||||||
postCreatedAt: null,
|
|
||||||
authorToolTip: false,
|
|
||||||
postOrComment: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(AuthorLabel);
|
export default injectIntl(AuthorLabel);
|
||||||
|
|||||||
@@ -1,108 +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 { getCourseConfigApiUrl } from '../data/api';
|
|
||||||
import { fetchCourseConfig } from '../data/thunks';
|
|
||||||
import AuthorLabel from './AuthorLabel';
|
|
||||||
import { DiscussionContext } from './context';
|
|
||||||
|
|
||||||
const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
|
||||||
const courseConfigApiUrl = getCourseConfigApiUrl();
|
|
||||||
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 });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -6,19 +6,16 @@ import { ActionRow, Button, ModalDialog } from '@edx/paragon';
|
|||||||
|
|
||||||
import messages from '../messages';
|
import messages from '../messages';
|
||||||
|
|
||||||
function Confirmation({
|
function DeleteConfirmation({
|
||||||
intl,
|
intl,
|
||||||
isOpen,
|
isOpen,
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
onClose,
|
onClose,
|
||||||
comfirmAction,
|
onDelete,
|
||||||
closeButtonVaraint,
|
|
||||||
confirmButtonVariant,
|
|
||||||
confirmButtonText,
|
|
||||||
}) {
|
}) {
|
||||||
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}
|
||||||
@@ -29,11 +26,11 @@ function Confirmation({
|
|||||||
</ModalDialog.Body>
|
</ModalDialog.Body>
|
||||||
<ModalDialog.Footer>
|
<ModalDialog.Footer>
|
||||||
<ActionRow>
|
<ActionRow>
|
||||||
<ModalDialog.CloseButton variant={closeButtonVaraint}>
|
<ModalDialog.CloseButton variant="tertiary">
|
||||||
{intl.formatMessage(messages.confirmationCancel)}
|
{intl.formatMessage(messages.deleteConfirmationCancel)}
|
||||||
</ModalDialog.CloseButton>
|
</ModalDialog.CloseButton>
|
||||||
<Button variant={confirmButtonVariant} onClick={comfirmAction}>
|
<Button variant="primary" onClick={onDelete}>
|
||||||
{ confirmButtonText || intl.formatMessage(messages.confirmationConfirm)}
|
{intl.formatMessage(messages.deleteConfirmationDelete)}
|
||||||
</Button>
|
</Button>
|
||||||
</ActionRow>
|
</ActionRow>
|
||||||
</ModalDialog.Footer>
|
</ModalDialog.Footer>
|
||||||
@@ -41,22 +38,13 @@ function Confirmation({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Confirmation.propTypes = {
|
DeleteConfirmation.propTypes = {
|
||||||
intl: intlShape.isRequired,
|
intl: intlShape.isRequired,
|
||||||
isOpen: PropTypes.bool.isRequired,
|
isOpen: PropTypes.bool.isRequired,
|
||||||
onClose: PropTypes.func.isRequired,
|
onClose: PropTypes.func.isRequired,
|
||||||
comfirmAction: PropTypes.func.isRequired,
|
onDelete: PropTypes.func.isRequired,
|
||||||
title: PropTypes.string.isRequired,
|
title: PropTypes.string.isRequired,
|
||||||
description: PropTypes.string.isRequired,
|
description: PropTypes.string.isRequired,
|
||||||
closeButtonVaraint: PropTypes.string,
|
|
||||||
confirmButtonVariant: PropTypes.string,
|
|
||||||
confirmButtonText: PropTypes.string,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
Confirmation.defaultProps = {
|
export default injectIntl(DeleteConfirmation);
|
||||||
closeButtonVaraint: 'default',
|
|
||||||
confirmButtonVariant: 'primary',
|
|
||||||
confirmButtonText: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
export default injectIntl(Confirmation);
|
|
||||||
@@ -1,73 +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, Icon } from '@edx/paragon';
|
|
||||||
import { CheckCircle, Verified } from '@edx/paragon/icons';
|
|
||||||
|
|
||||||
import { ThreadType } from '../../data/constants';
|
|
||||||
import { commentShape } from '../post-comments/comments/comment/proptypes';
|
|
||||||
import messages from '../post-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-2.5 mb-0 py-8px align-items-center shadow-none ${classes}`}
|
|
||||||
style={{ borderRadius: '0.375rem 0.375rem 0 0' }}
|
|
||||||
>
|
|
||||||
<div className="d-flex justify-content-between flex-wrap">
|
|
||||||
<div className="d-flex align-items-center">
|
|
||||||
<Icon
|
|
||||||
src={iconClass}
|
|
||||||
style={{
|
|
||||||
width: '21px',
|
|
||||||
height: '20px',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<strong className="ml-2 font-family-inter">
|
|
||||||
{intl.formatMessage(isQuestion ? messages.answer : messages.endorsed)}
|
|
||||||
</strong>
|
|
||||||
</div>
|
|
||||||
<span className="d-flex align-items-center align-items-center flex-wrap" style={{ marginRight: '-1px' }}>
|
|
||||||
<AuthorLabel
|
|
||||||
author={content.endorsedBy}
|
|
||||||
authorLabel={content.endorsedByLabel}
|
|
||||||
linkToProfile
|
|
||||||
alert={content.endorsed}
|
|
||||||
postCreatedAt={content.endorsedAt}
|
|
||||||
authorToolTip
|
|
||||||
postOrComment
|
|
||||||
/>
|
|
||||||
</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 '../post-comments/messages';
|
|
||||||
import { DiscussionContext } from './context';
|
|
||||||
import EndorsedAlertBanner from './EndorsedAlertBanner';
|
|
||||||
|
|
||||||
import '../post-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, '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, 'TA'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'endorsed comment in a discussion thread',
|
|
||||||
type: 'comment',
|
|
||||||
postType: ThreadType.DISCUSSION,
|
|
||||||
props: { endorsed: true, endorsedBy: 'test-user' },
|
|
||||||
expectText: [messages.endorsed.defaultMessage],
|
|
||||||
},
|
|
||||||
])('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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
import React, { useContext } from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
|
|
||||||
import classNames from 'classnames';
|
|
||||||
|
|
||||||
import { injectIntl } from '@edx/frontend-platform/i18n';
|
|
||||||
import { Button, Icon, IconButton } from '@edx/paragon';
|
|
||||||
|
|
||||||
import {
|
|
||||||
StarFilled, StarOutline, ThumbUpFilled, ThumbUpOutline,
|
|
||||||
} from '../../components/icons';
|
|
||||||
import { useUserCanAddThreadInBlackoutDate } from '../data/hooks';
|
|
||||||
import { commentShape } from '../post-comments/comments/comment/proptypes';
|
|
||||||
import { postShape } from '../posts/post/proptypes';
|
|
||||||
import ActionsDropdown from './ActionsDropdown';
|
|
||||||
import { DiscussionContext } from './context';
|
|
||||||
|
|
||||||
function HoverCard({
|
|
||||||
commentOrPost,
|
|
||||||
actionHandlers,
|
|
||||||
handleResponseCommentButton,
|
|
||||||
addResponseCommentButtonMessage,
|
|
||||||
onLike,
|
|
||||||
onFollow,
|
|
||||||
isClosedPost,
|
|
||||||
endorseIcons,
|
|
||||||
}) {
|
|
||||||
const { enableInContextSidebar } = useContext(DiscussionContext);
|
|
||||||
const userCanAddThreadInBlackoutDate = useUserCanAddThreadInBlackoutDate();
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="flex-fill justify-content-end align-items-center hover-card mr-n4 position-absolute"
|
|
||||||
data-testid={`hover-card-${commentOrPost.id}`}
|
|
||||||
id={`hover-card-${commentOrPost.id}`}
|
|
||||||
>
|
|
||||||
{userCanAddThreadInBlackoutDate && (
|
|
||||||
<div className="d-flex">
|
|
||||||
<Button
|
|
||||||
variant="tertiary"
|
|
||||||
className={classNames('px-2.5 py-2 border-0 font-style text-gray-700 font-size-12',
|
|
||||||
{ 'w-100': enableInContextSidebar })}
|
|
||||||
onClick={() => handleResponseCommentButton()}
|
|
||||||
disabled={isClosedPost}
|
|
||||||
style={{ lineHeight: '20px' }}
|
|
||||||
>
|
|
||||||
{addResponseCommentButtonMessage}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{endorseIcons && (
|
|
||||||
<div className="hover-button">
|
|
||||||
<IconButton
|
|
||||||
src={endorseIcons.icon}
|
|
||||||
iconAs={Icon}
|
|
||||||
onClick={() => {
|
|
||||||
const actionFunction = actionHandlers[endorseIcons.action];
|
|
||||||
actionFunction();
|
|
||||||
}}
|
|
||||||
className={['endorse', 'unendorse'].includes(endorseIcons.id) ? 'text-dark-500' : 'text-success-500'}
|
|
||||||
size="sm"
|
|
||||||
alt="Endorse"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="hover-button">
|
|
||||||
<IconButton
|
|
||||||
src={commentOrPost.voted ? ThumbUpFilled : ThumbUpOutline}
|
|
||||||
iconAs={Icon}
|
|
||||||
size="sm"
|
|
||||||
alt="Like"
|
|
||||||
iconClassNames="like-icon-dimentions"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
onLike();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{commentOrPost.following !== undefined && (
|
|
||||||
<div className="hover-button">
|
|
||||||
<IconButton
|
|
||||||
src={commentOrPost.following ? StarFilled : StarOutline}
|
|
||||||
iconAs={Icon}
|
|
||||||
size="sm"
|
|
||||||
alt="Follow"
|
|
||||||
iconClassNames="follow-icon-dimentions"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
onFollow();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="hover-button ml-auto">
|
|
||||||
<ActionsDropdown commentOrPost={commentOrPost} actionHandlers={actionHandlers} dropDownIconSize />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
HoverCard.propTypes = {
|
|
||||||
commentOrPost: PropTypes.oneOfType([commentShape, postShape]).isRequired,
|
|
||||||
actionHandlers: PropTypes.objectOf(PropTypes.func).isRequired,
|
|
||||||
handleResponseCommentButton: PropTypes.func.isRequired,
|
|
||||||
onLike: PropTypes.func.isRequired,
|
|
||||||
onFollow: PropTypes.func,
|
|
||||||
addResponseCommentButtonMessage: PropTypes.string.isRequired,
|
|
||||||
isClosedPost: PropTypes.bool.isRequired,
|
|
||||||
endorseIcons: PropTypes.objectOf(PropTypes.any),
|
|
||||||
};
|
|
||||||
|
|
||||||
HoverCard.defaultProps = {
|
|
||||||
onFollow: () => null,
|
|
||||||
endorseIcons: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default injectIntl(HoverCard);
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
import {
|
|
||||||
render, screen, waitFor,
|
|
||||||
within,
|
|
||||||
} from '@testing-library/react';
|
|
||||||
import MockAdapter from 'axios-mock-adapter';
|
|
||||||
import { IntlProvider } from 'react-intl';
|
|
||||||
import { MemoryRouter, Route } from 'react-router';
|
|
||||||
import { Factory } from 'rosie';
|
|
||||||
|
|
||||||
import { camelCaseObject, initializeMockApp } from '@edx/frontend-platform';
|
|
||||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
|
||||||
import { AppProvider } from '@edx/frontend-platform/react';
|
|
||||||
|
|
||||||
import { initializeStore } from '../../store';
|
|
||||||
import { executeThunk } from '../../test-utils';
|
|
||||||
import DiscussionContent from '../discussions-home/DiscussionContent';
|
|
||||||
import { getCommentsApiUrl } from '../post-comments/data/api';
|
|
||||||
import { getThreadsApiUrl } from '../posts/data/api';
|
|
||||||
import { fetchThreads } from '../posts/data/thunks';
|
|
||||||
import { DiscussionContext } from './context';
|
|
||||||
|
|
||||||
import '../posts/data/__factories__';
|
|
||||||
import '../post-comments/data/__factories__';
|
|
||||||
|
|
||||||
const commentsApiUrl = getCommentsApiUrl();
|
|
||||||
const threadsApiUrl = getThreadsApiUrl();
|
|
||||||
const discussionPostId = 'thread-1';
|
|
||||||
const questionPostId = 'thread-2';
|
|
||||||
const courseId = 'course-v1:edX+TestX+Test_Course';
|
|
||||||
const reverseOrder = false;
|
|
||||||
let store;
|
|
||||||
let axiosMock;
|
|
||||||
let container;
|
|
||||||
|
|
||||||
function mockAxiosReturnPagedComments() {
|
|
||||||
[null, false, true].forEach(endorsed => {
|
|
||||||
const postId = endorsed === null ? discussionPostId : questionPostId;
|
|
||||||
[1, 2].forEach(page => {
|
|
||||||
axiosMock
|
|
||||||
.onGet(commentsApiUrl, {
|
|
||||||
params: {
|
|
||||||
thread_id: postId,
|
|
||||||
page,
|
|
||||||
page_size: undefined,
|
|
||||||
requested_fields: 'profile_image',
|
|
||||||
endorsed,
|
|
||||||
reverse_order: reverseOrder,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.reply(200, Factory.build('commentsResult', { can_delete: true }, {
|
|
||||||
threadId: postId,
|
|
||||||
page,
|
|
||||||
pageSize: 1,
|
|
||||||
count: 2,
|
|
||||||
endorsed,
|
|
||||||
childCount: page === 1 ? 2 : 0,
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function mockAxiosReturnPagedCommentsResponses() {
|
|
||||||
const parentId = 'comment-1';
|
|
||||||
const commentsResponsesApiUrl = `${commentsApiUrl}${parentId}/`;
|
|
||||||
const paramsTemplate = {
|
|
||||||
page: undefined,
|
|
||||||
page_size: undefined,
|
|
||||||
requested_fields: 'profile_image',
|
|
||||||
};
|
|
||||||
|
|
||||||
for (let page = 1; page <= 2; page++) {
|
|
||||||
axiosMock
|
|
||||||
.onGet(commentsResponsesApiUrl, { params: { ...paramsTemplate, page } })
|
|
||||||
.reply(200, Factory.build('commentsResult', null, {
|
|
||||||
parentId,
|
|
||||||
page,
|
|
||||||
pageSize: 1,
|
|
||||||
count: 2,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderComponent(postId) {
|
|
||||||
const wrapper = render(
|
|
||||||
<IntlProvider locale="en">
|
|
||||||
<AppProvider store={store}>
|
|
||||||
<DiscussionContext.Provider
|
|
||||||
value={{ courseId }}
|
|
||||||
>
|
|
||||||
<MemoryRouter initialEntries={[`/${courseId}/posts/${postId}`]}>
|
|
||||||
<DiscussionContent />
|
|
||||||
<Route
|
|
||||||
path="*"
|
|
||||||
/>
|
|
||||||
</MemoryRouter>
|
|
||||||
</DiscussionContext.Provider>
|
|
||||||
</AppProvider>
|
|
||||||
</IntlProvider>,
|
|
||||||
);
|
|
||||||
container = wrapper.container;
|
|
||||||
return container;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('HoverCard', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
initializeMockApp({
|
|
||||||
authenticatedUser: {
|
|
||||||
userId: 3,
|
|
||||||
username: 'abc123',
|
|
||||||
administrator: true,
|
|
||||||
roles: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
store = initializeStore();
|
|
||||||
Factory.resetAll();
|
|
||||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
|
||||||
axiosMock.onGet(threadsApiUrl)
|
|
||||||
.reply(200, Factory.build('threadsResult'));
|
|
||||||
axiosMock.onPatch(new RegExp(`${commentsApiUrl}*`)).reply(({
|
|
||||||
url,
|
|
||||||
data,
|
|
||||||
}) => {
|
|
||||||
const commentId = url.match(/comments\/(?<id>[a-z1-9-]+)\//).groups.id;
|
|
||||||
const {
|
|
||||||
rawBody,
|
|
||||||
} = camelCaseObject(JSON.parse(data));
|
|
||||||
return [200, Factory.build('comment', {
|
|
||||||
id: commentId,
|
|
||||||
rendered_body: rawBody,
|
|
||||||
raw_body: rawBody,
|
|
||||||
})];
|
|
||||||
});
|
|
||||||
axiosMock.onPost(commentsApiUrl)
|
|
||||||
.reply(({ data }) => {
|
|
||||||
const {
|
|
||||||
rawBody,
|
|
||||||
threadId,
|
|
||||||
} = camelCaseObject(JSON.parse(data));
|
|
||||||
return [200, Factory.build(
|
|
||||||
'comment',
|
|
||||||
{
|
|
||||||
rendered_body: rawBody,
|
|
||||||
raw_body: rawBody,
|
|
||||||
thread_id: threadId,
|
|
||||||
},
|
|
||||||
)];
|
|
||||||
});
|
|
||||||
|
|
||||||
executeThunk(fetchThreads(courseId), store.dispatch, store.getState);
|
|
||||||
mockAxiosReturnPagedComments();
|
|
||||||
mockAxiosReturnPagedCommentsResponses();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('it should have hover card on post', async () => {
|
|
||||||
renderComponent(discussionPostId);
|
|
||||||
const post = screen.getByTestId('post-thread-1');
|
|
||||||
expect(within(post).getByTestId('hover-card-thread-1')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('it should have hover card on comment', async () => {
|
|
||||||
renderComponent(discussionPostId);
|
|
||||||
const comment = await waitFor(() => screen.findByTestId('comment-comment-1'));
|
|
||||||
expect(within(comment).getByTestId('hover-card-comment-1')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('it should show add response, like, follow and actions menu for hovered post', async () => {
|
|
||||||
renderComponent(discussionPostId);
|
|
||||||
const post = screen.getByTestId('post-thread-1');
|
|
||||||
const view = within(post).getByTestId('hover-card-thread-1');
|
|
||||||
expect(within(view).queryByRole('button', { name: /Add response/i })).toBeInTheDocument();
|
|
||||||
expect(within(view).getByRole('button', { name: /like/i })).toBeInTheDocument();
|
|
||||||
expect(within(view).queryByRole('button', { name: /follow/i })).toBeInTheDocument();
|
|
||||||
expect(within(view).queryByRole('button', { name: /actions menu/i })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('it should show add comment, Endorse, like and actions menu Buttons for hovered comment', async () => {
|
|
||||||
renderComponent(questionPostId);
|
|
||||||
const comment = await waitFor(() => screen.findByTestId('comment-comment-3'));
|
|
||||||
const view = within(comment).getByTestId('hover-card-comment-3');
|
|
||||||
expect(within(view).queryByRole('button', { name: /Add comment/i })).toBeInTheDocument();
|
|
||||||
expect(within(view).getByRole('button', { name: /Endorse/i })).toBeInTheDocument();
|
|
||||||
expect(within(view).queryByRole('button', { name: /like/i })).toBeInTheDocument();
|
|
||||||
expect(within(view).queryByRole('button', { name: /actions menu/i })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -2,11 +2,10 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
export const DiscussionContext = React.createContext({
|
export const DiscussionContext = React.createContext({
|
||||||
page: null,
|
|
||||||
courseId: null,
|
courseId: null,
|
||||||
postId: null,
|
postId: null,
|
||||||
topicId: null,
|
|
||||||
enableInContextSidebar: false,
|
|
||||||
category: null,
|
category: null,
|
||||||
|
commentId: null,
|
||||||
learnerUsername: null,
|
learnerUsername: null,
|
||||||
|
inContext: false,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
export { default as ActionsDropdown } from './ActionsDropdown';
|
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 Confirmation } from './Confirmation';
|
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']));
|
||||||
|
|||||||
@@ -7,14 +7,16 @@ ensureConfig([
|
|||||||
'LMS_BASE_URL',
|
'LMS_BASE_URL',
|
||||||
], 'Posts API service');
|
], 'Posts API service');
|
||||||
|
|
||||||
export const getCourseConfigApiUrl = () => `${getConfig().LMS_BASE_URL}/api/discussion/v1/courses/`;
|
const apiBaseUrl = getConfig().LMS_BASE_URL;
|
||||||
|
|
||||||
|
export const courseConfigApiUrl = `${apiBaseUrl}/api/discussion/v1/courses/`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get discussions course config
|
* Get discussions course config
|
||||||
* @param {string} courseId
|
* @param {string} courseId
|
||||||
*/
|
*/
|
||||||
export async function getDiscussionsConfig(courseId) {
|
export async function getDiscussionsConfig(courseId) {
|
||||||
const url = `${getCourseConfigApiUrl()}${courseId}/`;
|
const url = `${courseConfigApiUrl}${courseId}/`;
|
||||||
const { data } = await getAuthenticatedHttpClient().get(url);
|
const { data } = await getAuthenticatedHttpClient().get(url);
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
@@ -24,7 +26,7 @@ export async function getDiscussionsConfig(courseId) {
|
|||||||
* @param {string} courseId
|
* @param {string} courseId
|
||||||
*/
|
*/
|
||||||
export async function getDiscussionsSettings(courseId) {
|
export async function getDiscussionsSettings(courseId) {
|
||||||
const url = `${getCourseConfigApiUrl()}${courseId}/settings`;
|
const url = `${courseConfigApiUrl}${courseId}/settings`;
|
||||||
const { data } = await getAuthenticatedHttpClient().get(url);
|
const { data } = await getAuthenticatedHttpClient().get(url);
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,43 +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 { RequestStatus, 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 { threadsLoadingStatus } from '../posts/data/selectors';
|
|
||||||
import { selectTopics } from '../topics/data/selectors';
|
import { selectTopics } from '../topics/data/selectors';
|
||||||
import tourCheckpoints from '../tours/constants';
|
import { fetchCourseTopics } from '../topics/data/thunks';
|
||||||
import { selectTours } from '../tours/data/selectors';
|
import { discussionsPath, postMessageToParent } from '../utils';
|
||||||
import { updateTourShowStatus } from '../tours/data/thunks';
|
import { selectAreThreadsFiltered, selectPostThreadCount } from './selectors';
|
||||||
import messages from '../tours/messages';
|
|
||||||
import { discussionsPath, inBlackoutDateRange } from '../utils';
|
|
||||||
import {
|
|
||||||
selectAreThreadsFiltered,
|
|
||||||
selectBlackoutDate,
|
|
||||||
selectEnableInContext,
|
|
||||||
selectIsCourseAdmin,
|
|
||||||
selectIsCourseStaff,
|
|
||||||
selectLearnersTabEnabled,
|
|
||||||
selectModerationSettings,
|
|
||||||
selectPostThreadCount,
|
|
||||||
selectUserHasModerationPrivileges,
|
|
||||||
selectUserIsGroupTa,
|
|
||||||
selectUserIsStaff,
|
|
||||||
} from './selectors';
|
|
||||||
import { fetchCourseConfig } from './thunks';
|
import { fetchCourseConfig } from './thunks';
|
||||||
|
|
||||||
export function useTotalTopicThreadCount() {
|
export function useTotalTopicThreadCount() {
|
||||||
@@ -47,28 +23,27 @@ export function useTotalTopicThreadCount() {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Object.keys(topics)
|
return Object.keys(topics).reduce((total, topicId) => {
|
||||||
.reduce((total, topicId) => {
|
const topic = topics[topicId];
|
||||||
const topic = topics[topicId];
|
return total + topic.threadCounts.discussion + topic.threadCounts.question;
|
||||||
return total + topic.threadCounts.discussion + topic.threadCounts.question;
|
}, 0);
|
||||||
}, 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useSidebarVisible = () => {
|
export const useSidebarVisible = () => {
|
||||||
const enableInContext = useSelector(selectEnableInContext);
|
|
||||||
const isViewingTopics = useRouteMatch(Routes.TOPICS.ALL);
|
|
||||||
const isViewingLearners = useRouteMatch(Routes.LEARNERS.PATH);
|
|
||||||
const isFiltered = useSelector(selectAreThreadsFiltered);
|
const isFiltered = useSelector(selectAreThreadsFiltered);
|
||||||
const totalThreads = useSelector(selectPostThreadCount);
|
const totalThreads = useSelector(selectPostThreadCount);
|
||||||
const isThreadsEmpty = Boolean(useSelector(threadsLoadingStatus()) === RequestStatus.SUCCESSFUL && !totalThreads);
|
const isViewingTopics = useRouteMatch(Routes.TOPICS.PATH);
|
||||||
const isIncontextTopicsView = Boolean(useRouteMatch(Routes.TOPICS.PATH) && enableInContext);
|
const isViewingLearners = useRouteMatch(Routes.LEARNERS.PATH);
|
||||||
const hideSidebar = Boolean(isThreadsEmpty && !isFiltered && !(isViewingTopics?.isExact || isViewingLearners));
|
|
||||||
|
|
||||||
if (isIncontextTopicsView) {
|
if (isFiltered) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return !hideSidebar;
|
if (isViewingTopics || isViewingLearners) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return totalThreads > 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useCourseDiscussionData(courseId) {
|
export function useCourseDiscussionData(courseId) {
|
||||||
@@ -78,6 +53,7 @@ export function useCourseDiscussionData(courseId) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchBaseData() {
|
async function fetchBaseData() {
|
||||||
await dispatch(fetchCourseConfig(courseId));
|
await dispatch(fetchCourseConfig(courseId));
|
||||||
|
await dispatch(fetchCourseTopics(courseId));
|
||||||
await dispatch(fetchCourseBlocks(courseId, authenticatedUser.username));
|
await dispatch(fetchCourseBlocks(courseId, authenticatedUser.username));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,7 +61,7 @@ export function useCourseDiscussionData(courseId) {
|
|||||||
}, [courseId]);
|
}, [courseId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useRedirectToThread(courseId, enableInContextSidebar) {
|
export function useRedirectToThread(courseId) {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const redirectToThread = useSelector(
|
const redirectToThread = useSelector(
|
||||||
(state) => state.threads.redirectToThread,
|
(state) => state.threads.redirectToThread,
|
||||||
@@ -98,10 +74,9 @@ export function useRedirectToThread(courseId, enableInContextSidebar) {
|
|||||||
// stored in redirectToThread
|
// stored in redirectToThread
|
||||||
if (redirectToThread) {
|
if (redirectToThread) {
|
||||||
dispatch(clearRedirect());
|
dispatch(clearRedirect());
|
||||||
const newLocation = discussionsPath(Routes.COMMENTS.PAGES[enableInContextSidebar ? 'topics' : 'my-posts'], {
|
const newLocation = discussionsPath(Routes.COMMENTS.PAGES['my-posts'], {
|
||||||
courseId,
|
courseId,
|
||||||
postId: redirectToThread.threadId,
|
postId: redirectToThread.threadId,
|
||||||
topicId: redirectToThread.topicId,
|
|
||||||
})(location);
|
})(location);
|
||||||
history.push(newLocation);
|
history.push(newLocation);
|
||||||
}
|
}
|
||||||
@@ -110,138 +85,59 @@ export function useRedirectToThread(courseId, enableInContextSidebar) {
|
|||||||
|
|
||||||
export function useIsOnDesktop() {
|
export function useIsOnDesktop() {
|
||||||
const windowSize = useWindowSize();
|
const windowSize = useWindowSize();
|
||||||
return windowSize.width >= breakpoints.medium.minWidth;
|
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useUserCanAddThreadInBlackoutDate = () => {
|
|
||||||
const blackoutDateRange = useSelector(selectBlackoutDate);
|
|
||||||
const isUserAdmin = useSelector(selectUserIsStaff);
|
|
||||||
const userHasModerationPrivilages = useSelector(selectUserHasModerationPrivileges);
|
|
||||||
const isUserGroupTA = useSelector(selectUserIsGroupTa);
|
|
||||||
const isCourseAdmin = useSelector(selectIsCourseAdmin);
|
|
||||||
const isCourseStaff = useSelector(selectIsCourseStaff);
|
|
||||||
const isInBlackoutDateRange = inBlackoutDateRange(blackoutDateRange);
|
|
||||||
|
|
||||||
return (!(isInBlackoutDateRange)
|
|
||||||
|| (isUserAdmin || userHasModerationPrivilages || isUserGroupTA || isCourseAdmin || isCourseStaff));
|
|
||||||
};
|
|
||||||
|
|
||||||
function camelToConstant(string) {
|
|
||||||
return string.replace(/[A-Z]/g, (match) => `_${match}`).toUpperCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useTourConfiguration = (intl) => {
|
|
||||||
const dispatch = useDispatch();
|
|
||||||
|
|
||||||
const { enableInContextSidebar } = useContext(DiscussionContext);
|
|
||||||
const tours = useSelector(selectTours);
|
|
||||||
|
|
||||||
return tours.map((tour) => (
|
|
||||||
{
|
|
||||||
tourId: tour.tourName,
|
|
||||||
advanceButtonText: intl.formatMessage(messages.advanceButtonText),
|
|
||||||
dismissButtonText: intl.formatMessage(messages.dismissButtonText),
|
|
||||||
endButtonText: intl.formatMessage(messages.endButtonText),
|
|
||||||
enabled: tour && Boolean(tour.showTour && !enableInContextSidebar),
|
|
||||||
onDismiss: () => dispatch(updateTourShowStatus(tour.id)),
|
|
||||||
onEnd: () => dispatch(updateTourShowStatus(tour.id)),
|
|
||||||
checkpoints: tourCheckpoints(intl)[camelToConstant(tour.tourName)],
|
|
||||||
}
|
|
||||||
));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useDebounce = (value, delay) => {
|
|
||||||
// State and setters for debounced value
|
|
||||||
const [debouncedValue, setDebouncedValue] = useState(value);
|
|
||||||
useEffect(
|
|
||||||
() => {
|
|
||||||
// Update debounced value after delay
|
|
||||||
const handler = setTimeout(() => {
|
|
||||||
setDebouncedValue(value);
|
|
||||||
}, delay);
|
|
||||||
// Cancel the timeout if value changes (also on delay change or unmount)
|
|
||||||
// This is how we prevent debounced value from updating if value is changed ...
|
|
||||||
// .. within the delay period. Timeout gets cleared and restarted.
|
|
||||||
return () => {
|
|
||||||
clearTimeout(handler);
|
|
||||||
};
|
|
||||||
},
|
|
||||||
[value, delay], // Only re-call effect if value or delay changes
|
|
||||||
);
|
|
||||||
return debouncedValue;
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,175 +1,68 @@
|
|||||||
import { render } from '@testing-library/react';
|
import { useRef } from 'react';
|
||||||
import MockAdapter from 'axios-mock-adapter';
|
|
||||||
import { IntlProvider } from 'react-intl';
|
|
||||||
import { Factory } from 'rosie';
|
|
||||||
|
|
||||||
import { initializeMockApp } from '@edx/frontend-platform';
|
import { render, waitFor } from '@testing-library/react';
|
||||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
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 { executeThunk } from '../../test-utils';
|
import { useContainerSizeForParent } from './hooks';
|
||||||
import { DiscussionContext } from '../common/context';
|
|
||||||
import { getCourseConfigApiUrl } from './api';
|
|
||||||
import { useCurrentDiscussionTopic, useUserCanAddThreadInBlackoutDate } from './hooks';
|
|
||||||
import { fetchCourseConfig } from './thunks';
|
|
||||||
|
|
||||||
const courseId = 'course-v1:edX+TestX+Test_Course';
|
|
||||||
const courseConfigApiUrl = getCourseConfigApiUrl();
|
|
||||||
let store;
|
let store;
|
||||||
let axiosMock;
|
initializeMockApp();
|
||||||
|
|
||||||
const generateApiResponse = (blackouts = [], isCourseAdmin = false) => ({
|
|
||||||
blackouts,
|
|
||||||
hasModerationPrivileges: false,
|
|
||||||
isGroupTa: false,
|
|
||||||
isCourseAdmin,
|
|
||||||
isCourseStaff: false,
|
|
||||||
isUserAdmin: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
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;
|
||||||
initializeMockApp();
|
beforeEach(() => {
|
||||||
store = initializeStore({
|
store = initializeStore();
|
||||||
blocks: {
|
parent = window.parent;
|
||||||
blocks: {
|
|
||||||
'some-unit-key': { topics: ['some-topic-0'], parent: 'some-sequence-key' },
|
|
||||||
'some-sequence-key': { topics: ['some-topic-0'] },
|
|
||||||
'another-sequence-key': { topics: ['some-topic-1', 'some-topic-2'] },
|
|
||||||
'empty-key': { topics: [] },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
config: { provider: 'openedx' },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('when topicId is in context', () => {
|
|
||||||
const { queryByText } = renderComponent({ topicId: 'some-topic' });
|
|
||||||
expect(queryByText('some-topic')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('when the category is a unit', () => {
|
|
||||||
const { queryByText } = renderComponent({ category: 'some-unit-key' });
|
|
||||||
expect(queryByText('some-topic-0')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('when the category is a sequence with one unit', () => {
|
|
||||||
const { queryByText } = renderComponent({ category: 'some-sequence-key' });
|
|
||||||
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();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
afterEach(() => {
|
||||||
describe('useUserCanAddThreadInBlackoutDate', () => {
|
window.parent = parent;
|
||||||
function ComponentWithHook() {
|
});
|
||||||
const userCanAddThreadInBlackoutDate = useUserCanAddThreadInBlackoutDate();
|
test('useContainerSizeForParent enabled', async () => {
|
||||||
return (
|
delete window.parent;
|
||||||
<div>
|
window.parent = { ...window, postMessage: jest.fn() };
|
||||||
{String(userCanAddThreadInBlackoutDate)}
|
const { unmount } = renderComponent();
|
||||||
</div>
|
// Once for LMS and one for learning MFE
|
||||||
);
|
await waitFor(() => expect(window.parent.postMessage).toHaveBeenCalledTimes(2));
|
||||||
}
|
// Test that size is reset on unmount
|
||||||
|
unmount();
|
||||||
function renderComponent() {
|
await waitFor(() => expect(window.parent.postMessage).toHaveBeenCalledTimes(4));
|
||||||
return render(
|
expect(window.parent.postMessage).toHaveBeenLastCalledWith(
|
||||||
<IntlProvider locale="en">
|
{ type: 'plugin.resize', payload: { height: null } },
|
||||||
<AppProvider store={store}>
|
getConfig().LMS_BASE_URL,
|
||||||
<ComponentWithHook />
|
);
|
||||||
</AppProvider>
|
});
|
||||||
</IntlProvider>,
|
test('useContainerSizeForParent disabled', async () => {
|
||||||
);
|
window.parent.postMessage = jest.fn();
|
||||||
}
|
renderComponent();
|
||||||
describe('User can add Thread in blackoutdates ', () => {
|
await waitFor(() => expect(window.parent.postMessage).not.toHaveBeenCalled());
|
||||||
beforeEach(() => {
|
|
||||||
initializeMockApp({
|
|
||||||
authenticatedUser: {
|
|
||||||
userId: 3,
|
|
||||||
username: 'abc123',
|
|
||||||
administrator: true,
|
|
||||||
roles: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
|
||||||
Factory.resetAll();
|
|
||||||
store = initializeStore();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('when blackoutdates are not active and Role is Learner return true', async () => {
|
|
||||||
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`)
|
|
||||||
.reply(200, generateApiResponse([], false));
|
|
||||||
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
|
|
||||||
const { queryByText } = renderComponent();
|
|
||||||
expect(queryByText('true')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('when blackoutdates are not active and Role is not Learner return true', async () => {
|
|
||||||
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`)
|
|
||||||
.reply(200, generateApiResponse([], true));
|
|
||||||
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
|
|
||||||
const { queryByText } = renderComponent();
|
|
||||||
expect(queryByText('true')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('when blackoutdates are active and Role is Learner return false', async () => {
|
|
||||||
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`)
|
|
||||||
.reply(200, generateApiResponse([{
|
|
||||||
start: '2022-11-25T00:00:00Z',
|
|
||||||
end: '2050-11-25T23:59:00Z',
|
|
||||||
}], false));
|
|
||||||
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
|
|
||||||
const { queryByText } = renderComponent();
|
|
||||||
expect(queryByText('false')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('when blackoutdates are active and Role is not Learner return true', async () => {
|
|
||||||
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`)
|
|
||||||
.reply(200, generateApiResponse([
|
|
||||||
{ start: '2022-11-25T00:00:00Z', end: '2050-11-25T23:59:00Z' }], true));
|
|
||||||
const { queryByText } = renderComponent();
|
|
||||||
expect(queryByText('true')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,30 +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 selectIsCourseAdmin = state => state.config.isCourseAdmin;
|
|
||||||
|
|
||||||
export const selectIsCourseStaff = state => state.config.isCourseStaff;
|
|
||||||
|
|
||||||
export const selectEnableInContext = state => state.config.enableInContext;
|
|
||||||
|
|
||||||
export const selectModerationSettings = state => ({
|
export const selectModerationSettings = state => ({
|
||||||
postCloseReasons: state.config.postCloseReasons,
|
postCloseReasons: state.config.postCloseReasons,
|
||||||
editReasons: state.config.editReasons,
|
editReasons: state.config.editReasons,
|
||||||
@@ -53,7 +37,7 @@ export function selectAreThreadsFiltered(state) {
|
|||||||
|
|
||||||
export function selectTopicThreadCount(topicId) {
|
export function selectTopicThreadCount(topicId) {
|
||||||
return state => {
|
return state => {
|
||||||
const topic = topicId && state.topics?.topics[topicId];
|
const topic = state.topics.topics[topicId];
|
||||||
if (!topic) {
|
if (!topic) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,12 +11,7 @@ const configSlice = createSlice({
|
|||||||
allowAnonymous: false,
|
allowAnonymous: false,
|
||||||
allowAnonymousToPeers: false,
|
allowAnonymousToPeers: false,
|
||||||
userRoles: [],
|
userRoles: [],
|
||||||
groupAtSubsection: false,
|
userIsPrivileged: false,
|
||||||
hasModerationPrivileges: false,
|
|
||||||
isGroupTa: false,
|
|
||||||
isCourseAdmin: false,
|
|
||||||
isCourseStaff: false,
|
|
||||||
isUserAdmin: false,
|
|
||||||
learnersTabEnabled: false,
|
learnersTabEnabled: false,
|
||||||
settings: {
|
settings: {
|
||||||
divisionScheme: 'none',
|
divisionScheme: 'none',
|
||||||
@@ -27,7 +22,6 @@ const configSlice = createSlice({
|
|||||||
reasonCodesEnabled: false,
|
reasonCodesEnabled: false,
|
||||||
editReasons: [],
|
editReasons: [],
|
||||||
postCloseReasons: [],
|
postCloseReasons: [],
|
||||||
enableInContext: false,
|
|
||||||
},
|
},
|
||||||
reducers: {
|
reducers: {
|
||||||
fetchConfigRequest: (state) => {
|
fetchConfigRequest: (state) => {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user