Compare commits

..

3 Commits

Author SHA1 Message Date
Jawayria
32a100ea41 fix: try old package-lock 2022-04-18 17:43:14 +05:00
Jawayria
876211388a fix: regenerate package-lock 2022-04-18 14:57:51 +05:00
Jawayria
c409514766 fix: Updated dependencies for Node 16 compatibility 2022-04-18 14:24:19 +05:00
259 changed files with 8242 additions and 45502 deletions

5
.env
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

2
.nvmrc
View File

@@ -1 +1 @@
16 12

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1 +0,0 @@
export * from './slice';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,2 @@
export { default as PostActionsBar } from '../discussions/posts/post-actions-bar/PostActionsBar'; export { default as PostActionsBar } from '../discussions/posts/post-actions-bar/PostActionsBar';
export { default as Search } from './Search';
export { default as TinyMCEEditor } from './TinyMCEEditor'; export { default as TinyMCEEditor } from './TinyMCEEditor';

View File

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

View File

@@ -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',
@@ -74,9 +73,9 @@ export const RequestStatus = {
* @readonly * @readonly
* @enum {string} * @enum {string}
*/ */
export const AvatarOutlineAndLabelColors = { export const AvatarBorderAndLabelColors = {
Staff: 'staff-color', Staff: 'warning-700',
'Community TA': 'TA-color', 'Community TA': 'success-700',
}; };
/** /**
@@ -90,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
@@ -101,7 +110,6 @@ export const PostsStatusFilter = {
FOLLOWING: 'statusFollowing', FOLLOWING: 'statusFollowing',
REPORTED: 'statusReported', REPORTED: 'statusReported',
UNANSWERED: 'statusUnanswered', UNANSWERED: 'statusUnanswered',
UNRESPONDED: 'statusUnresponded',
}; };
/** /**
@@ -124,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',
}; };
/** /**
@@ -144,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`,
@@ -156,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;

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

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

View File

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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)}:&nbsp;{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)}:&nbsp;{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)}:&nbsp;{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);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,12 +2,6 @@
import { camelCaseObject } from '@edx/frontend-platform'; import { camelCaseObject } from '@edx/frontend-platform';
import { logError } from '@edx/frontend-platform/logging'; import { logError } from '@edx/frontend-platform/logging';
import {
DiscussionProvider, LearnersOrdering,
PostsStatusFilter,
} from '../../data/constants';
import { setSortedBy } from '../learners/data';
import { setStatusFilter } from '../posts/data';
import { getHttpErrorStatus } from '../utils'; import { getHttpErrorStatus } from '../utils';
import { getDiscussionsConfig, getDiscussionsSettings } from './api'; import { getDiscussionsConfig, getDiscussionsSettings } from './api';
import { import {
@@ -22,26 +16,13 @@ import {
export function fetchCourseConfig(courseId) { export function fetchCourseConfig(courseId) {
return async (dispatch) => { return async (dispatch) => {
try { try {
let learnerSort = LearnersOrdering.BY_LAST_ACTIVITY;
const postsFilterStatus = PostsStatusFilter.ALL;
dispatch(fetchConfigRequest()); dispatch(fetchConfigRequest());
const config = await getDiscussionsConfig(courseId); const config = await getDiscussionsConfig(courseId);
if (config.has_moderation_privileges) { if (config.is_user_admin) {
const settings = await getDiscussionsSettings(courseId); const settings = await getDiscussionsSettings(courseId);
Object.assign(config, { settings }); Object.assign(config, { settings });
} }
dispatch(fetchConfigSuccess(camelCaseObject(config)));
if ((config.has_moderation_privileges || config.is_group_ta)) {
learnerSort = LearnersOrdering.BY_FLAG;
}
dispatch(fetchConfigSuccess(camelCaseObject({
...config,
enable_in_context: config.provider === DiscussionProvider.OPEN_EDX,
})));
dispatch(setSortedBy(learnerSort));
dispatch(setStatusFilter(postsFilterStatus));
} catch (error) { } catch (error) {
if (getHttpErrorStatus(error) === 403) { if (getHttpErrorStatus(error) === 403) {
dispatch(fetchConfigDenied()); dispatch(fetchConfigDenied());

Some files were not shown because too many files have changed in this diff Show More