Compare commits

..

1 Commits

Author SHA1 Message Date
Kshitij Sobti
40ab53d793 feat: A prototype of dynamically loading themes 2022-04-22 11:26:22 +05:30
186 changed files with 11192 additions and 20394 deletions

6
.env
View File

@@ -15,11 +15,9 @@ LOGO_WHITE_URL=''
FAVICON_URL='' FAVICON_URL=''
MARKETING_SITE_BASE_URL='' MARKETING_SITE_BASE_URL=''
ORDER_HISTORY_URL='' ORDER_HISTORY_URL=''
POST_MARK_AS_READ_DELAY=2000
REFRESH_ACCESS_TOKEN_ENDPOINT='' REFRESH_ACCESS_TOKEN_ENDPOINT=''
SEGMENT_KEY='' SEGMENT_KEY=''
SITE_NAME='' SITE_NAME=''
USER_INFO_COOKIE_NAME='' USER_INFO_COOKIE_NAME=''
SUPPORT_URL='' THEME_LOADER_URL=''
TA_FEEDBACK_FORM= ''
STAFF_FEEDBACK_FORM= ''
DISPLAY_FEEDBACK_BANNER='false'

View File

@@ -16,11 +16,9 @@ LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
MARKETING_SITE_BASE_URL='http://localhost:18000' MARKETING_SITE_BASE_URL='http://localhost:18000'
ORDER_HISTORY_URL='http://localhost:1996/orders' ORDER_HISTORY_URL='http://localhost:1996/orders'
POST_MARK_AS_READ_DELAY=2000
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh' REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
SEGMENT_KEY='' SEGMENT_KEY=''
SITE_NAME=localhost SITE_NAME=localhost
USER_INFO_COOKIE_NAME='edx-user-info' USER_INFO_COOKIE_NAME='edx-user-info'
SUPPORT_URL='https://support.edx.org' THEME_LOADER_URL='https://xitij2000.github.io/frontend-theme-prototype/themes.js'
TA_FEEDBACK_FORM='https://learner-form.test'
STAFF_FEEDBACK_FORM='https://staff-form.test'
DISPLAY_FEEDBACK_BANNER='false'

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

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

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,17 +38,17 @@ push_translations:
# Pushing strings to Transifex... # Pushing strings to Transifex...
tx push -s tx push -s
# Fetching hashes from Transifex... # Fetching hashes from Transifex...
./node_modules/@edx/reactifex/bash_scripts/get_hashed_strings_v3.sh ./node_modules/reactifex/bash_scripts/get_hashed_strings.sh $(tx_url1)
# Writing out comments to file... # Writing out comments to file...
$(transifex_utils) $(transifex_temp) --comments --v3-scripts-path $(transifex_utils) $(transifex_temp) --comments
# Pushing comments to Transifex... # Pushing comments to Transifex...
./node_modules/@edx/reactifex/bash_scripts/put_comments_v3.sh ./node_modules/reactifex/bash_scripts/put_comments.sh $(tx_url2)
# Pulls translations from Transifex. # Pulls translations from Transifex.
pull_translations: pull_translations:
tx pull -t -f --mode reviewed --languages=$(transifex_langs) tx pull -f --mode reviewed --language=$(transifex_langs)
# This target is used by Travis. # This target is used by Travis.
validate-no-uncommitted-package-lock-changes: validate-no-uncommitted-package-lock-changes:
# Checking for package-lock.json changes... # Checking for package-lock.json changes...
git diff --exit-code package-lock.json git diff --exit-code package-lock.json

View File

@@ -14,7 +14,7 @@ This repository is a React-based micro frontend for the Open edX discussion foru
1. Clone your new repo: 1. Clone your new repo:
``git clone https://github.com/openedx/frontend-app-discussions.git`` ``git clone https://github.com/edx/frontend-app-discussions.git``
2. Install npm dependencies: 2. Install npm dependencies:
@@ -29,7 +29,7 @@ The dev server is running at `http://localhost:2002 <http://localhost:2002>`_.
Project Structure Project Structure
----------------- -----------------
The source for this project is organized into nested submodules according to the ADR `Feature-based Application Organization <https://github.com/openedx/frontend-app-discussions/blob/master/docs/decisions/0002-feature-based-application-organization.rst>`_. The source for this project is organized into nested submodules according to the ADR `Feature-based Application Organization <https://github.com/edx/frontend-app-discussions/blob/master/docs/decisions/0002-feature-based-application-organization.rst>`_.
Build Process Notes Build Process Notes
------------------- -------------------
@@ -41,7 +41,7 @@ The production build is created with ``npm run build``.
Internationalization Internationalization
-------------------- --------------------
Please see `edx/frontend-platform's i18n module <https://edx.github.io/frontend-platform/module-Internationalization.html>`_ for documentation on internationalization. The documentation explains how to use it, and the `How To <https://github.com/openedx/frontend-i18n/blob/master/docs/how_tos/i18n.rst>`_ has more detail. Please see `edx/frontend-platform's i18n module <https://edx.github.io/frontend-platform/module-Internationalization.html>`_ for documentation on internationalization. The documentation explains how to use it, and the `How To <https://github.com/edx/frontend-i18n/blob/master/docs/how_tos/i18n.rst>`_ has more detail.
.. |Build Status| image:: https://api.travis-ci.org/edx/frontend-app-discussions.svg?branch=master .. |Build Status| image:: https://api.travis-ci.org/edx/frontend-app-discussions.svg?branch=master
:target: https://travis-ci.org/edx/frontend-app-discussions :target: https://travis-ci.org/edx/frontend-app-discussions

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

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

21674
package-lock.json generated

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,19 +27,17 @@
}, },
"author": "edX", "author": "edX",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"homepage": "https://github.com/openedx/frontend-app-discussions#readme", "homepage": "https://github.com/edx/frontend-app-discussions#readme",
"publishConfig": { "publishConfig": {
"access": "public" "access": "public"
}, },
"bugs": { "bugs": {
"url": "https://github.com/openedx/frontend-app-discussions/issues" "url": "https://github.com/edx/frontend-app-discussions/issues"
}, },
"dependencies": { "dependencies": {
"@edx/brand": "npm:@edx/brand-openedx@1.1.0", "@edx/brand": "npm:@edx/brand-openedx@1.1.0",
"@edx/frontend-component-footer": "11.2.0", "@edx/frontend-platform": "1.15.4",
"@edx/frontend-component-header": "3.2.0", "@edx/paragon": "19.10.1",
"@edx/frontend-platform": "2.6.1",
"@edx/paragon": "20.15.0",
"@reduxjs/toolkit": "1.8.0", "@reduxjs/toolkit": "1.8.0",
"@tinymce/tinymce-react": "3.13.1", "@tinymce/tinymce-react": "3.13.1",
"babel-polyfill": "6.26.0", "babel-polyfill": "6.26.0",
@@ -49,7 +49,6 @@
"raw-loader": "4.0.2", "raw-loader": "4.0.2",
"react": "16.14.0", "react": "16.14.0",
"react-dom": "16.14.0", "react-dom": "16.14.0",
"react-mathjax-preview": "2.2.6",
"react-redux": "7.2.6", "react-redux": "7.2.6",
"react-router": "5.2.1", "react-router": "5.2.1",
"react-router-dom": "5.3.0", "react-router-dom": "5.3.0",
@@ -60,20 +59,18 @@
"yup": "0.31.1" "yup": "0.31.1"
}, },
"devDependencies": { "devDependencies": {
"@edx/browserslist-config": "1.1.0", "@edx/frontend-build": "9.1.4",
"@edx/frontend-build": "11.0.1",
"@edx/reactifex": "1.0.3",
"@testing-library/jest-dom": "5.16.2", "@testing-library/jest-dom": "5.16.2",
"@testing-library/react": "12.1.4", "@testing-library/react": "12.1.4",
"@testing-library/user-event": "13.5.0", "@testing-library/user-event": "13.5.0",
"axios-mock-adapter": "1.20.0", "axios-mock-adapter": "1.20.0",
"babel-plugin-react-intl": "8.2.25",
"codecov": "3.8.3", "codecov": "3.8.3",
"es-check": "6.2.1", "es-check": "6.2.1",
"eslint-plugin-simple-import-sort": "7.0.0", "eslint-plugin-simple-import-sort": "7.0.0",
"glob": "7.2.0", "glob": "7.2.0",
"husky": "7.0.4", "husky": "7.0.4",
"jest": "27.5.1", "jest": "27.5.1",
"reactifex": "1.1.1",
"rosie": "2.1.0" "rosie": "2.1.0"
} }
} }

View File

@@ -1,16 +1,12 @@
<!DOCTYPE html> <!doctype html>
<html lang="en-us"> <html lang="en-us">
<head> <head>
<title>Discussions | <%= process.env.SITE_NAME %></title> <title>Discussions | <%= process.env.SITE_NAME %></title>
<meta charset="utf-8" /> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<link <link rel="shortcut icon" href="<%=htmlWebpackPlugin.options.FAVICON_URL%>" type="image/x-icon" />
rel="shortcut icon"
href="<%=htmlWebpackPlugin.options.FAVICON_URL%>"
type="image/x-icon"
/>
</head> </head>
<body> <body class="vh-100 vw-100 overflow-hidden">
<div id="root" class="small"></div> <div id="root" class="vh-100 vw-100 small"></div>
</body> </body>
</html> </html>

View File

@@ -1,201 +0,0 @@
/* eslint-disable react/forbid-prop-types */
import React, { useMemo, useState } from 'react';
import PropTypes from 'prop-types';
import { capitalize, toString } from 'lodash';
import { useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import {
Collapsible, Form, Icon, Spinner,
} from '@edx/paragon';
import { Tune } from '@edx/paragon/icons';
import {
PostsStatusFilter, RequestStatus,
ThreadOrdering, ThreadType,
} from '../data/constants';
import { selectCourseCohorts } from '../discussions/cohorts/data/selectors';
import messages from '../discussions/posts/post-filter-bar/messages';
import { ActionItem } from '../discussions/posts/post-filter-bar/PostFilterBar';
function FilterBar({
intl,
filters,
selectedFilters,
onFilterChange,
showCohortsFilter,
}) {
const [isOpen, setOpen] = useState(false);
const cohorts = useSelector(selectCourseCohorts);
const { status } = useSelector(state => state.cohorts);
const selectedCohort = useMemo(() => cohorts.find(cohort => (
toString(cohort.id) === selectedFilters.cohort)),
[selectedFilters.cohort]);
const allFilters = [
{
id: 'type-all',
label: intl.formatMessage(messages.allPosts),
value: ThreadType.ALL,
},
{
id: 'type-discussions',
label: intl.formatMessage(messages.filterDiscussions),
value: ThreadType.DISCUSSION,
},
{
id: 'type-questions',
label: intl.formatMessage(messages.filterQuestions),
value: ThreadType.QUESTION,
},
{
id: 'status-any',
label: intl.formatMessage(messages.filterAnyStatus),
value: PostsStatusFilter.ALL,
},
{
id: 'status-unread',
label: intl.formatMessage(messages.filterUnread),
value: PostsStatusFilter.UNREAD,
},
{
id: 'status-reported',
label: intl.formatMessage(messages.filterReported),
value: PostsStatusFilter.REPORTED,
},
{
id: 'status-unanswered',
label: intl.formatMessage(messages.filterUnanswered),
value: PostsStatusFilter.UNANSWERED,
},
{
id: 'status-unresponded',
label: intl.formatMessage(messages.filterUnresponded),
value: PostsStatusFilter.UNRESPONDED,
},
{
id: 'sort-activity',
label: intl.formatMessage(messages.lastActivityAt),
value: ThreadOrdering.BY_LAST_ACTIVITY,
},
{
id: 'sort-comments',
label: intl.formatMessage(messages.commentCount),
value: ThreadOrdering.BY_COMMENT_COUNT,
},
{
id: 'sort-votes',
label: intl.formatMessage(messages.voteCount),
value: ThreadOrdering.BY_VOTE_COUNT,
},
];
return (
<Collapsible.Advanced
open={isOpen}
onToggle={() => setOpen(!isOpen)}
className="filter-bar collapsible-card-lg border-0"
>
<Collapsible.Trigger className="collapsible-trigger border-0">
<span className="text-primary-700 pr-4">
{intl.formatMessage(messages.sortFilterStatus, {
own: false,
type: selectedFilters.type,
sort: selectedFilters.orderBy,
status: selectedFilters.status,
cohortType: selectedCohort?.name ? 'group' : 'all',
cohort: capitalize(selectedCohort?.name),
})}
</span>
<Collapsible.Visible whenClosed>
<Icon src={Tune} />
</Collapsible.Visible>
<Collapsible.Visible whenOpen>
<Icon src={Tune} />
</Collapsible.Visible>
</Collapsible.Trigger>
<Collapsible.Body className="collapsible-body px-4 pb-3 pt-0">
<Form>
<div className="d-flex flex-row py-2 justify-content-between">
{filters.map((value) => (
<Form.RadioSet
name={value.name}
className="d-flex flex-column list-group list-group-flush"
value={selectedFilters[value.name]}
onChange={onFilterChange}
>
{
value.filters.map(filterName => {
const element = allFilters.find(obj => obj.id === filterName);
if (element) {
return (
<ActionItem
id={element.id}
label={element.label}
value={element.value}
selected={selectedFilters[value.name]}
/>
);
}
return false;
})
}
</Form.RadioSet>
))}
</div>
{showCohortsFilter && (
<>
<div className="border-bottom my-2" />
{status === RequestStatus.IN_PROGRESS ? (
<div className="d-flex justify-content-center p-4">
<Spinner animation="border" variant="primary" size="lg" />
</div>
) : (
<div className="d-flex flex-row pt-2">
<Form.RadioSet
name="cohort"
className="d-flex flex-column list-group list-group-flush w-100"
value={selectedFilters.cohort}
onChange={onFilterChange}
>
<ActionItem
id="all-groups"
label="All groups"
value=""
selected={selectedFilters.cohort}
/>
{cohorts.map(cohort => (
<ActionItem
key={toString(cohort.id)}
id={toString(cohort.id)}
label={capitalize(cohort.name)}
value={toString(cohort.id)}
selected={selectedFilters.cohort}
/>
))}
</Form.RadioSet>
</div>
)}
</>
)}
</Form>
</Collapsible.Body>
</Collapsible.Advanced>
);
}
FilterBar.propTypes = {
intl: intlShape.isRequired,
filters: PropTypes.array.isRequired,
selectedFilters: PropTypes.object.isRequired,
onFilterChange: PropTypes.func.isRequired,
showCohortsFilter: PropTypes.bool,
};
FilterBar.defaultProps = {
showCohortsFilter: false,
};
export default injectIntl(FilterBar);

View File

@@ -1,66 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import MathJax from 'react-mathjax-preview';
const baseConfig = {
showMathMenu: true,
tex2jax: {
inlineMath: [
['$', '$'],
['\\\\(', '\\\\)'],
['\\(', '\\)'],
['[mathjaxinline]', '[/mathjaxinline]'],
['\\begin{math}', '\\end{math}'],
],
displayMath: [
['[mathjax]', '[/mathjax]'],
['$$', '$$'],
['\\\\[', '\\\\]'],
['\\[', '\\]'],
['\\begin{displaymath}', '\\end{displaymath}'],
['\\begin{equation}', '\\end{equation}'],
],
},
skipStartupTypeset: true,
};
function HTMLLoader({ htmlNode, componentId, cssClassName }) {
const isLatex = htmlNode.match(/(\${1,2})((?:\\.|.)*)\1/)
|| htmlNode.match(/(\[mathjax](.+?)\[\/mathjax])+/)
|| htmlNode.match(/(\[mathjaxinline](.+?)\[\/mathjaxinline])+/)
|| htmlNode.match(/(\\begin\{math}(.+?)\\end\{math})+/)
|| htmlNode.match(/(\\begin\{displaymath}(.+?)\\end\{displaymath})+/)
|| htmlNode.match(/(\\begin\{equation}(.+?)\\end\{equation})+/)
|| htmlNode.match(/(\\\[(.+?)\\\])+/)
|| htmlNode.match(/(\\\((.+?)\\\))+/);
return (
isLatex ? (
<MathJax
math={htmlNode}
id={componentId}
className={cssClassName}
sanitizeOptions={{ USE_PROFILES: { html: true } }}
config={baseConfig}
/>
)
// eslint-disable-next-line react/no-danger
: <div className={cssClassName} id={componentId} dangerouslySetInnerHTML={{ __html: htmlNode }} />
);
}
HTMLLoader.propTypes = {
htmlNode: PropTypes.node,
componentId: PropTypes.string,
cssClassName: PropTypes.string,
};
HTMLLoader.defaultProps = {
htmlNode: '',
componentId: null,
cssClassName: '',
};
export default HTMLLoader;

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 { API_BASE_URL } from '../../../data/constants';
function normalizeCourseHomeCourseMetadata(metadata, rootSlug) {
const data = camelCaseObject(metadata);
return {
...data,
tabs: data.tabs.map(tab => ({
// The API uses "courseware" as a slug for both courseware and the outline tab.
// If needed, we switch it to "outline" here for
// use within the MFE to differentiate between course home and courseware.
slug: tab.tabId === 'courseware' ? rootSlug : tab.tabId,
title: tab.title,
url: tab.url,
})),
isMasquerading: data.originalUserIsStaff && !data.isStaff,
};
}
export async function getCourseHomeCourseMetadata(courseId, rootSlug) {
const url = `${API_BASE_URL}/api/course_home/course_metadata/${courseId}`;
// don't know the context of adding timezone in url. hence omitting it
// url = appendBrowserTimezoneToUrl(url);
const { data } = await getAuthenticatedHttpClient().get(url);
return normalizeCourseHomeCourseMetadata(data, rootSlug);
}

View File

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

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" />
</div>
)}
<div className="d-flex justify-content-end">
{!showPreviewPane
&& (
<Button
variant="link"
size="md"
onClick={() => setShowPreviewPane(true)}
className={`text-primary-500 px-0 ${editExisting && 'mb-4.5'}`}
>
{intl.formatMessage(messages.showPreviewButton)}
</Button>
)}
</div>
</>
);
}
PostPreviewPane.propTypes = {
intl: intlShape.isRequired,
htmlNode: PropTypes.node.isRequired,
isPost: PropTypes.bool,
editExisting: PropTypes.bool,
};
PostPreviewPane.defaultProps = {
isPost: false,
editExisting: false,
};
export default injectIntl(PostPreviewPane);

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,16 +17,12 @@ import 'tinymce/icons/default';
import 'tinymce/skins/ui/oxide/skin.css'; import 'tinymce/skins/ui/oxide/skin.css';
// importing the plugin js. // importing the plugin js.
import 'tinymce/plugins/autolink'; import 'tinymce/plugins/autolink';
import 'tinymce/plugins/autoresize';
import 'tinymce/plugins/autosave'; import 'tinymce/plugins/autosave';
import 'tinymce/plugins/codesample'; import 'tinymce/plugins/codesample';
import 'tinymce/plugins/image'; import 'tinymce/plugins/image';
import 'tinymce/plugins/imagetools'; import 'tinymce/plugins/imagetools';
import 'tinymce/plugins/link'; import 'tinymce/plugins/link';
import 'tinymce/plugins/lists'; import 'tinymce/plugins/lists';
import 'tinymce/plugins/emoticons';
import 'tinymce/plugins/emoticons/js/emojis';
import 'tinymce/plugins/charmap';
/* eslint import/no-webpack-loader-syntax: off */ /* eslint import/no-webpack-loader-syntax: off */
// eslint-disable-next-line import/no-unresolved // eslint-disable-next-line import/no-unresolved
import edxBrandCss from '!!raw-loader!sass-loader!../index.scss'; import edxBrandCss from '!!raw-loader!sass-loader!../index.scss';
@@ -40,7 +31,6 @@ import contentCss from '!!raw-loader!tinymce/skins/content/default/content.min.c
// eslint-disable-next-line import/no-unresolved // eslint-disable-next-line import/no-unresolved
import contentUiCss from '!!raw-loader!tinymce/skins/ui/oxide/content.min.css'; import contentUiCss from '!!raw-loader!tinymce/skins/ui/oxide/content.min.css';
/* istanbul ignore next */
const setup = (editor) => { const setup = (editor) => {
editor.ui.registry.addButton('openedx_code', { editor.ui.registry.addButton('openedx_code', {
icon: 'sourcecode', icon: 'sourcecode',
@@ -56,29 +46,17 @@ const setup = (editor) => {
}); });
}; };
/* istanbul ignore next */
export default function TinyMCEEditor(props) { export default function TinyMCEEditor(props) {
// note that skin and content_css is disabled to avoid the normal // note that skin and content_css is disabled to avoid the normal
// loading process and is instead loaded as a string via content_style // loading process and is instead loaded as a string via content_style
const { courseId, postId } = useParams(); const { courseId, postId } = useParams();
const [showImageWarning, setShowImageWarning] = useState(false);
const intl = useIntl();
const uploadHandler = async (blobInfo, success, failure) => { const uploadHandler = async (blobInfo, success, failure) => {
try { try {
const blob = blobInfo.blob(); const blob = blobInfo.blob();
const imageSize = blobInfo.blob().size / 1024;
if (imageSize > MAX_UPLOAD_FILE_SIZE) {
failure(`Images size should not exceed ${MAX_UPLOAD_FILE_SIZE} KB`);
return;
}
const filename = blobInfo.filename(); const filename = blobInfo.filename();
const { location } = await uploadFile(blob, filename, courseId, postId || 'root'); const { location } = await uploadFile(blob, filename, courseId, postId || 'root');
const img = new Image();
img.onload = function () {
if (img.height > 999 || img.width > 999) { setShowImageWarning(true); }
};
img.src = location;
success(location); success(location);
} catch (e) { } catch (e) {
failure(e.toString(), { remove: true }); failure(e.toString(), { remove: true });
@@ -94,54 +72,32 @@ export default function TinyMCEEditor(props) {
} }
return ( return (
<> <Editor
<Editor init={{
init={{ skin: false,
skin: false, menubar: false,
menubar: false, branding: false,
branding: false, contextmenu: false,
contextmenu: false, browser_spellcheck: true,
browser_spellcheck: true, a11y_advanced_options: true,
a11y_advanced_options: true, autosave_interval: '1s',
autosave_interval: '1s', autosave_restore_when_empty: true,
autosave_restore_when_empty: false, plugins: 'autosave codesample link lists image imagetools code',
plugins: 'autoresize autosave codesample link lists image imagetools code emoticons charmap', toolbar: 'formatselect | bold italic underline'
toolbar: 'undo redo' + ' | link blockquote openedx_code image'
+ ' | formatselect | bold italic underline' + ' | bullist numlist outdent indent'
+ ' | link blockquote openedx_code image' + ' | removeformat'
+ ' | bullist numlist outdent indent' + ' | openedx_html'
+ ' | removeformat' + ' | undo redo',
+ ' | openedx_html' content_css: false,
+ ' | emoticons' content_style: contentStyle,
+ ' | charmap', body_class: 'm-2',
content_css: false, default_link_target: '_blank',
content_style: contentStyle, target_list: false,
body_class: 'm-2 text-editor', images_upload_handler: uploadHandler,
default_link_target: '_blank', setup,
target_list: false, }}
images_upload_handler: uploadHandler, {...props}
setup, />
}}
{...props}
/>
<AlertModal
title={intl.formatMessage(messages.imageWarningModalTitle)}
isOpen={showImageWarning}
onClose={() => setShowImageWarning(false)}
isBlocking
footerNode={(
<ActionRow>
<Button variant="danger" onClick={() => setShowImageWarning(false)}>
{intl.formatMessage(messages.imageWarningDismissButton)}
</Button>
</ActionRow>
)}
>
<p>
{intl.formatMessage(messages.imageWarningMessage)}
</p>
</AlertModal>
</>
); );
} }

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

@@ -45,7 +45,6 @@ export const ContentActions = {
PIN: 'pinned', PIN: 'pinned',
ENDORSE: 'endorsed', ENDORSE: 'endorsed',
CLOSE: 'closed', CLOSE: 'closed',
COPY_LINK: 'copy_link',
REPORT: 'abuse_flagged', REPORT: 'abuse_flagged',
DELETE: 'delete', DELETE: 'delete',
FOLLOWING: 'following', FOLLOWING: 'following',
@@ -74,9 +73,9 @@ export const RequestStatus = {
* @readonly * @readonly
* @enum {string} * @enum {string}
*/ */
export const AvatarOutlineAndLabelColors = { export const AvatarBorderAndLabelColors = {
Staff: 'staff-color', Staff: 'warning-700',
'Community TA': 'TA-color', 'Community TA': 'success-700',
}; };
/** /**
@@ -111,7 +110,6 @@ export const PostsStatusFilter = {
FOLLOWING: 'statusFollowing', FOLLOWING: 'statusFollowing',
REPORTED: 'statusReported', REPORTED: 'statusReported',
UNANSWERED: 'statusUnanswered', UNANSWERED: 'statusUnanswered',
UNRESPONDED: 'statusUnresponded',
}; };
/** /**
@@ -134,7 +132,17 @@ export const TopicOrdering = {
export const LearnersOrdering = { export const LearnersOrdering = {
BY_FLAG: 'flagged', BY_FLAG: 'flagged',
BY_LAST_ACTIVITY: 'activity', BY_LAST_ACTIVITY: 'activity',
BY_RECENCY: 'recency', };
/**
* Enum for Learner content tabs
* @readonly
* @enum {string}
*/
export const LearnerTabs = {
POSTS: 'posts',
COMMENTS: 'comments',
RESPONSES: 'responses',
}; };
/** /**
@@ -154,7 +162,12 @@ export const Routes = {
}, },
LEARNERS: { LEARNERS: {
PATH: `${BASE_PATH}/learners`, PATH: `${BASE_PATH}/learners`,
POSTS: `${BASE_PATH}/learners/:learnerUsername/posts(/:postId)?`, LEARNER: `${BASE_PATH}/learners/:learnerUsername`,
TABS: {
posts: `${BASE_PATH}/learners/:learnerUsername/${LearnerTabs.POSTS}`,
responses: `${BASE_PATH}/learners/:learnerUsername/${LearnerTabs.RESPONSES}`,
comments: `${BASE_PATH}/learners/:learnerUsername/${LearnerTabs.COMMENTS}`,
},
}, },
POSTS: { POSTS: {
PATH: `${BASE_PATH}/topics/:topicId`, PATH: `${BASE_PATH}/topics/:topicId`,
@@ -170,7 +183,6 @@ export const Routes = {
`${BASE_PATH}/topics/:topicId/posts/:postId/edit`, `${BASE_PATH}/topics/:topicId/posts/:postId/edit`,
`${BASE_PATH}/posts/:postId/edit`, `${BASE_PATH}/posts/:postId/edit`,
`${BASE_PATH}/my-posts/:postId/edit`, `${BASE_PATH}/my-posts/:postId/edit`,
`${BASE_PATH}/learners/:learnerUsername/posts/:postId/edit`,
], ],
}, },
COMMENTS: { COMMENTS: {
@@ -179,7 +191,6 @@ export const Routes = {
`${BASE_PATH}/topics/:topicId/posts/:postId`, `${BASE_PATH}/topics/:topicId/posts/:postId`,
`${BASE_PATH}/posts/:postId`, `${BASE_PATH}/posts/:postId`,
`${BASE_PATH}/my-posts/:postId`, `${BASE_PATH}/my-posts/:postId`,
`${BASE_PATH}/learners/:learnerUsername/posts/:postId`,
], ],
PAGE: `${BASE_PATH}/:page`, PAGE: `${BASE_PATH}/:page`,
PAGES: { PAGES: {
@@ -187,7 +198,6 @@ export const Routes = {
topics: `${BASE_PATH}/topics/:topicId/posts/:postId`, topics: `${BASE_PATH}/topics/:topicId/posts/:postId`,
posts: `${BASE_PATH}/posts/:postId`, posts: `${BASE_PATH}/posts/:postId`,
'my-posts': `${BASE_PATH}/my-posts/:postId`, 'my-posts': `${BASE_PATH}/my-posts/:postId`,
learners: `${BASE_PATH}/learners/:learnerUsername/posts/:postId`,
}, },
}, },
TOPICS: { TOPICS: {
@@ -203,20 +213,10 @@ export const Routes = {
}, },
}; };
export const PostsPages = {
category: `${BASE_PATH}/category/:category/posts`,
topics: `${BASE_PATH}/topics/:topicId/posts`,
posts: `${BASE_PATH}/posts`,
'my-posts': `${BASE_PATH}/my-posts`,
learners: `${BASE_PATH}/learners/:learnerUsername/posts`,
};
export const ALL_ROUTES = [] export const ALL_ROUTES = []
.concat([Routes.TOPICS.CATEGORY_POST, Routes.TOPICS.CATEGORY]) .concat([Routes.TOPICS.CATEGORY_POST, Routes.TOPICS.CATEGORY])
.concat(Routes.COMMENTS.PATH) .concat(Routes.COMMENTS.PATH)
.concat(Routes.TOPICS.PATH) .concat(Routes.TOPICS.PATH)
.concat([Routes.POSTS.ALL_POSTS, Routes.POSTS.MY_POSTS]) .concat([Routes.POSTS.ALL_POSTS, Routes.POSTS.MY_POSTS])
.concat([Routes.LEARNERS.POSTS, Routes.LEARNERS.PATH]) .concat([Routes.LEARNERS.LEARNER, Routes.LEARNERS.PATH])
.concat([Routes.DISCUSSIONS.PATH]); .concat([Routes.DISCUSSIONS.PATH]);
export const MAX_UPLOAD_FILE_SIZE = 1024;

View File

@@ -8,7 +8,6 @@ import { initializeStore } from '../store';
import { executeThunk } from '../test-utils'; import { executeThunk } from '../test-utils';
import { getBlocksAPIResponse } from './__factories__'; import { getBlocksAPIResponse } from './__factories__';
import { blocksAPIURL } from './api'; import { blocksAPIURL } from './api';
import { RequestStatus } from './constants';
import { fetchCourseBlocks } from './thunks'; import { fetchCourseBlocks } from './thunks';
const courseId = 'course-v1:edX+DemoX+Demo_Course'; const courseId = 'course-v1:edX+DemoX+Demo_Course';
@@ -36,7 +35,7 @@ describe('Course blocks data layer tests', () => {
axiosMock.reset(); axiosMock.reset();
}); });
it('successfully processes block data', async () => { test('successfully processes block data', async () => {
axiosMock.onGet(blocksAPIURL) axiosMock.onGet(blocksAPIURL)
.reply(200, getBlocksAPIResponse()); .reply(200, getBlocksAPIResponse());
@@ -78,29 +77,4 @@ describe('Course blocks data layer tests', () => {
}, },
); );
}); });
it('handles network error', async () => {
axiosMock.onGet(blocksAPIURL).networkError();
await executeThunk(fetchCourseBlocks(courseId, 'test-user'), store.dispatch, store.getState);
expect(store.getState().blocks.status)
.toBe(RequestStatus.FAILED);
});
it('handles network timeout', async () => {
axiosMock.onGet(blocksAPIURL).timeout();
await executeThunk(fetchCourseBlocks(courseId, 'test-user'), store.dispatch, store.getState);
expect(store.getState().blocks.status)
.toBe(RequestStatus.FAILED);
});
it('handles access denied', async () => {
axiosMock.onGet(blocksAPIURL).reply(403, {});
await executeThunk(fetchCourseBlocks(courseId, 'test-user'), store.dispatch, store.getState);
expect(store.getState().blocks.status)
.toBe(RequestStatus.DENIED);
});
}); });

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,
@@ -44,10 +32,4 @@ export const selectSequences = createSelector(
(chapterIds, blocks) => chapterIds?.flatMap(cId => blocks[cId].children.map(seqId => blocks[seqId])) || [], (chapterIds, blocks) => chapterIds?.flatMap(cId => blocks[cId].children.map(seqId => blocks[seqId])) || [],
); );
export const selectArchivedTopics = createSelector(
state => state.topics.topics,
state => state.topics.archivedIds || [],
(topics, ids) => ids.map(id => topics[id]),
);
export const selectTopicIds = () => (state) => state.blocks.chapters; export const selectTopicIds = () => (state) => state.blocks.chapters;

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

@@ -1,9 +1,10 @@
import React, { useEffect, useMemo } from 'react'; import React, { useEffect } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { useParams } from 'react-router'; import { useParams } from 'react-router';
import { ensureConfig, getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button, Spinner } from '@edx/paragon'; import { Button, Spinner } from '@edx/paragon';
@@ -11,23 +12,27 @@ import { EndorsementStatus, ThreadType } from '../../data/constants';
import { useDispatchWithState } from '../../data/hooks'; import { useDispatchWithState } from '../../data/hooks';
import { Post } from '../posts'; import { Post } from '../posts';
import { selectThread } from '../posts/data/selectors'; import { selectThread } from '../posts/data/selectors';
import { fetchThread, markThreadAsRead } from '../posts/data/thunks'; import { markThreadAsRead } from '../posts/data/thunks';
import { filterPosts } from '../utils';
import { selectThreadComments, selectThreadCurrentPage, selectThreadHasMorePages } from './data/selectors'; import { selectThreadComments, selectThreadCurrentPage, selectThreadHasMorePages } from './data/selectors';
import { fetchThreadComments } from './data/thunks'; import { fetchThreadComments } from './data/thunks';
import { Comment, ResponseEditor } from './comment'; import { Comment, ResponseEditor } from './comment';
import messages from './messages'; import messages from './messages';
ensureConfig(['POST_MARK_AS_READ_DELAY'], 'Comment thread view');
function usePost(postId) { function usePost(postId) {
const dispatch = useDispatch(); const dispatch = useDispatch();
const thread = useSelector(selectThread(postId)); const thread = useSelector(selectThread(postId));
useEffect(() => { useEffect(() => {
if (thread && !thread.read) { const markReadTimer = setTimeout(() => {
dispatch(markThreadAsRead(postId)); if (thread && !thread.read) {
} dispatch(markThreadAsRead(postId));
}
}, getConfig().POST_MARK_AS_READ_DELAY);
return () => {
clearTimeout(markReadTimer);
};
}, [postId]); }, [postId]);
return thread; return thread;
} }
@@ -59,7 +64,6 @@ function DiscussionCommentsView({
postId, postId,
intl, intl,
endorsed, endorsed,
isClosed,
}) { }) {
const { const {
comments, comments,
@@ -67,56 +71,41 @@ function DiscussionCommentsView({
isLoading, isLoading,
handleLoadMoreResponses, handleLoadMoreResponses,
} = usePostComments(postId, endorsed); } = usePostComments(postId, endorsed);
const sortedComments = useMemo(() => [...filterPosts(comments, 'endorsed'),
...filterPosts(comments, 'unendorsed')], [comments]);
return ( return (
<> <div className="m-3">
{((hasMorePages && isLoading) || !isLoading) <div className="my-3">
&& (
<div className="mx-4 text-primary-700" role="heading" aria-level="2" style={{ lineHeight: '28px' }}>
{endorsed === EndorsementStatus.ENDORSED {endorsed === EndorsementStatus.ENDORSED
? intl.formatMessage(messages.endorsedResponseCount, { num: sortedComments.length }) ? intl.formatMessage(messages.endorsedResponseCount, { num: comments.length })
: intl.formatMessage(messages.responseCount, { num: sortedComments.length })} : intl.formatMessage(messages.responseCount, { num: comments.length })}
</div> </div>
)} {comments.map(comment => (
<Comment comment={comment} key={comment.id} postType={postType} />
))}
<div className="mx-4" role="list"> {hasMorePages && !isLoading && (
{sortedComments.map(comment => ( <Button
<Comment comment={comment} key={comment.id} postType={postType} isClosedPost={isClosed} /> onClick={handleLoadMoreResponses}
))} variant="link"
{hasMorePages && !isLoading && ( block="true"
<Button className="card p-4"
onClick={handleLoadMoreResponses} data-testid="load-more-comments"
variant="link" >
block="true" {intl.formatMessage(messages.loadMoreResponses)}
className="card p-4 mb-4 font-weight-500 font-size-14" </Button>
style={{ )}
lineHeight: '20px', {isLoading
}}
data-testid="load-more-comments"
>
{intl.formatMessage(messages.loadMoreResponses)}
</Button>
)}
{isLoading
&& ( && (
<div className="card my-4 p-4 d-flex align-items-center"> <div className="card my-4 p-4 d-flex align-items-center">
<Spinner animation="border" variant="primary" /> <Spinner animation="border" variant="primary" />
</div> </div>
)} )}
{!!sortedComments.length && !isClosed </div>
&& <ResponseEditor postId={postId} addWrappingDiv />}
</div>
</>
); );
} }
DiscussionCommentsView.propTypes = { DiscussionCommentsView.propTypes = {
postId: PropTypes.string.isRequired, postId: PropTypes.string.isRequired,
postType: PropTypes.string.isRequired, postType: PropTypes.string.isRequired,
isClosed: PropTypes.bool.isRequired,
intl: intlShape.isRequired, intl: intlShape.isRequired,
endorsed: PropTypes.oneOf([ endorsed: PropTypes.oneOf([
EndorsementStatus.ENDORSED, EndorsementStatus.UNENDORSED, EndorsementStatus.DISCUSSION, EndorsementStatus.ENDORSED, EndorsementStatus.UNENDORSED, EndorsementStatus.DISCUSSION,
@@ -126,18 +115,16 @@ DiscussionCommentsView.propTypes = {
function CommentsView({ intl }) { function CommentsView({ intl }) {
const { postId } = useParams(); const { postId } = useParams();
const thread = usePost(postId); const thread = usePost(postId);
const dispatch = useDispatch();
if (!thread) { if (!thread) {
dispatch(fetchThread(postId, true));
return ( return (
<Spinner animation="border" variant="primary" data-testid="loading-indicator" /> <Spinner animation="border" variant="primary" data-testid="loading-indicator" />
); );
} }
return ( return (
<> <>
<div className="discussion-comments d-flex flex-column m-4 p-4.5 card"> <div className="discussion-comments d-flex flex-column mt-3 mb-0 mx-3 p-4 card">
<Post post={thread} /> <Post post={thread} />
{!thread.closed && <ResponseEditor postId={postId} /> } <ResponseEditor postId={postId} />
</div> </div>
{thread.type === ThreadType.DISCUSSION {thread.type === ThreadType.DISCUSSION
&& ( && (
@@ -146,7 +133,6 @@ function CommentsView({ intl }) {
intl={intl} intl={intl}
postType={thread.type} postType={thread.type}
endorsed={EndorsementStatus.DISCUSSION} endorsed={EndorsementStatus.DISCUSSION}
isClosed={thread.closed}
/> />
)} )}
{thread.type === ThreadType.QUESTION && ( {thread.type === ThreadType.QUESTION && (
@@ -156,14 +142,12 @@ function CommentsView({ intl }) {
intl={intl} intl={intl}
postType={thread.type} postType={thread.type}
endorsed={EndorsementStatus.ENDORSED} endorsed={EndorsementStatus.ENDORSED}
isClosed={thread.closed}
/> />
<DiscussionCommentsView <DiscussionCommentsView
postId={postId} postId={postId}
intl={intl} intl={intl}
postType={thread.type} postType={thread.type}
endorsed={EndorsementStatus.UNENDORSED} endorsed={EndorsementStatus.UNENDORSED}
isClosed={thread.closed}
/> />
</> </>
)} )}

View File

@@ -12,7 +12,6 @@ import { AppProvider } from '@edx/frontend-platform/react';
import { initializeStore } from '../../store'; import { initializeStore } from '../../store';
import { executeThunk } from '../../test-utils'; import { executeThunk } from '../../test-utils';
import { DiscussionContext } from '../common/context';
import { courseConfigApiUrl } from '../data/api'; import { courseConfigApiUrl } from '../data/api';
import { fetchCourseConfig } from '../data/thunks'; import { fetchCourseConfig } from '../data/thunks';
import DiscussionContent from '../discussions-home/DiscussionContent'; import DiscussionContent from '../discussions-home/DiscussionContent';
@@ -82,20 +81,16 @@ function renderComponent(postId) {
render( render(
<IntlProvider locale="en"> <IntlProvider locale="en">
<AppProvider store={store}> <AppProvider store={store}>
<DiscussionContext.Provider <MemoryRouter initialEntries={[`/${courseId}/posts/${postId}`]}>
value={{ courseId }} <DiscussionContent />
> <Route
<MemoryRouter initialEntries={[`/${courseId}/posts/${postId}`]}> path="*"
<DiscussionContent /> render={({ location }) => {
<Route testLocation = location;
path="*" return null;
render={({ location }) => { }}
testLocation = location; />
return null; </MemoryRouter>
}}
/>
</MemoryRouter>
</DiscussionContext.Provider>
</AppProvider> </AppProvider>
</IntlProvider>, </IntlProvider>,
); );
@@ -160,10 +155,9 @@ describe('CommentsView', () => {
it('should show and hide the editor', async () => { it('should show and hide the editor', async () => {
renderComponent(discussionPostId); renderComponent(discussionPostId);
await waitFor(() => screen.findByText('comment number 1', { exact: false })); await waitFor(() => screen.findByText('comment number 1', { exact: false }));
const addResponseButtons = screen.getAllByRole('button', { name: /add a response/i });
await act(async () => { await act(async () => {
fireEvent.click( fireEvent.click(
addResponseButtons[0], screen.getByRole('button', { name: /add a response/i }),
); );
}); });
expect(screen.queryByTestId('tinymce-editor')).toBeInTheDocument(); expect(screen.queryByTestId('tinymce-editor')).toBeInTheDocument();
@@ -172,17 +166,15 @@ describe('CommentsView', () => {
}); });
expect(screen.queryByTestId('tinymce-editor')).not.toBeInTheDocument(); expect(screen.queryByTestId('tinymce-editor')).not.toBeInTheDocument();
}); });
it('should allow posting a response', async () => { it('should allow posting a response', async () => {
renderComponent(discussionPostId); renderComponent(discussionPostId);
await waitFor(() => screen.findByText('comment number 1', { exact: false })); await waitFor(() => screen.findByText('comment number 1', { exact: false }));
const responseButtons = screen.getAllByRole('button', { name: /add a response/i });
await act(async () => { await act(async () => {
fireEvent.click( fireEvent.click(
responseButtons[0], screen.getByRole('button', { name: /add a response/i }),
); );
}); });
await act(() => { act(() => {
fireEvent.change(screen.getByTestId('tinymce-editor'), { target: { value: 'testing123' } }); fireEvent.change(screen.getByTestId('tinymce-editor'), { target: { value: 'testing123' } });
}); });
@@ -194,13 +186,6 @@ describe('CommentsView', () => {
expect(screen.queryByTestId('tinymce-editor')).not.toBeInTheDocument(); expect(screen.queryByTestId('tinymce-editor')).not.toBeInTheDocument();
await waitFor(async () => expect(await screen.findByText('testing123', { exact: false })).toBeInTheDocument()); await waitFor(async () => expect(await screen.findByText('testing123', { exact: false })).toBeInTheDocument());
}); });
it('should not allow posting a response on a closed post', async () => {
renderComponent(closedPostId);
await waitFor(() => screen.findByText('Thread-2', { exact: false }));
expect(screen.queryByRole('button', { name: /add a response/i })).not.toBeInTheDocument();
});
it('should allow posting a comment', async () => { it('should allow posting a comment', async () => {
renderComponent(discussionPostId); renderComponent(discussionPostId);
await waitFor(() => screen.findByText('comment number 1', { exact: false })); await waitFor(() => screen.findByText('comment number 1', { exact: false }));
@@ -221,17 +206,6 @@ describe('CommentsView', () => {
expect(screen.queryByTestId('tinymce-editor')).not.toBeInTheDocument(); expect(screen.queryByTestId('tinymce-editor')).not.toBeInTheDocument();
await waitFor(async () => expect(await screen.findByText('testing123', { exact: false })).toBeInTheDocument()); await waitFor(async () => expect(await screen.findByText('testing123', { exact: false })).toBeInTheDocument());
}); });
it('should not allow posting a comment on a closed post', async () => {
renderComponent(closedPostId);
await waitFor(() => screen.findByText('thread-2', { exact: false }));
await act(async () => {
expect(
screen.queryByRole('button', { name: /add a comment/i }),
).not.toBeInTheDocument();
});
});
it('should allow editing an existing comment', async () => { it('should allow editing an existing comment', async () => {
renderComponent(discussionPostId); renderComponent(discussionPostId);
await waitFor(() => screen.findByText('comment number 1', { exact: false })); await waitFor(() => screen.findByText('comment number 1', { exact: false }));
@@ -257,7 +231,7 @@ describe('CommentsView', () => {
async function setupCourseConfig(reasonCodesEnabled = true) { async function setupCourseConfig(reasonCodesEnabled = true) {
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`).reply(200, { axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`).reply(200, {
has_moderation_privileges: true, user_is_privileged: true,
reason_codes_enabled: reasonCodesEnabled, reason_codes_enabled: reasonCodesEnabled,
editReasons: [ editReasons: [
{ code: 'reason-1', label: 'reason 1' }, { code: 'reason-1', label: 'reason 1' },

View File

@@ -4,18 +4,16 @@ import PropTypes from 'prop-types';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import * as timeago from 'timeago.js'; import * as timeago from 'timeago.js';
import { injectIntl } from '@edx/frontend-platform/i18n'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import timeLocale from '../../common/time-locale';
import LikeButton from '../../posts/post/LikeButton'; import LikeButton from '../../posts/post/LikeButton';
import { editComment } from '../data/thunks'; import { editComment } from '../data/thunks';
function CommentIcons({ function CommentIcons({
comment, comment,
intl,
}) { }) {
const dispatch = useDispatch(); const dispatch = useDispatch();
timeago.register('time-locale', timeLocale);
const handleLike = () => dispatch(editComment(comment.id, { voted: !comment.voted })); const handleLike = () => dispatch(editComment(comment.id, { voted: !comment.voted }));
return ( return (
<div className="d-flex flex-row align-items-center"> <div className="d-flex flex-row align-items-center">
@@ -24,8 +22,8 @@ function CommentIcons({
onClick={handleLike} onClick={handleLike}
voted={comment.voted} voted={comment.voted}
/> />
<div className="d-flex flex-fill text-gray-500 justify-content-end" title={comment.createdAt}> <div className="d-flex flex-fill text-gray-500 justify-content-end mt-2" title={comment.createdAt}>
{timeago.format(comment.createdAt, 'time-locale')} {timeago.format(comment.createdAt, intl.locale)}
</div> </div>
</div> </div>
); );
@@ -39,6 +37,7 @@ CommentIcons.propTypes = {
voted: PropTypes.bool, voted: PropTypes.bool,
createdAt: PropTypes.string, createdAt: PropTypes.string,
}).isRequired, }).isRequired,
intl: intlShape.isRequired,
}; };
export default injectIntl(CommentIcons); export default injectIntl(CommentIcons);

View File

@@ -7,12 +7,9 @@ import { useDispatch, useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button, useToggle } from '@edx/paragon'; import { Button, useToggle } from '@edx/paragon';
import HTMLLoader from '../../../components/HTMLLoader';
import { ContentActions } from '../../../data/constants'; import { ContentActions } from '../../../data/constants';
import { AlertBanner, DeleteConfirmation, EndorsedAlertBanner } from '../../common'; import { AlertBanner, DeleteConfirmation } from '../../common';
import { selectBlackoutDate } from '../../data/selectors';
import { fetchThread } from '../../posts/data/thunks'; import { fetchThread } from '../../posts/data/thunks';
import { inBlackoutDateRange } from '../../utils';
import CommentIcons from '../comment-icons/CommentIcons'; import CommentIcons from '../comment-icons/CommentIcons';
import { selectCommentCurrentPage, selectCommentHasMorePages, selectCommentResponses } from '../data/selectors'; import { selectCommentCurrentPage, selectCommentHasMorePages, selectCommentResponses } from '../data/selectors';
import { editComment, fetchCommentResponses, removeComment } from '../data/thunks'; import { editComment, fetchCommentResponses, removeComment } from '../data/thunks';
@@ -26,7 +23,6 @@ function Comment({
postType, postType,
comment, comment,
showFullThread = true, showFullThread = true,
isClosedPost,
intl, intl,
}) { }) {
const dispatch = useDispatch(); const dispatch = useDispatch();
@@ -38,113 +34,94 @@ function Comment({
const [isReplying, setReplying] = useState(false); const [isReplying, setReplying] = useState(false);
const hasMorePages = useSelector(selectCommentHasMorePages(comment.id)); const hasMorePages = useSelector(selectCommentHasMorePages(comment.id));
const currentPage = useSelector(selectCommentCurrentPage(comment.id)); const currentPage = useSelector(selectCommentCurrentPage(comment.id));
const blackoutDateRange = useSelector(selectBlackoutDate);
useEffect(() => { useEffect(() => {
// If the comment has a parent comment, it won't have any children, so don't fetch them. // If the comment has a parent comment, it won't have any children, so don't fetch them.
if (hasChildren && !currentPage && showFullThread) { if (hasChildren && !currentPage && showFullThread) {
dispatch(fetchCommentResponses(comment.id, { page: 1 })); dispatch(fetchCommentResponses(comment.id, { page: 1 }));
} }
}, [comment.id]); }, [comment.id]);
const actionHandlers = { const actionHandlers = {
[ContentActions.EDIT_CONTENT]: () => setEditing(true), [ContentActions.EDIT_CONTENT]: () => setEditing(true),
[ContentActions.ENDORSE]: async () => { [ContentActions.ENDORSE]: async () => {
await dispatch(editComment(comment.id, { endorsed: !comment.endorsed }, ContentActions.ENDORSE)); await dispatch(editComment(comment.id, { endorsed: !comment.endorsed }));
await dispatch(fetchThread(comment.threadId)); await dispatch(fetchThread(comment.threadId));
}, },
[ContentActions.DELETE]: showDeleteConfirmation, [ContentActions.DELETE]: showDeleteConfirmation,
[ContentActions.REPORT]: () => dispatch(editComment(comment.id, { flagged: !comment.abuseFlagged })), [ContentActions.REPORT]: () => dispatch(editComment(comment.id, { flagged: !comment.abuseFlagged })),
}; };
const handleLoadMoreComments = () => ( const handleLoadMoreComments = () => (
dispatch(fetchCommentResponses(comment.id, { page: currentPage + 1 })) dispatch(fetchCommentResponses(comment.id, { page: currentPage + 1 }))
); );
const commentClasses = classNames('d-flex flex-column card', { 'my-3': showFullThread });
return ( return (
<div className={classNames({ 'py-2 my-3': showFullThread })}> <div className={commentClasses} data-testid={`comment-${comment.id}`}>
<div className="d-flex flex-column card" data-testid={`comment-${comment.id}`} role="listitem"> <DeleteConfirmation
<DeleteConfirmation isOpen={isDeleting}
isOpen={isDeleting} title={intl.formatMessage(messages.deleteResponseTitle)}
title={intl.formatMessage(messages.deleteResponseTitle)} description={intl.formatMessage(messages.deleteResponseDescription)}
description={intl.formatMessage(messages.deleteResponseDescription)} onClose={hideDeleteConfirmation}
onClose={hideDeleteConfirmation} onDelete={() => {
onDelete={() => { dispatch(removeComment(comment.id));
dispatch(removeComment(comment.id)); hideDeleteConfirmation();
hideDeleteConfirmation(); }}
}} />
<AlertBanner postType={postType} content={comment} />
<div className="d-flex flex-column p-4">
<CommentHeader comment={comment} actionHandlers={actionHandlers} postType={postType} />
{isEditing
? (
<CommentEditor comment={comment} onCloseEditor={() => setEditing(false)} />
)
// eslint-disable-next-line react/no-danger
: <div className="comment-body px-2" dangerouslySetInnerHTML={{ __html: comment.renderedBody }} />}
<CommentIcons
comment={comment}
following={comment.following}
onLike={() => dispatch(editComment(comment.id, { voted: !comment.voted }))}
createdAt={comment.createdAt}
/> />
<EndorsedAlertBanner postType={postType} content={comment} /> <div className="d-flex my-2 flex-column">
<div className="d-flex flex-column p-4.5"> {/* Pass along intl since component used here is the one before it's injected with `injectIntl` */}
<AlertBanner content={comment} /> {inlineReplies.map(inlineReply => (
<CommentHeader comment={comment} actionHandlers={actionHandlers} postType={postType} /> <Reply
{isEditing reply={inlineReply}
? ( postType={postType}
<CommentEditor comment={comment} onCloseEditor={() => setEditing(false)} formClasses="pt-3" /> key={inlineReply.id}
) intl={intl}
: <HTMLLoader cssClassName="comment-body pt-4 text-primary-500" componentId="comment" htmlNode={comment.renderedBody} />} />
<CommentIcons ))}
comment={comment} </div>
following={comment.following} {hasMorePages && (
onLike={() => dispatch(editComment(comment.id, { voted: !comment.voted }))}
createdAt={comment.createdAt}
/>
<div className="sr-only" role="heading" aria-level="3"> {intl.formatMessage(messages.replies, { count: inlineReplies.length })}</div>
<div className="d-flex flex-column" role="list">
{/* Pass along intl since component used here is the one before it's injected with `injectIntl` */}
{inlineReplies.map(inlineReply => (
<Reply
reply={inlineReply}
postType={postType}
key={inlineReply.id}
intl={intl}
/>
))}
</div>
{hasMorePages && (
<Button <Button
onClick={handleLoadMoreComments} onClick={handleLoadMoreComments}
variant="link" variant="link"
block="true" block="true"
className="mt-4.5 font-size-14 font-style-normal font-family-inter font-weight-500 px-2.5 py-2" className="my-4"
data-testid="load-more-comments-responses" data-testid="load-more-comments-responses"
style={{
lineHeight: '20px',
}}
> >
{intl.formatMessage(messages.loadMoreComments)} {intl.formatMessage(messages.loadMoreResponses)}
</Button> </Button>
)}
{!isNested && showFullThread
&& (
isReplying
? (
<CommentEditor
comment={{
threadId: comment.threadId,
parentId: comment.id,
}}
edit={false}
onCloseEditor={() => setReplying(false)}
/>
)
: (
<Button className="d-flex flex-grow " variant="outline-secondary" onClick={() => setReplying(true)}>
{intl.formatMessage(messages.addComment)}
</Button>
)
)} )}
{!isNested && showFullThread && (
isReplying ? (
<CommentEditor
comment={{
threadId: comment.threadId,
parentId: comment.id,
}}
edit={false}
onCloseEditor={() => setReplying(false)}
/>
) : (
<>
{(!isClosedPost && !inBlackoutDateRange(blackoutDateRange))
&& (
<Button
className="d-flex flex-grow mt-3 py-2 font-size-14"
variant="outline-primary"
style={{
lineHeight: '20px',
}}
onClick={() => setReplying(true)}
>
{intl.formatMessage(messages.addComment)}
</Button>
)}
</>
)
)}
</div>
</div> </div>
</div> </div>
); );
@@ -154,13 +131,11 @@ Comment.propTypes = {
postType: PropTypes.oneOf(['discussion', 'question']).isRequired, postType: PropTypes.oneOf(['discussion', 'question']).isRequired,
comment: commentShape.isRequired, comment: commentShape.isRequired,
showFullThread: PropTypes.bool, showFullThread: PropTypes.bool,
isClosedPost: PropTypes.bool,
intl: intlShape.isRequired, intl: intlShape.isRequired,
}; };
Comment.defaultProps = { Comment.defaultProps = {
showFullThread: true, showFullThread: true,
isClosedPost: false,
}; };
export default injectIntl(Comment); export default injectIntl(Comment);

View File

@@ -10,15 +10,8 @@ import { AppContext } from '@edx/frontend-platform/react';
import { Button, Form, StatefulButton } from '@edx/paragon'; import { Button, Form, StatefulButton } from '@edx/paragon';
import { TinyMCEEditor } from '../../../components'; import { TinyMCEEditor } from '../../../components';
import FormikErrorFeedback from '../../../components/FormikErrorFeedback';
import PostPreviewPane from '../../../components/PostPreviewPane';
import { useDispatchWithState } from '../../../data/hooks'; import { useDispatchWithState } from '../../../data/hooks';
import { import { selectModerationSettings, selectUserIsPrivileged } from '../../data/selectors';
selectModerationSettings,
selectUserHasModerationPrivileges,
selectUserIsGroupTa,
selectUserIsStaff,
} from '../../data/selectors';
import { formikCompatibleHandler, isFormikFieldInvalid } from '../../utils'; import { formikCompatibleHandler, isFormikFieldInvalid } from '../../utils';
import { addComment, editComment } from '../data/thunks'; import { addComment, editComment } from '../data/thunks';
import messages from '../messages'; import messages from '../messages';
@@ -28,48 +21,15 @@ function CommentEditor({
comment, comment,
onCloseEditor, onCloseEditor,
edit, edit,
formClasses,
}) { }) {
const editorRef = useRef(null);
const { authenticatedUser } = useContext(AppContext); const { authenticatedUser } = useContext(AppContext);
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges); const userIsPrivileged = useSelector(selectUserIsPrivileged);
const userIsGroupTa = useSelector(selectUserIsGroupTa);
const userIsStaff = useSelector(selectUserIsStaff);
const { reasonCodesEnabled, editReasons } = useSelector(selectModerationSettings); const { reasonCodesEnabled, editReasons } = useSelector(selectModerationSettings);
const [submitting, dispatch] = useDispatchWithState(); const [submitting, dispatch] = useDispatchWithState();
const editorRef = useRef(null);
const canDisplayEditReason = (reasonCodesEnabled && edit const saveUpdatedComment = async (values) => {
&& (userHasModerationPrivileges || userIsGroupTa || userIsStaff)
&& comment?.author !== authenticatedUser.username
);
const editReasonCodeValidation = canDisplayEditReason && {
editReasonCode: Yup.string().required(intl.formatMessage(messages.editReasonCodeError)),
};
const validationSchema = Yup.object().shape({
comment: Yup.string()
.required(),
...editReasonCodeValidation,
});
const initialValues = {
comment: comment.rawBody,
editReasonCode: comment?.lastEdit?.reasonCode || (userIsStaff ? 'violates-guidelines' : ''),
};
const handleCloseEditor = (resetForm) => {
resetForm({ values: initialValues });
onCloseEditor();
};
const saveUpdatedComment = async (values, { resetForm }) => {
if (comment.id) { if (comment.id) {
const payload = { await dispatch(editComment(comment.id, values));
...values,
editReasonCode: values.editReasonCode || undefined,
};
await dispatch(editComment(comment.id, payload));
} else { } else {
await dispatch(addComment(values.comment, comment.threadId, comment.parentId)); await dispatch(addComment(values.comment, comment.threadId, comment.parentId));
} }
@@ -77,16 +37,22 @@ function CommentEditor({
if (editorRef.current) { if (editorRef.current) {
editorRef.current.plugins.autosave.removeDraft(); editorRef.current.plugins.autosave.removeDraft();
} }
handleCloseEditor(resetForm); onCloseEditor();
}; };
// The editorId is used to autosave contents to localstorage. This format means that the autosave is scoped to // The editorId is used to autosave contents to localstorage. This format means that the autosave is scoped to
// the current comment id, or the current comment parent or the curren thread. // the current comment id, or the current comment parent or the curren thread.
const editorId = `comment-editor-${comment.id || comment.parentId || comment.threadId}`; const editorId = `comment-editor-${comment.id || comment.parentId || comment.threadId}`;
return ( return (
<Formik <Formik
initialValues={initialValues} initialValues={{ comment: comment.rawBody }}
validationSchema={validationSchema} validationSchema={Yup.object()
.shape({
comment: Yup.string()
.required(),
editReasonCode: Yup.string()
.nullable()
.default(undefined),
})}
onSubmit={saveUpdatedComment} onSubmit={saveUpdatedComment}
> >
{({ {({
@@ -96,19 +62,17 @@ function CommentEditor({
handleSubmit, handleSubmit,
handleBlur, handleBlur,
handleChange, handleChange,
resetForm,
}) => ( }) => (
<Form onSubmit={handleSubmit} className={formClasses}> <Form onSubmit={handleSubmit}>
{canDisplayEditReason && ( {(reasonCodesEnabled
<Form.Group && userIsPrivileged
isInvalid={isFormikFieldInvalid('editReasonCode', { && comment.author !== authenticatedUser.username
errors, && edit
touched, ) && (
})} <Form.Group>
>
<Form.Control <Form.Control
name="editReasonCode" name="editReasonCode"
className="mt-2 mr-0" className="mt-2"
as="select" as="select"
value={values.editReasonCode} value={values.editReasonCode}
onChange={handleChange} onChange={handleChange}
@@ -124,7 +88,6 @@ function CommentEditor({
<option key={code} value={code}>{label}</option> <option key={code} value={code}>{label}</option>
))} ))}
</Form.Control> </Form.Control>
<FormikErrorFeedback name="editReasonCode" />
</Form.Group> </Form.Group>
)} )}
<TinyMCEEditor <TinyMCEEditor
@@ -148,11 +111,10 @@ function CommentEditor({
{intl.formatMessage(messages.commentError)} {intl.formatMessage(messages.commentError)}
</Form.Control.Feedback> </Form.Control.Feedback>
)} )}
<PostPreviewPane htmlNode={values.comment} />
<div className="d-flex py-2 justify-content-end"> <div className="d-flex py-2 justify-content-end">
<Button <Button
variant="outline-primary" variant="outline-primary"
onClick={() => handleCloseEditor(resetForm)} onClick={onCloseEditor}
> >
{intl.formatMessage(messages.cancel)} {intl.formatMessage(messages.cancel)}
</Button> </Button>
@@ -180,17 +142,14 @@ CommentEditor.propTypes = {
parentId: PropTypes.string, parentId: PropTypes.string,
rawBody: PropTypes.string, rawBody: PropTypes.string,
author: PropTypes.string, author: PropTypes.string,
lastEdit: PropTypes.object,
}).isRequired, }).isRequired,
onCloseEditor: PropTypes.func.isRequired, onCloseEditor: PropTypes.func.isRequired,
intl: intlShape.isRequired, intl: intlShape.isRequired,
edit: PropTypes.bool, edit: PropTypes.bool,
formClasses: PropTypes.string,
}; };
CommentEditor.defaultProps = { CommentEditor.defaultProps = {
edit: true, edit: true,
formClasses: '',
}; };
export default injectIntl(CommentEditor); export default injectIntl(CommentEditor);

View File

@@ -1,17 +1,15 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { injectIntl } from '@edx/frontend-platform/i18n'; import { injectIntl } from '@edx/frontend-platform/i18n';
import { Avatar, Icon } from '@edx/paragon'; import { Avatar, Icon } from '@edx/paragon';
import { CheckCircle, Verified } from '@edx/paragon/icons'; import { CheckCircle, Verified } from '@edx/paragon/icons';
import { AvatarOutlineAndLabelColors, ThreadType } from '../../../data/constants'; import { AvatarBorderAndLabelColors, ThreadType } from '../../../data/constants';
import { AuthorLabel } from '../../common'; import { AuthorLabel } from '../../common';
import ActionsDropdown from '../../common/ActionsDropdown'; import ActionsDropdown from '../../common/ActionsDropdown';
import { useAlertBannerVisible } from '../../data/hooks';
import { selectAuthorAvatars } from '../../posts/data/selectors'; import { selectAuthorAvatars } from '../../posts/data/selectors';
import { commentShape } from './proptypes'; import { commentShape } from './proptypes';
@@ -21,37 +19,22 @@ function CommentHeader({
actionHandlers, actionHandlers,
}) { }) {
const authorAvatars = useSelector(selectAuthorAvatars(comment.author)); const authorAvatars = useSelector(selectAuthorAvatars(comment.author));
const colorClass = AvatarOutlineAndLabelColors[comment.authorLabel]; const colorClass = AvatarBorderAndLabelColors[comment.authorLabel];
const hasAnyAlert = useAlertBannerVisible(comment);
return ( return (
<div className={classNames('d-flex flex-row justify-content-between', { <div className="d-flex flex-row justify-content-between">
'mt-2': hasAnyAlert,
})}
>
<div className="align-items-center d-flex flex-row"> <div className="align-items-center d-flex flex-row">
<Avatar <Avatar
className={`border-0 ml-0.5 mr-2.5 ${colorClass ? `outline-${colorClass}` : 'outline-anonymous'}`} className={`m-2 ${colorClass && `border-${colorClass}`}`}
style={{ borderWidth: '2px' }}
alt={comment.author} alt={comment.author}
src={authorAvatars?.imageUrlSmall} src={authorAvatars?.imageUrlSmall}
style={{
width: '32px',
height: '32px',
}}
/>
<AuthorLabel
author={comment.author}
authorLabel={comment.authorLabel}
labelColor={colorClass && `text-${colorClass}`}
linkToProfile
/> />
<AuthorLabel author={comment.author} authorLabel={comment.authorLabel} labelColor={colorClass && `text-${colorClass}`} />
</div> </div>
<div className="d-flex align-items-center"> <div className="d-flex align-items-center">
<span className="btn-icon btn-icon-sm mr-1 align-items-center"> {comment.endorsed && (postType === 'question'
{comment.endorsed && (postType === 'question' ? <Icon src={CheckCircle} className="text-success" data-testid="check-icon" />
? <Icon src={CheckCircle} className="text-success" data-testid="check-icon" /> : <Icon src={Verified} data-testid="verified-icon" />)}
: <Icon src={Verified} className="text-dark-500" data-testid="verified-icon" />)}
</span>
<ActionsDropdown <ActionsDropdown
commentOrPost={{ commentOrPost={{
...comment, ...comment,

View File

@@ -7,7 +7,6 @@ import { initializeMockApp } from '@edx/frontend-platform';
import { AppProvider } from '@edx/frontend-platform/react'; import { AppProvider } from '@edx/frontend-platform/react';
import { initializeStore } from '../../../store'; import { initializeStore } from '../../../store';
import { DiscussionContext } from '../../common/context';
import CommentHeader from './CommentHeader'; import CommentHeader from './CommentHeader';
let store; let store;
@@ -16,11 +15,7 @@ function renderComponent(comment, postType, actionHandlers) {
return render( return render(
<IntlProvider locale="en"> <IntlProvider locale="en">
<AppProvider store={store}> <AppProvider store={store}>
<DiscussionContext.Provider <CommentHeader comment={comment} postType={postType} actionHandlers={actionHandlers} />
value={{ courseId: 'course-v1:edX+TestX+Test_Course' }}
>
<CommentHeader comment={comment} postType={postType} actionHandlers={actionHandlers} />
</DiscussionContext.Provider>
</AppProvider> </AppProvider>
</IntlProvider>, </IntlProvider>,
); );

View File

@@ -7,13 +7,10 @@ import * as timeago from 'timeago.js';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Avatar, useToggle } from '@edx/paragon'; import { Avatar, useToggle } from '@edx/paragon';
import HTMLLoader from '../../../components/HTMLLoader'; import { AvatarBorderAndLabelColors, ContentActions } from '../../../data/constants';
import { AvatarOutlineAndLabelColors, ContentActions } from '../../../data/constants';
import { import {
ActionsDropdown, AlertBanner, AuthorLabel, DeleteConfirmation, ActionsDropdown, AlertBanner, AuthorLabel, DeleteConfirmation,
} from '../../common'; } from '../../common';
import timeLocale from '../../common/time-locale';
import { useAlertBannerVisible } from '../../data/hooks';
import { selectAuthorAvatars } from '../../posts/data/selectors'; import { selectAuthorAvatars } from '../../posts/data/selectors';
import { editComment, removeComment } from '../data/thunks'; import { editComment, removeComment } from '../data/thunks';
import messages from '../messages'; import messages from '../messages';
@@ -25,26 +22,19 @@ function Reply({
postType, postType,
intl, intl,
}) { }) {
timeago.register('time-locale', timeLocale);
const dispatch = useDispatch(); const dispatch = useDispatch();
const [isEditing, setEditing] = useState(false); const [isEditing, setEditing] = useState(false);
const [isDeleting, showDeleteConfirmation, hideDeleteConfirmation] = useToggle(false); const [isDeleting, showDeleteConfirmation, hideDeleteConfirmation] = useToggle(false);
const actionHandlers = { const actionHandlers = {
[ContentActions.EDIT_CONTENT]: () => setEditing(true), [ContentActions.EDIT_CONTENT]: () => setEditing(true),
[ContentActions.ENDORSE]: () => dispatch(editComment( [ContentActions.ENDORSE]: () => dispatch(editComment(reply.id, { endorsed: !reply.endorsed })),
reply.id,
{ endorsed: !reply.endorsed },
ContentActions.ENDORSE,
)),
[ContentActions.DELETE]: showDeleteConfirmation, [ContentActions.DELETE]: showDeleteConfirmation,
[ContentActions.REPORT]: () => dispatch(editComment(reply.id, { flagged: !reply.abuseFlagged })), [ContentActions.REPORT]: () => dispatch(editComment(reply.id, { flagged: !reply.abuseFlagged })),
}; };
const authorAvatars = useSelector(selectAuthorAvatars(reply.author)); const authorAvatars = useSelector(selectAuthorAvatars(reply.author));
const colorClass = AvatarOutlineAndLabelColors[reply.authorLabel]; const colorClass = AvatarBorderAndLabelColors[reply.authorLabel];
const hasAnyAlert = useAlertBannerVisible(reply);
return ( return (
<div className="d-flex flex-column mt-4.5" data-testid={`reply-${reply.id}`} role="listitem"> <div className="d-flex my-2 flex-column" data-testid={`reply-${reply.id}`}>
<DeleteConfirmation <DeleteConfirmation
isOpen={isDeleting} isOpen={isDeleting}
title={intl.formatMessage(messages.deleteCommentTitle)} title={intl.formatMessage(messages.deleteCommentTitle)}
@@ -55,36 +45,22 @@ function Reply({
hideDeleteConfirmation(); hideDeleteConfirmation();
}} }}
/> />
<div className="d-flex flex-fill ml-6">
{hasAnyAlert && ( <AlertBanner postType={null} content={reply} intl={intl} />
<div className="d-flex"> </div>
<div className="d-flex invisible">
<Avatar />
</div>
<div className="w-100">
<AlertBanner content={reply} intl={intl} />
</div>
</div>
)}
<div className="d-flex"> <div className="d-flex">
<div className="d-flex mr-3 mt-2.5">
<div className="d-flex m-3">
<Avatar <Avatar
className={`ml-0.5 mt-0.5 border-0 ${colorClass ? `outline-${colorClass}` : 'outline-anonymous'}`} className={`m-2 ${colorClass && `border-${colorClass}`}`}
style={{ borderWidth: '2px' }}
alt={reply.author} alt={reply.author}
src={authorAvatars?.imageUrlSmall} src={authorAvatars?.imageUrlSmall}
style={{
width: '32px',
height: '32px',
}}
/> />
</div> </div>
<div <div className="rounded bg-light-300 px-4 py-2 flex-fill">
className="bg-light-300 px-4 pb-2 pt-2.5 flex-fill" <div className="d-flex flex-row justify-content-between align-items-center">
style={{ borderRadius: '0rem 0.375rem 0.375rem' }} <AuthorLabel author={reply.author} authorLabel={reply.authorLabel} labelColor={colorClass && `text-${colorClass}`} />
>
<div className="d-flex flex-row justify-content-between align-items-center mb-0.5">
<AuthorLabel author={reply.author} authorLabel={reply.authorLabel} labelColor={colorClass && `text-${colorClass}`} linkToProfile />
<ActionsDropdown <ActionsDropdown
commentOrPost={{ commentOrPost={{
...reply, ...reply,
@@ -95,11 +71,13 @@ function Reply({
</div> </div>
{isEditing {isEditing
? <CommentEditor comment={reply} onCloseEditor={() => setEditing(false)} /> ? <CommentEditor comment={reply} onCloseEditor={() => setEditing(false)} />
: <HTMLLoader componentId="reply" htmlNode={reply.renderedBody} cssClassName="text-primary-500" />} // eslint-disable-next-line react/no-danger
: <div dangerouslySetInnerHTML={{ __html: reply.renderedBody }} />}
</div> </div>
</div> </div>
<div className="text-gray-500 align-self-end mt-2" title={reply.createdAt}> <div className="text-gray-500 align-self-end mt-2" title={reply.createdAt}>
{timeago.format(reply.createdAt, 'time-locale')} {timeago.format(reply.createdAt, intl.locale)}
</div> </div>
</div> </div>
); );

View File

@@ -1,50 +1,27 @@
import React, { useEffect, useState } from 'react'; import React, { useState } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button } from '@edx/paragon'; import { Button } from '@edx/paragon';
import { selectBlackoutDate } from '../../data/selectors';
import { inBlackoutDateRange } from '../../utils';
import messages from '../messages'; import messages from '../messages';
import CommentEditor from './CommentEditor'; import CommentEditor from './CommentEditor';
function ResponseEditor({ function ResponseEditor({
postId, postId,
intl, intl,
addWrappingDiv,
}) { }) {
const [addingResponse, setAddingResponse] = useState(false); const [addingResponse, setAddingResponse] = useState(false);
useEffect(() => {
setAddingResponse(false);
}, [postId]);
const blackoutDateRange = useSelector(selectBlackoutDate);
return addingResponse return addingResponse
? ( ? (
<div className={classNames({ 'bg-white p-4 mb-4 rounded': addWrappingDiv })}> <CommentEditor
<CommentEditor comment={{ threadId: postId }}
comment={{ threadId: postId }} edit={false}
edit={false} onCloseEditor={() => setAddingResponse(false)}
onCloseEditor={() => setAddingResponse(false)} />
/> ) : (
</div> <div className="actions d-flex">
) <Button variant="primary" onClick={() => setAddingResponse(true)}>
: !inBlackoutDateRange(blackoutDateRange) && (
<div className={classNames({ 'mb-4': addWrappingDiv }, 'actions d-flex')}>
<Button
variant="primary"
className="px-2.5 py-2 font-size-14"
onClick={() => setAddingResponse(true)}
style={{
lineHeight: '20px',
}}
>
{intl.formatMessage(messages.addResponse)} {intl.formatMessage(messages.addResponse)}
</Button> </Button>
</div> </div>
@@ -54,11 +31,6 @@ function ResponseEditor({
ResponseEditor.propTypes = { ResponseEditor.propTypes = {
postId: PropTypes.string.isRequired, postId: PropTypes.string.isRequired,
intl: intlShape.isRequired, intl: intlShape.isRequired,
addWrappingDiv: PropTypes.bool,
};
ResponseEditor.defaultProps = {
addWrappingDiv: false,
}; };
export default injectIntl(ResponseEditor); export default injectIntl(ResponseEditor);

View File

@@ -117,3 +117,29 @@ export async function deleteComment(commentId) {
await getAuthenticatedHttpClient() await getAuthenticatedHttpClient()
.delete(url); .delete(url);
} }
/**
* Get the comments by a specific user in a course's discussions
*
* comments = responses + comments in the UI
*
* @param {string} courseId Course ID for the course
* @param {string} username Username of the user
* @returns API response in the format
* {
* results: [array of comments],
* pagination: {count, num_pages, next, previous}
* }
*/
export async function getUserComments(courseId, username, { page }) {
const { data } = await getAuthenticatedHttpClient()
.get(commentsApiUrl, {
params: {
course_id: courseId,
username,
page,
},
});
return data;
}

View File

@@ -145,18 +145,6 @@ const commentsSlice = createSlice({
state.commentsById[payload.id] = payload; state.commentsById[payload.id] = payload;
state.commentDraft = null; state.commentDraft = null;
}, },
updateCommentsList: (state, { payload }) => {
const { id: commentId, threadId, endorsed } = payload;
const commentAddListtype = endorsed ? EndorsementStatus.ENDORSED : EndorsementStatus.UNENDORSED;
const commentRemoveListType = !endorsed ? EndorsementStatus.ENDORSED : EndorsementStatus.UNENDORSED;
state.commentsInThreads[threadId][commentRemoveListType] = (
state.commentsInThreads[threadId]?.[commentRemoveListType]?.filter(item => item !== commentId)
);
state.commentsInThreads[threadId][commentAddListtype] = [
...state.commentsInThreads[threadId][commentAddListtype], payload.id,
];
},
deleteCommentRequest: (state) => { deleteCommentRequest: (state) => {
state.postStatus = RequestStatus.IN_PROGRESS; state.postStatus = RequestStatus.IN_PROGRESS;
}, },
@@ -201,7 +189,6 @@ export const {
updateCommentFailed, updateCommentFailed,
updateCommentRequest, updateCommentRequest,
updateCommentSuccess, updateCommentSuccess,
updateCommentsList,
deleteCommentDenied, deleteCommentDenied,
deleteCommentFailed, deleteCommentFailed,
deleteCommentRequest, deleteCommentRequest,

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';
@@ -117,15 +116,12 @@ export function fetchCommentResponses(commentId, { page = 1 } = {}) {
}; };
} }
export function editComment(commentId, comment, action = null) { export function editComment(commentId, comment) {
return async (dispatch) => { return async (dispatch) => {
try { try {
dispatch(updateCommentRequest({ commentId })); dispatch(updateCommentRequest({ commentId }));
const data = await updateComment(commentId, comment); const data = await updateComment(commentId, comment);
dispatch(updateCommentSuccess(camelCaseObject(data))); dispatch(updateCommentSuccess(camelCaseObject(data)));
if (action === ContentActions.ENDORSE) {
dispatch(updateCommentsList(camelCaseObject(data)));
}
} catch (error) { } catch (error) {
if (getHttpErrorStatus(error) === 403) { if (getHttpErrorStatus(error) === 403) {
dispatch(updateCommentDenied()); dispatch(updateCommentDenied());

View File

@@ -26,7 +26,7 @@ const messages = defineMessages({
}, },
endorsedResponseCount: { endorsedResponseCount: {
id: 'discussions.comments.comment.endorsedResponseCount', id: 'discussions.comments.comment.endorsedResponseCount',
defaultMessage: `{num, plural, defaultMessage: `{num, plural,
=0 {No endorsed responses} =0 {No endorsed responses}
one {Showing # endorsed response} one {Showing # endorsed response}
other {Showing # endorsed responses} other {Showing # endorsed responses}
@@ -55,7 +55,6 @@ const messages = defineMessages({
defaultMessage: `{postType, select, defaultMessage: `{postType, select,
discussion {Discussion} discussion {Discussion}
question {Question} question {Question}
other {{postType}}
} posted {relativeTime} by`, } posted {relativeTime} by`,
description: 'Timestamp for when a user posted the message followed by username. The relative time is already translated.', description: 'Timestamp for when a user posted the message followed by username. The relative time is already translated.',
}, },
@@ -148,11 +147,6 @@ const messages = defineMessages({
defaultMessage: 'Reason for editing', defaultMessage: 'Reason for editing',
description: 'Label for field visible to moderators that allows them to select a reason for editing another user\'s response', description: 'Label for field visible to moderators that allows them to select a reason for editing another user\'s response',
}, },
editReasonCodeError: {
id: 'discussions.editor.posts.editReasonCode.error',
defaultMessage: 'Select reason for editing',
description: 'Error message visible to moderators when they submit the post/response/comment without select reason for editing',
},
editedBy: { editedBy: {
id: 'discussions.comment.comments.editedBy', id: 'discussions.comment.comments.editedBy',
defaultMessage: 'Edited by', defaultMessage: 'Edited by',
@@ -167,16 +161,6 @@ const messages = defineMessages({
id: 'discussions.post.closedBy', id: 'discussions.post.closedBy',
defaultMessage: 'Post closed by', defaultMessage: 'Post closed by',
}, },
replies: {
id: 'discussion.comment.repliesHeading',
defaultMessage: '{count} replies for the response added',
description: 'Text added for screen reader to understand nesting replies.',
},
time: {
id: 'discussion.comment.time',
defaultMessage: '{time} ago',
description: 'Time text for endorse banner',
},
}); });
export default messages; export default messages;

View File

@@ -1,21 +1,18 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { logError } from '@edx/frontend-platform/logging'; import { logError } from '@edx/frontend-platform/logging';
import { import {
Button, Dropdown, Icon, IconButton, ModalPopup, useToggle, Button, Dropdown, Icon, IconButton, ModalPopup,
} from '@edx/paragon'; } from '@edx/paragon';
import { MoreHoriz } from '@edx/paragon/icons'; import { MoreHoriz } from '@edx/paragon/icons';
import { ContentActions } from '../../data/constants'; import { ContentActions } from '../../data/constants';
import { commentShape } from '../comments/comment/proptypes'; import { commentShape } from '../comments/comment/proptypes';
import { selectBlackoutDate } from '../data/selectors';
import messages from '../messages'; import messages from '../messages';
import { postShape } from '../posts/post/proptypes'; import { postShape } from '../posts/post/proptypes';
import { inBlackoutDateRange, useActions } from '../utils'; import { useActions } from '../utils';
function ActionsDropdown({ function ActionsDropdown({
intl, intl,
@@ -23,8 +20,8 @@ function ActionsDropdown({
disabled, disabled,
actionHandlers, actionHandlers,
}) { }) {
const [isOpen, open, close] = useToggle(false); const [isOpen, setOpen] = useState(false);
const [target, setTarget] = useState(null); const dropdownIconRef = React.useRef(null);
const actions = useActions(commentOrPost); const actions = useActions(commentOrPost);
const handleActions = (action) => { const handleActions = (action) => {
const actionFunction = actionHandlers[action]; const actionFunction = actionHandlers[action];
@@ -34,25 +31,20 @@ function ActionsDropdown({
logError(`Unknown or unimplemented action ${action}`); logError(`Unknown or unimplemented action ${action}`);
} }
}; };
const blackoutDateRange = useSelector(selectBlackoutDate);
// Find and remove edit action if in blackout date range.
if (inBlackoutDateRange(blackoutDateRange)) {
actions.splice(actions.findIndex(action => action.id === 'edit'), 1);
}
return ( return (
<> <>
<IconButton <span ref={dropdownIconRef}>
onClick={open} <IconButton
alt={intl.formatMessage(messages.actionsAlt)} onClick={() => setOpen(!isOpen)}
src={MoreHoriz} alt={intl.formatMessage(messages.actionsAlt)}
iconAs={Icon} src={MoreHoriz}
disabled={disabled} iconAs={Icon}
size="sm" disabled={disabled}
ref={setTarget} />
/> </span>
<ModalPopup <ModalPopup
onClose={close} onClose={() => setOpen(false)}
positionRef={target} positionRef={dropdownIconRef}
isOpen={isOpen} isOpen={isOpen}
placement="auto-start" placement="auto-start"
> >
@@ -70,7 +62,7 @@ function ActionsDropdown({
variant="tertiary" variant="tertiary"
size="inline" size="inline"
onClick={() => { onClick={() => {
close(); setOpen(false);
handleActions(action.action); handleActions(action.action);
}} }}
className="d-flex justify-content-start py-1.5 mr-4" className="d-flex justify-content-start py-1.5 mr-4"

View File

@@ -9,7 +9,6 @@ import { camelCaseObject, initializeMockApp, snakeCaseObject } from '@edx/fronte
import { AppProvider } from '@edx/frontend-platform/react'; import { AppProvider } from '@edx/frontend-platform/react';
import { ContentActions } from '../../data/constants'; import { ContentActions } from '../../data/constants';
import { initializeStore } from '../../store';
import messages from '../messages'; import messages from '../messages';
import { ACTIONS_LIST } from '../utils'; import { ACTIONS_LIST } from '../utils';
import ActionsDropdown from './ActionsDropdown'; import ActionsDropdown from './ActionsDropdown';
@@ -127,7 +126,6 @@ describe('ActionsDropdown', () => {
roles: [], roles: [],
}, },
}); });
store = initializeStore();
}); });
it.each(buildTestContent())('can open drop down if enabled', async (commentOrPost) => { it.each(buildTestContent())('can open drop down if enabled', async (commentOrPost) => {
@@ -152,36 +150,6 @@ describe('ActionsDropdown', () => {
await waitFor(() => expect(screen.queryByTestId('actions-dropdown-modal-popup')).not.toBeInTheDocument()); await waitFor(() => expect(screen.queryByTestId('actions-dropdown-modal-popup')).not.toBeInTheDocument());
}); });
it('copy link action should be visible on posts', async () => {
const commentOrPost = {
testFor: 'thread',
...camelCaseObject(Factory.build('thread', { editable_fields: ['copy_link'] }, null)),
};
renderComponent(commentOrPost, { disabled: false });
const openButton = await findOpenActionsDropdownButton();
await act(async () => {
fireEvent.click(openButton);
});
await waitFor(() => expect(screen.queryByText('Copy link')).toBeInTheDocument());
});
it('copy link action should not be visible on a comment', async () => {
const commentOrPost = {
testFor: 'comments',
...camelCaseObject(Factory.build('comment', {}, null)),
};
renderComponent(commentOrPost, { disabled: false });
const openButton = await findOpenActionsDropdownButton();
await act(async () => {
fireEvent.click(openButton);
});
await waitFor(() => expect(screen.queryByText('Copy link')).not.toBeInTheDocument());
});
describe.each(canPerformActionTestData)('Actions', ({ describe.each(canPerformActionTestData)('Actions', ({
testFor, action, label, reason, ...commentOrPost testFor, action, label, reason, ...commentOrPost
}) => { }) => {

View File

@@ -2,62 +2,86 @@ import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import * as timeago from 'timeago.js';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Alert } from '@edx/paragon'; import { Alert } from '@edx/paragon';
import { Error } from '@edx/paragon/icons'; import { CheckCircle, Error, Verified } from '@edx/paragon/icons';
import { ThreadType } from '../../data/constants';
import { commentShape } from '../comments/comment/proptypes'; import { commentShape } from '../comments/comment/proptypes';
import messages from '../comments/messages'; import messages from '../comments/messages';
import { selectModerationSettings, selectUserHasModerationPrivileges, selectUserIsGroupTa } from '../data/selectors'; import { selectModerationSettings, selectUserIsPrivileged } from '../data/selectors';
import { postShape } from '../posts/post/proptypes'; import { postShape } from '../posts/post/proptypes';
import AuthorLabel from './AuthorLabel'; import AuthorLabel from './AuthorLabel';
function AlertBanner({ function AlertBanner({
intl, intl,
content, content,
postType,
}) { }) {
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges); const isQuestion = postType === ThreadType.QUESTION;
const userIsGroupTa = useSelector(selectUserIsGroupTa); const classes = isQuestion ? 'bg-success-500 text-white' : 'bg-dark-500 text-white';
const iconClass = isQuestion ? CheckCircle : Verified;
const userIsPrivileged = useSelector(selectUserIsPrivileged);
const { reasonCodesEnabled } = useSelector(selectModerationSettings); const { reasonCodesEnabled } = useSelector(selectModerationSettings);
const userIsContentAuthor = getAuthenticatedUser().username === content.author;
const canSeeLastEditOrClosedAlert = (userHasModerationPrivileges || userIsContentAuthor || userIsGroupTa);
const canSeeReportedBanner = content?.abuseFlagged;
return ( return (
<> <>
{canSeeReportedBanner && ( {content.endorsed && (
<Alert icon={Error} variant="danger" className="px-3 mb-2 py-10px shadow-none flex-fill"> <Alert
variant="plain"
className={`p-3 m-0 align-items-center shadow-none ${classes}`}
style={{ borderRadius: '0.375rem 0.375rem 0 0' }}
icon={iconClass}
>
<div className="d-flex justify-content-between">
<strong className="lead">{intl.formatMessage(
isQuestion
? messages.answer
: messages.endorsed,
)}
</strong>
<span className="d-flex align-items-center mr-1">
<span className="mr-2">
{intl.formatMessage(
isQuestion
? messages.answeredLabel
: messages.endorsedLabel,
)}
</span>
<AuthorLabel author={content.endorsedBy} authorLabel={content.endorsedByLabel} />
{timeago.format(content.endorsedAt, intl.locale)}
</span>
</div>
</Alert>
)}
{content.abuseFlagged && (
<Alert icon={Error} variant="danger" className="p-3 m-0 shadow-none mb-1 flex-fill">
{intl.formatMessage(messages.abuseFlaggedMessage)} {intl.formatMessage(messages.abuseFlaggedMessage)}
</Alert> </Alert>
)} )}
{reasonCodesEnabled && canSeeLastEditOrClosedAlert && ( {reasonCodesEnabled && userIsPrivileged && content.lastEdit?.reason && (
<> <Alert variant="info" className="p-3 m-0 shadow-none mb-1 bg-light-200">
{content.lastEdit?.reason && ( <div className="d-flex align-items-center">
<Alert variant="info" className="px-3 shadow-none mb-2 py-10px bg-light-200"> {intl.formatMessage(messages.editedBy)}
<div className="d-flex align-items-center flex-wrap"> <span className="ml-1 mr-3">
{intl.formatMessage(messages.editedBy)} <AuthorLabel author={content.lastEdit.editorUsername} linkToProfile />
<span className="ml-1 mr-3"> </span>
<AuthorLabel author={content.lastEdit.editorUsername} linkToProfile /> {intl.formatMessage(messages.reason)}:&nbsp;{content.lastEdit.reason}
</span> </div>
{intl.formatMessage(messages.reason)}:&nbsp;{content.lastEdit.reason} </Alert>
</div> )}
</Alert> {reasonCodesEnabled && content.closed && (
)} <Alert variant="info" className="p-3 m-0 shadow-none mb-1 bg-light-200">
{content.closed && ( <div className="d-flex align-items-center">
<Alert variant="info" className="px-3 shadow-none mb-2 py-10px bg-light-200"> {intl.formatMessage(messages.closedBy)}
<div className="d-flex align-items-center flex-wrap"> <span className="ml-1 ">
{intl.formatMessage(messages.closedBy)} <AuthorLabel author={content.closedBy} linkToProfile />
<span className="ml-1 "> </span>
<AuthorLabel author={content.closedBy} linkToProfile /> <span className="mx-1" />
</span> {intl.formatMessage(messages.reason)}:&nbsp;{content.closeReason}
<span className="mx-1" /> </div>
{content.closeReason && (`${intl.formatMessage(messages.reason)}: ${content.closeReason}`)} </Alert>
</div>
</Alert>
)}
</>
)} )}
</> </>
); );
@@ -66,6 +90,11 @@ function AlertBanner({
AlertBanner.propTypes = { AlertBanner.propTypes = {
intl: intlShape.isRequired, intl: intlShape.isRequired,
content: PropTypes.oneOfType([commentShape.isRequired, postShape.isRequired]).isRequired, content: PropTypes.oneOfType([commentShape.isRequired, postShape.isRequired]).isRequired,
postType: PropTypes.string,
};
AlertBanner.defaultProps = {
postType: null,
}; };
export default injectIntl(AlertBanner); export default injectIntl(AlertBanner);

View File

@@ -9,7 +9,6 @@ import { ThreadType } from '../../data/constants';
import { initializeStore } from '../../store'; import { initializeStore } from '../../store';
import messages from '../comments/messages'; import messages from '../comments/messages';
import AlertBanner from './AlertBanner'; import AlertBanner from './AlertBanner';
import { DiscussionContext } from './context';
import '../comments/data/__factories__'; import '../comments/data/__factories__';
import '../posts/data/__factories__'; import '../posts/data/__factories__';
@@ -23,17 +22,15 @@ function buildTestContent(type, buildParams) {
function renderComponent( function renderComponent(
content, content,
postType,
) { ) {
render( render(
<IntlProvider locale="en"> <IntlProvider locale="en">
<AppProvider store={store}> <AppProvider store={store}>
<DiscussionContext.Provider <AlertBanner
value={{ courseId: 'course-v1:edX+TestX+Test_Course' }} content={content}
> postType={postType}
<AlertBanner />
content={content}
/>
</DiscussionContext.Provider>
</AppProvider> </AppProvider>
</IntlProvider>, </IntlProvider>,
); );
@@ -47,6 +44,27 @@ describe.each([
props: { abuseFlagged: true }, props: { abuseFlagged: true },
expectText: [messages.abuseFlaggedMessage.defaultMessage], expectText: [messages.abuseFlaggedMessage.defaultMessage],
}, },
{
label: 'Staff endorsed comment in a question thread',
type: 'comment',
postType: ThreadType.QUESTION,
props: { endorsed: true, endorsedBy: 'test-user', endorsedByLabel: 'Staff' },
expectText: [messages.answer.defaultMessage, messages.answeredLabel.defaultMessage, 'test-user', 'Staff'],
},
{
label: 'TA endorsed comment in a question thread',
type: 'comment',
postType: ThreadType.QUESTION,
props: { endorsed: true, endorsedBy: 'test-user', endorsedByLabel: 'Community TA' },
expectText: [messages.answer.defaultMessage, messages.answeredLabel.defaultMessage, 'test-user', 'TA'],
},
{
label: 'endorsed comment in a discussion thread',
type: 'comment',
postType: ThreadType.DISCUSSION,
props: { endorsed: true, endorsedBy: 'test-user' },
expectText: [messages.endorsed.defaultMessage, messages.endorsedLabel.defaultMessage, 'test-user'],
},
{ {
label: 'flagged thread', label: 'flagged thread',
type: 'thread', type: 'thread',
@@ -58,7 +76,7 @@ describe.each([
label: 'edited content', label: 'edited content',
type: 'thread', type: 'thread',
postType: null, postType: null,
props: { closed: false, last_edit: { reason: 'test-reason', editorUsername: 'editor-user' } }, props: { last_edit: { reason: 'test-reason', editorUsername: 'editor-user' } },
expectText: [messages.editedBy.defaultMessage, messages.reason.defaultMessage, 'editor-user', 'test-reason'], expectText: [messages.editedBy.defaultMessage, messages.reason.defaultMessage, 'editor-user', 'test-reason'],
}, },
{ {
@@ -69,7 +87,7 @@ describe.each([
expectText: [messages.closedBy.defaultMessage, 'closing-user', 'test-close-reason'], expectText: [messages.closedBy.defaultMessage, 'closing-user', 'test-close-reason'],
}, },
])('AlertBanner', ({ ])('AlertBanner', ({
label, type, props, expectText, label, type, postType, props, expectText,
}) => { }) => {
beforeEach(async () => { beforeEach(async () => {
initializeMockApp({ initializeMockApp({
@@ -82,12 +100,12 @@ describe.each([
}); });
store = initializeStore({ store = initializeStore({
config: { config: {
hasModerationPrivileges: true, userIsPrivileged: true,
reasonCodesEnabled: true, reasonCodesEnabled: true,
}, },
}); });
const content = buildTestContent(type, props); const content = buildTestContent(type, props);
renderComponent(content); renderComponent(content, postType);
}); });
it(`should show correct banner for a ${label}`, async () => { it(`should show correct banner for a ${label}`, async () => {

View File

@@ -1,18 +1,13 @@
import React, { useContext } from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
import { Link, useLocation } from 'react-router-dom';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Icon } from '@edx/paragon'; import { Icon } from '@edx/paragon';
import { Institution, School } from '@edx/paragon/icons'; import { Institution, School } from '@edx/paragon/icons';
import { Routes } from '../../data/constants';
import { useShowLearnersTab } from '../data/hooks';
import messages from '../messages'; import messages from '../messages';
import { discussionsPath } from '../utils';
import { DiscussionContext } from './context';
function AuthorLabel({ function AuthorLabel({
intl, intl,
@@ -21,11 +16,8 @@ function AuthorLabel({
linkToProfile, linkToProfile,
labelColor, labelColor,
}) { }) {
const location = useLocation();
const { courseId } = useContext(DiscussionContext);
let icon = null; let icon = null;
let authorLabelMessage = null; let authorLabelMessage = null;
if (authorLabel === 'Staff') { if (authorLabel === 'Staff') {
icon = Institution; icon = Institution;
authorLabelMessage = intl.formatMessage(messages.authorLabelStaff); authorLabelMessage = intl.formatMessage(messages.authorLabelStaff);
@@ -34,26 +26,9 @@ function AuthorLabel({
icon = School; icon = School;
authorLabelMessage = intl.formatMessage(messages.authorLabelTA); authorLabelMessage = intl.formatMessage(messages.authorLabelTA);
} }
const isRetiredUser = author ? author.startsWith('retired__user') : false;
const className = classNames('d-flex align-items-center', labelColor);
const showUserNameAsLink = useShowLearnersTab()
&& linkToProfile && author && author !== intl.formatMessage(messages.anonymous);
const labelContents = ( const labelContents = (
<div className={className}> <>
<span <span className="mr-1">{author}</span>
className={classNames('mr-1 font-size-14 font-style-normal font-family-inter font-weight-500', {
'text-primary-500': !authorLabelMessage && !isRetiredUser,
'text-gray-700': isRetiredUser,
})}
role="heading"
aria-level="2"
>
{isRetiredUser ? '[Deactivated]' : author }
</span>
{icon && ( {icon && (
<Icon <Icon
style={{ style={{
@@ -64,35 +39,20 @@ function AuthorLabel({
/> />
)} )}
{authorLabelMessage && ( {authorLabelMessage && (
<span <span className="mr-3 ml-1">
className={classNames('mr-3 font-size-14 font-style-normal font-family-inter font-weight-500', {
'text-primary-500': !authorLabelMessage,
})}
style={{ marginLeft: '2px' }}
>
{authorLabelMessage} {authorLabelMessage}
</span> </span>
)} )}
</div> </>
); );
const className = classNames('d-flex align-items-center', labelColor);
return showUserNameAsLink return linkToProfile
? ( ? React.createElement('a', { href: '#nowhere', className }, labelContents)
<Link : React.createElement('div', { className }, labelContents);
data-testid="learner-posts-link"
id="learner-posts-link"
to={discussionsPath(Routes.LEARNERS.POSTS, { learnerUsername: author, courseId })(location)}
className="text-decoration-none"
style={{ width: 'fit-content' }}
>
{labelContents}
</Link>
)
: <>{labelContents}</>;
} }
AuthorLabel.propTypes = { AuthorLabel.propTypes = {
intl: intlShape.isRequired, intl: intlShape,
author: PropTypes.string.isRequired, author: PropTypes.string.isRequired,
authorLabel: PropTypes.string, authorLabel: PropTypes.string,
linkToProfile: PropTypes.bool, linkToProfile: PropTypes.bool,

View File

@@ -1,107 +0,0 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import { IntlProvider } from 'react-intl';
import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
import { initializeStore } from '../../store';
import { executeThunk } from '../../test-utils';
import { courseConfigApiUrl } from '../data/api';
import { fetchCourseConfig } from '../data/thunks';
import AuthorLabel from './AuthorLabel';
import { DiscussionContext } from './context';
const courseId = 'course-v1:edX+DemoX+Demo_Course';
let store;
let axiosMock;
let container;
function renderComponent(author, authorLabel, linkToProfile, labelColor) {
const wrapper = render(
<IntlProvider locale="en">
<AppProvider store={store}>
<DiscussionContext.Provider value={{ courseId }}>
<AuthorLabel
author={author}
authorLabel={authorLabel}
linkToProfile={linkToProfile}
labelColor={labelColor}
/>
</DiscussionContext.Provider>
</AppProvider>
</IntlProvider>,
);
container = wrapper.container;
return container;
}
describe('Author label', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`).reply(200, {
learners_tab_enabled: true,
has_moderation_privileges: true,
});
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/settings`).reply(200, {});
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
});
describe.each([
['anonymous', null, false, ''],
['ta_user', 'Community TA', true, 'text-TA-color'],
['retired__user', null, false, ''],
['staff_user', 'Staff', true, 'text-staff-color'],
['learner_user', null, false, ''],
])('for %s', (
author, authorLabel, linkToProfile, labelColor,
) => {
it('it has author name text',
async () => {
renderComponent(author, authorLabel, linkToProfile, labelColor);
const authorElement = container.querySelector('[role=heading]');
const authorName = author.startsWith('retired__user') ? '[Deactivated]' : author;
expect(authorElement).toHaveTextContent(authorName);
});
it(`it is "${!linkToProfile && 'not'}" clickable when linkToProfile is ${!!linkToProfile}`,
async () => {
renderComponent(author, authorLabel, linkToProfile, labelColor);
if (linkToProfile) {
expect(screen.queryByTestId('learner-posts-link')).toBeInTheDocument();
} else {
expect(screen.queryByTestId('learner-posts-link')).not.toBeInTheDocument();
}
});
it(`it has "${!linkToProfile && 'not'}" label text and label color when linkToProfile is ${!!linkToProfile}`,
async () => {
renderComponent(author, authorLabel, linkToProfile, labelColor);
const authorElement = container.querySelector('[role=heading]');
const labelElement = authorElement.parentNode.lastChild;
const label = ['TA', 'Staff'].includes(labelElement.textContent) && labelElement.textContent;
if (linkToProfile) {
expect(authorElement.parentNode).toHaveClass(labelColor);
expect(authorElement.parentNode.lastChild).toHaveTextContent(label);
} else {
expect(authorElement.parentNode.lastChild).not.toHaveTextContent(label, { exact: true });
expect(authorElement.parentNode).not.toHaveClass(labelColor, { exact: true });
}
});
});
});

View File

@@ -15,7 +15,7 @@ function DeleteConfirmation({
onDelete, onDelete,
}) { }) {
return ( return (
<ModalDialog title={title} isOpen={isOpen} hasCloseButton={false} onClose={onClose} zIndex={5000}> <ModalDialog title={title} isOpen={isOpen} hasCloseButton={false} onClose={onClose}>
<ModalDialog.Header> <ModalDialog.Header>
<ModalDialog.Title> <ModalDialog.Title>
{title} {title}

View File

@@ -1,68 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import * as timeago from 'timeago.js';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Alert } from '@edx/paragon';
import { CheckCircle, Verified } from '@edx/paragon/icons';
import { ThreadType } from '../../data/constants';
import { commentShape } from '../comments/comment/proptypes';
import messages from '../comments/messages';
import AuthorLabel from './AuthorLabel';
import timeLocale from './time-locale';
function EndorsedAlertBanner({
intl,
content,
postType,
}) {
timeago.register('time-locale', timeLocale);
const isQuestion = postType === ThreadType.QUESTION;
const classes = isQuestion ? 'bg-success-500 text-white' : 'bg-dark-500 text-white';
const iconClass = isQuestion ? CheckCircle : Verified;
return (
content.endorsed && (
<Alert
variant="plain"
className={`px-3 mb-0 py-10px align-items-center shadow-none ${classes}`}
style={{ borderRadius: '0.375rem 0.375rem 0 0' }}
icon={iconClass}
>
<div className="d-flex justify-content-between flex-wrap">
<strong className="lead">{intl.formatMessage(
isQuestion
? messages.answer
: messages.endorsed,
)}
</strong>
<span className="d-flex align-items-center mr-1 flex-wrap">
<span className="mr-2">
{intl.formatMessage(
isQuestion
? messages.answeredLabel
: messages.endorsedLabel,
)}
</span>
<AuthorLabel author={content.endorsedBy} authorLabel={content.endorsedByLabel} linkToProfile />
{intl.formatMessage(messages.time, { time: timeago.format(content.endorsedAt, 'time-locale') })}
</span>
</div>
</Alert>
)
);
}
EndorsedAlertBanner.propTypes = {
intl: intlShape.isRequired,
content: PropTypes.oneOfType([commentShape.isRequired]).isRequired,
postType: PropTypes.string,
};
EndorsedAlertBanner.defaultProps = {
postType: null,
};
export default injectIntl(EndorsedAlertBanner);

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 '../comments/messages';
import { DiscussionContext } from './context';
import EndorsedAlertBanner from './EndorsedAlertBanner';
import '../comments/data/__factories__';
import '../posts/data/__factories__';
let store;
function buildTestContent(type, buildParams) {
const buildParamsSnakeCase = snakeCaseObject(buildParams);
return camelCaseObject(Factory.build(type, { ...buildParamsSnakeCase }, null));
}
function renderComponent(
content, postType,
) {
render(
<IntlProvider locale="en">
<AppProvider store={store}>
<DiscussionContext.Provider
value={{ courseId: 'course-v1:edX+DemoX+Demo_Course' }}
>
<EndorsedAlertBanner
content={content}
postType={postType}
/>
</DiscussionContext.Provider>
</AppProvider>
</IntlProvider>,
);
}
describe.each([
{
label: 'Staff endorsed comment in a question thread',
type: 'comment',
postType: ThreadType.QUESTION,
props: { endorsed: true, endorsedBy: 'test-user', endorsedByLabel: 'Staff' },
expectText: [messages.answer.defaultMessage, messages.answeredLabel.defaultMessage, 'test-user', 'Staff'],
},
{
label: 'TA endorsed comment in a question thread',
type: 'comment',
postType: ThreadType.QUESTION,
props: { endorsed: true, endorsedBy: 'test-user', endorsedByLabel: 'Community TA' },
expectText: [messages.answer.defaultMessage, messages.answeredLabel.defaultMessage, 'test-user', 'TA'],
},
{
label: 'endorsed comment in a discussion thread',
type: 'comment',
postType: ThreadType.DISCUSSION,
props: { endorsed: true, endorsedBy: 'test-user' },
expectText: [messages.endorsed.defaultMessage, messages.endorsedLabel.defaultMessage, 'test-user'],
},
])('EndorsedAlertBanner', ({
label, type, postType, props, expectText,
}) => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: false,
roles: [],
},
});
store = initializeStore({
config: {
hasModerationPrivileges: true,
reasonCodesEnabled: true,
},
});
const content = buildTestContent(type, props);
renderComponent(content, postType);
});
it(`should show correct banner for a ${label}`, async () => {
expectText.forEach(message => {
expect(screen.queryAllByText(message, { exact: false }).length).toBeGreaterThan(0);
});
});
});

View File

@@ -2,4 +2,3 @@ export { default as ActionsDropdown } from './ActionsDropdown';
export { default as AlertBanner } from './AlertBanner'; export { default as AlertBanner } from './AlertBanner';
export { default as AuthorLabel } from './AuthorLabel'; export { default as AuthorLabel } from './AuthorLabel';
export { default as DeleteConfirmation } from './DeleteConfirmation'; export { default as DeleteConfirmation } from './DeleteConfirmation';
export { default as EndorsedAlertBanner } from './EndorsedAlertBanner';

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

@@ -1,30 +1,19 @@
/* eslint-disable import/prefer-default-export */ /* eslint-disable import/prefer-default-export */
import { import { useContext, useEffect, useRef } from 'react';
useContext, useEffect, useRef, useState,
} from 'react';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { useHistory, useLocation, useRouteMatch } from 'react-router'; import { useHistory, useLocation, useRouteMatch } from 'react-router';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { AppContext } from '@edx/frontend-platform/react'; import { AppContext } from '@edx/frontend-platform/react';
import { breakpoints, useWindowSize } from '@edx/paragon'; import { breakpoints, useWindowSize } from '@edx/paragon';
import { Routes } from '../../data/constants'; import { Routes } from '../../data/constants';
import { selectTopicsUnderCategory } from '../../data/selectors';
import { fetchCourseBlocks } from '../../data/thunks'; import { fetchCourseBlocks } from '../../data/thunks';
import { DiscussionContext } from '../common/context';
import { clearRedirect } from '../posts/data'; import { clearRedirect } from '../posts/data';
import { selectTopics } from '../topics/data/selectors'; import { selectTopics } from '../topics/data/selectors';
import { fetchCourseTopics } from '../topics/data/thunks'; import { fetchCourseTopics } from '../topics/data/thunks';
import { discussionsPath } from '../utils'; import { discussionsPath, postMessageToParent } from '../utils';
import { import { selectAreThreadsFiltered, selectPostThreadCount } from './selectors';
selectAreThreadsFiltered, selectLearnersTabEnabled,
selectModerationSettings,
selectPostThreadCount,
selectUserHasModerationPrivileges,
selectUserIsGroupTa,
} from './selectors';
import { fetchCourseConfig } from './thunks'; import { fetchCourseConfig } from './thunks';
export function useTotalTopicThreadCount() { export function useTotalTopicThreadCount() {
@@ -95,80 +84,60 @@ export function useRedirectToThread(courseId) {
} }
export function useIsOnDesktop() { export function useIsOnDesktop() {
return window.outerWidth >= breakpoints.large.minWidth; const windowSize = useWindowSize();
return windowSize.width >= breakpoints.large.minWidth;
} }
export function useIsOnXLDesktop() { /**
const windowSize = useWindowSize(); * Given an element this attempts to get the height of the entire UI.
return windowSize.width >= breakpoints.extraLarge.minWidth; *
* @param element
* @returns {number}
*/
function getOuterHeight(element) {
// This is the height of the entire document body.
const bodyHeight = document.body.offsetHeight;
// This is the height of the container that will scroll.
const elementContainerHeight = element.parentNode.clientHeight;
// The difference between the body height and the container height is the size of the header footer etc.
// Add to that the element's own height and we get the size the UI should be to fit everything.
return bodyHeight - elementContainerHeight + element.scrollHeight;
} }
/** /**
* This hook posts a resize message to the parent window if running in an iframe * This hook posts a resize message to the parent window if running in an iframe
* @param refContainer reference to the component whose size is to be measured * @param refContainer reference to the component whose size is to be measured
*/ */
export function useContainerSize(refContainer) { export function useContainerSizeForParent(refContainer) {
function postResizeMessage(height) {
postMessageToParent('plugin.resize', { height });
}
const location = useLocation(); const location = useLocation();
const [height, setHeight] = useState(); const enabled = window.parent !== window;
const resizeObserver = useRef(new ResizeObserver(() => { const resizeObserver = useRef(new ResizeObserver(() => {
/* istanbul ignore if: ResizeObserver isn't available in the testing env */ /* istanbul ignore if: ResizeObserver isn't available in the testing env */
if (refContainer?.current) { if (refContainer.current) {
setHeight(refContainer?.current?.clientHeight); postResizeMessage(getOuterHeight(refContainer.current));
} }
})); }));
useEffect(() => { useEffect(() => {
const container = refContainer?.current; const container = refContainer.current;
const observer = resizeObserver?.current; const observer = resizeObserver.current;
if (container && observer) { if (container && observer && enabled) {
observer.observe(container); observer.observe(container);
setHeight(container.clientHeight); postResizeMessage(getOuterHeight(container));
} }
return () => { return () => {
if (container && observer) { if (container && observer && enabled) {
observer.unobserve(container); observer.unobserve(container);
// Send a message to reset the size so that navigating to another
// page doesn't cause the size to be retained
postResizeMessage(null);
} }
}; };
}, [refContainer, resizeObserver, location]); }, [refContainer, resizeObserver, location]);
return height;
} }
export const useAlertBannerVisible = (content) => {
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
const userIsGroupTa = useSelector(selectUserIsGroupTa);
const { reasonCodesEnabled } = useSelector(selectModerationSettings);
const userIsContentAuthor = getAuthenticatedUser().username === content.author;
const canSeeLastEditOrClosedAlert = (userHasModerationPrivileges || userIsContentAuthor || userIsGroupTa);
const canSeeReportedBanner = content.abuseFlagged;
return (
(reasonCodesEnabled && canSeeLastEditOrClosedAlert && (content.lastEdit?.reason || content.closed))
|| (content.abuseFlagged && canSeeReportedBanner)
);
};
export const useShowLearnersTab = () => useSelector(selectLearnersTabEnabled);
/**
* React hook that gets the current topic ID from the current topic or category.
* The topicId in the DiscussionContext only return the direct topicId from the URL.
* If the URL has the current block ID it cannot get the topicID from that. This hook
* gets the topic ID from the URL if available, or from the current category otherwise.
* It only returns an ID if a single ID is available, if navigating a subsection it
* returns null.
* @returns {null|string} A topic ID if a single one available in the current context.
*/
export const useCurrentDiscussionTopic = () => {
const { topicId, category } = useContext(DiscussionContext);
const topics = useSelector(selectTopicsUnderCategory)(category);
if (topicId) {
return topicId;
}
if (topics?.length === 1) {
return topics[0];
}
return null;
};

View File

@@ -1,85 +1,68 @@
import { render } from '@testing-library/react'; import { useRef } from 'react';
import { IntlProvider } from 'react-intl';
import { initializeMockApp } from '@edx/frontend-platform'; import { render, waitFor } from '@testing-library/react';
import { IntlProvider } from 'react-intl';
import { Context as ResponsiveContext } from 'react-responsive';
import { MemoryRouter } from 'react-router';
import { getConfig, initializeMockApp } from '@edx/frontend-platform';
import { AppProvider } from '@edx/frontend-platform/react'; import { AppProvider } from '@edx/frontend-platform/react';
import { initializeStore } from '../../store'; import { initializeStore } from '../../store';
import { DiscussionContext } from '../common/context'; import { useContainerSizeForParent } from './hooks';
import { useCurrentDiscussionTopic } from './hooks';
let store; let store;
initializeMockApp(); initializeMockApp();
describe('Hooks', () => { describe('Hooks', () => {
describe('useCurrentDiscussionTopic', () => { function ComponentWithHook() {
function ComponentWithHook() { const refContainer = useRef(null);
const topic = useCurrentDiscussionTopic(); useContainerSizeForParent(refContainer);
return ( return (
<div> <div>
{String(topic)} <div ref={refContainer} />
</div> </div>
); );
} }
function renderComponent({ topicId, category }) { function renderComponent() {
return render( return render(
<IntlProvider locale="en"> <IntlProvider locale="en">
<ResponsiveContext.Provider value={{ width: 1280 }}>
<AppProvider store={store}> <AppProvider store={store}>
<DiscussionContext.Provider <MemoryRouter initialEntries={['/']}>
value={{
topicId,
category,
}}
>
<ComponentWithHook /> <ComponentWithHook />
</DiscussionContext.Provider> </MemoryRouter>
</AppProvider> </AppProvider>
</IntlProvider>, </ResponsiveContext.Provider>
); </IntlProvider>,
} );
}
beforeEach(() => { let parent;
store = initializeStore({ beforeEach(() => {
blocks: { store = initializeStore();
blocks: { parent = window.parent;
'some-unit-key': { topics: ['some-topic-0'], parent: 'some-sequence-key' }, });
'some-sequence-key': { topics: ['some-topic-0'] }, afterEach(() => {
'another-sequence-key': { topics: ['some-topic-1', 'some-topic-2'] }, window.parent = parent;
'empty-key': { topics: [] }, });
}, test('useContainerSizeForParent enabled', async () => {
}, delete window.parent;
config: { provider: 'openedx' }, window.parent = { ...window, postMessage: jest.fn() };
}); const { unmount } = renderComponent();
}); // Once for LMS and one for learning MFE
await waitFor(() => expect(window.parent.postMessage).toHaveBeenCalledTimes(2));
test('when topicId is in context', () => { // Test that size is reset on unmount
const { queryByText } = renderComponent({ topicId: 'some-topic' }); unmount();
expect(queryByText('some-topic')).toBeInTheDocument(); await waitFor(() => expect(window.parent.postMessage).toHaveBeenCalledTimes(4));
}); expect(window.parent.postMessage).toHaveBeenLastCalledWith(
{ type: 'plugin.resize', payload: { height: null } },
test('when the category is a unit', () => { getConfig().LMS_BASE_URL,
const { queryByText } = renderComponent({ category: 'some-unit-key' }); );
expect(queryByText('some-topic-0')).toBeInTheDocument(); });
}); test('useContainerSizeForParent disabled', async () => {
window.parent.postMessage = jest.fn();
test('when the category is a sequence with one unit', () => { renderComponent();
const { queryByText } = renderComponent({ category: 'some-sequence-key' }); await waitFor(() => expect(window.parent.postMessage).not.toHaveBeenCalled());
expect(queryByText('some-topic-0')).toBeInTheDocument();
});
test('when the category is a sequence with multiple units', () => {
const { queryByText } = renderComponent({ category: 'another-sequence-key' });
expect(queryByText('null')).toBeInTheDocument();
});
test('when the category is invalid', () => {
const { queryByText } = renderComponent({ category: 'invalid-key' });
expect(queryByText('null')).toBeInTheDocument();
});
test('when the category has no topics', () => {
const { queryByText } = renderComponent({ category: 'empty-key' });
expect(queryByText('null')).toBeInTheDocument();
});
}); });
}); });

View File

@@ -6,24 +6,14 @@ export const selectAnonymousPostingConfig = state => ({
allowAnonymousToPeers: state.config.allowAnonymousToPeers, allowAnonymousToPeers: state.config.allowAnonymousToPeers,
}); });
export const selectUserHasModerationPrivileges = state => state.config.hasModerationPrivileges; export const selectUserIsPrivileged = state => state.config.userIsPrivileged;
export const selectUserIsStaff = state => state.config.isUserAdmin;
export const selectUserIsGroupTa = state => state.config.isGroupTa;
export const selectconfigLoadingStatus = state => state.config.status; export const selectconfigLoadingStatus = state => state.config.status;
export const selectLearnersTabEnabled = state => state.config.learnersTabEnabled; export const selectLearnersTabEnabled = state => state.config.learnersTabEnabled;
export const selectUserRoles = state => state.config.userRoles;
export const selectDivisionSettings = state => state.config.settings; export const selectDivisionSettings = state => state.config.settings;
export const selectBlackoutDate = state => state.config.blackouts;
export const selectGroupAtSubsection = state => state.config.groupAtSubsection;
export const selectModerationSettings = state => ({ export const selectModerationSettings = state => ({
postCloseReasons: state.config.postCloseReasons, postCloseReasons: state.config.postCloseReasons,
editReasons: state.config.editReasons, editReasons: state.config.editReasons,

View File

@@ -11,10 +11,7 @@ const configSlice = createSlice({
allowAnonymous: false, allowAnonymous: false,
allowAnonymousToPeers: false, allowAnonymousToPeers: false,
userRoles: [], userRoles: [],
groupAtSubsection: false, userIsPrivileged: false,
hasModerationPrivileges: false,
isGroupTa: false,
isUserAdmin: false,
learnersTabEnabled: false, learnersTabEnabled: false,
settings: { settings: {
divisionScheme: 'none', divisionScheme: 'none',

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 {
LearnersOrdering,
PostsStatusFilter,
} from '../../data/constants';
import { setSortedBy } from '../learners/data';
import { setStatusFilter } from '../posts/data';
import { getHttpErrorStatus } from '../utils'; import { getHttpErrorStatus } from '../utils';
import { getDiscussionsConfig, getDiscussionsSettings } from './api'; import { getDiscussionsConfig, getDiscussionsSettings } from './api';
import { import {
@@ -22,23 +16,13 @@ import {
export function fetchCourseConfig(courseId) { export function fetchCourseConfig(courseId) {
return async (dispatch) => { return async (dispatch) => {
try { try {
let learnerSort = LearnersOrdering.BY_LAST_ACTIVITY;
const postsFilterStatus = PostsStatusFilter.ALL;
dispatch(fetchConfigRequest()); dispatch(fetchConfigRequest());
const config = await getDiscussionsConfig(courseId); const config = await getDiscussionsConfig(courseId);
if (config.has_moderation_privileges) { if (config.is_user_admin) {
const settings = await getDiscussionsSettings(courseId); const settings = await getDiscussionsSettings(courseId);
Object.assign(config, { settings }); Object.assign(config, { settings });
} }
if ((config.has_moderation_privileges || config.is_group_ta)) {
learnerSort = LearnersOrdering.BY_FLAG;
}
dispatch(fetchConfigSuccess(camelCaseObject(config))); dispatch(fetchConfigSuccess(camelCaseObject(config)));
dispatch(setSortedBy(learnerSort));
dispatch(setStatusFilter(postsFilterStatus));
} catch (error) { } catch (error) {
if (getHttpErrorStatus(error) === 403) { if (getHttpErrorStatus(error) === 403) {
dispatch(fetchConfigDenied()); dispatch(fetchConfigDenied());

View File

@@ -1,36 +0,0 @@
import React, { useState } from 'react';
import { useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { PageBanner } from '@edx/paragon';
import { selectBlackoutDate } from '../data/selectors';
import messages from '../messages';
import { inBlackoutDateRange } from '../utils';
function BlackoutInformationBanner({
intl,
}) {
const isDiscussionsBlackout = inBlackoutDateRange(useSelector(selectBlackoutDate));
const [showBanner, setShowBanner] = useState(true);
return (
<PageBanner
variant="accentB"
show={isDiscussionsBlackout && showBanner}
dismissible
onDismiss={() => setShowBanner(false)}
>
<div className="font-weight-500">
{intl.formatMessage(messages.blackoutDiscussionInformation)}
</div>
</PageBanner>
);
}
BlackoutInformationBanner.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(BlackoutInformationBanner);

View File

@@ -1,76 +0,0 @@
import { render, screen } from '@testing-library/react';
import { IntlProvider } from 'react-intl';
import { initializeMockApp } from '@edx/frontend-platform';
import { AppProvider } from '@edx/frontend-platform/react';
import { initializeStore } from '../../store';
import { DiscussionContext } from '../common/context';
import { fetchConfigSuccess } from '../data/slices';
import messages from '../messages';
import BlackoutInformationBanner from './BlackoutInformationBanner';
let store;
let container;
const courseId = 'course-v1:edX+DemoX+Demo_Course';
let activeStartDate = new Date();
activeStartDate.setDate(activeStartDate.getDate() - 2);
let activeEndDate = new Date();
activeEndDate.setDate(activeEndDate.getDate() + 2);
activeStartDate = activeStartDate.toISOString();
activeEndDate = activeEndDate.toISOString();
const getConfigData = (blackouts = []) => ({
id: 'course-v1:edX+DemoX+Demo_Course',
userRoles: ['Admin', 'Student'],
hasModerationPrivileges: false,
isGroupTa: false,
isUserAdmin: false,
blackouts,
});
function renderComponent() {
const wrapper = render(
<IntlProvider locale="en">
<AppProvider store={store}>
<DiscussionContext.Provider value={{ courseId }}>
<BlackoutInformationBanner />
</DiscussionContext.Provider>
</AppProvider>
</IntlProvider>,
);
container = wrapper.container;
return container;
}
describe('Blackout Information Banner', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: false,
roles: ['Student'],
},
});
});
test.each([
{ blackouts: [], visibility: false },
{ blackouts: ['2021-12-31T10:15', '2021-12-31T10:20'], visibility: false },
{ blackouts: [{ start: activeStartDate, end: activeEndDate }], visibility: true },
{ blackouts: [{ start: activeEndDate, end: activeEndDate }], visibility: false },
])('Test Blackout Banner is visible on app load if blackout date is active', async ({ blackouts, visibility }) => {
store = initializeStore();
await store.dispatch(fetchConfigSuccess(getConfigData(blackouts)));
renderComponent();
if (visibility) {
const element = await screen.findByRole('alert');
expect(element).toBeInTheDocument();
expect(element).toHaveTextContent(messages.blackoutDiscussionInformation.defaultMessage);
} else {
const element = await screen.queryByRole('alert');
expect(element).not.toBeInTheDocument();
}
});
});

View File

@@ -1,46 +1,26 @@
import React, { useContext } from 'react'; import React, { useRef } from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { Route, Switch } from 'react-router'; import { Route, Switch } from 'react-router';
import { useHistory, useLocation } from 'react-router-dom';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { Routes } from '../../data/constants';
import { Icon, IconButton } from '@edx/paragon';
import { ArrowBack } from '@edx/paragon/icons';
import { PostsPages, Routes } from '../../data/constants';
import { CommentsView } from '../comments'; import { CommentsView } from '../comments';
import { DiscussionContext } from '../common/context'; import { useContainerSizeForParent } from '../data/hooks';
import { useIsOnDesktop } from '../data/hooks'; import { LearnersContentView } from '../learners';
import messages from '../messages'; import LearnerPageHeader from '../learners/LearnerPageHeader';
import { PostEditor } from '../posts'; import { PostEditor } from '../posts';
import { discussionsPath } from '../utils';
function DiscussionContent({ intl }) { export default function DiscussionContent() {
const location = useLocation(); const refContainer = useRef(null);
const history = useHistory();
const postEditorVisible = useSelector((state) => state.threads.postEditorVisible); const postEditorVisible = useSelector((state) => state.threads.postEditorVisible);
const isOnDesktop = useIsOnDesktop(); useContainerSizeForParent(refContainer);
const {
courseId, learnerUsername, category, topicId, page,
} = useContext(DiscussionContext);
return ( return (
<div className="d-flex bg-light-400 flex-column w-75 w-xs-100 w-xl-75 align-items-center"> <div className="d-flex bg-light-400 flex-column w-75 w-xs-100 w-xl-75 align-items-center h-100 overflow-auto">
<div className="d-flex flex-column w-100"> <Route path={Routes.LEARNERS.LEARNER}>
{!isOnDesktop && ( <LearnerPageHeader />
<IconButton </Route>
src={ArrowBack} <div className="d-flex flex-column w-100 mw-xl" ref={refContainer}>
iconAs={Icon}
style={{ padding: '18px' }}
size="inline"
className="ml-4 mt-4"
onClick={() => history.push(discussionsPath(PostsPages[page], {
courseId, learnerUsername, category, topicId,
})(location))}
alt={intl.formatMessage(messages.backAlt)}
/>
)}
{postEditorVisible ? ( {postEditorVisible ? (
<Route path={Routes.POSTS.NEW_POST}> <Route path={Routes.POSTS.NEW_POST}>
<PostEditor /> <PostEditor />
@@ -53,15 +33,12 @@ function DiscussionContent({ intl }) {
<Route path={Routes.COMMENTS.PATH}> <Route path={Routes.COMMENTS.PATH}>
<CommentsView /> <CommentsView />
</Route> </Route>
<Route path={Routes.LEARNERS.LEARNER}>
<LearnersContentView />
</Route>
</Switch> </Switch>
)} )}
</div> </div>
</div> </div>
); );
} }
DiscussionContent.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(DiscussionContent);

View File

@@ -1,53 +1,26 @@
import React, { useEffect, useRef } from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
import { useSelector } from 'react-redux';
import { import {
Redirect, Route, Switch, useLocation, Redirect, Route, Switch, useLocation,
} from 'react-router'; } from 'react-router';
import { useWindowSize } from '@edx/paragon'; import { Routes } from '../../data/constants';
import { LearnersView } from '../learners';
import { RequestStatus, Routes } from '../../data/constants';
import {
useContainerSize, useIsOnDesktop, useIsOnXLDesktop, useShowLearnersTab,
} from '../data/hooks';
import { selectconfigLoadingStatus } from '../data/selectors';
import { LearnerPostsView, LearnersView } from '../learners';
import { PostsView } from '../posts'; import { PostsView } from '../posts';
import { TopicsView } from '../topics'; import { TopicsView } from '../topics';
export default function DiscussionSidebar({ displaySidebar, postActionBarRef }) { export default function DiscussionSidebar({ displaySidebar }) {
const location = useLocation(); const location = useLocation();
const isOnDesktop = useIsOnDesktop();
const isOnXLDesktop = useIsOnXLDesktop();
const configStatus = useSelector(selectconfigLoadingStatus);
const redirectToLearnersTab = useShowLearnersTab();
const sidebarRef = useRef(null);
const postActionBarHeight = useContainerSize(postActionBarRef);
const { height: windowHeight } = useWindowSize();
useEffect(() => {
if (sidebarRef && postActionBarHeight) {
if (isOnDesktop) {
sidebarRef.current.style.maxHeight = `${windowHeight - postActionBarHeight}px`;
}
sidebarRef.current.style.minHeight = `${windowHeight - postActionBarHeight}px`;
sidebarRef.current.style.top = `${postActionBarHeight}px`;
}
}, [sidebarRef, postActionBarHeight]);
return ( return (
<div <div
ref={sidebarRef} className={classNames('flex-column', {
className={classNames('flex-column min-content-height position-sticky', {
'd-none': !displaySidebar, 'd-none': !displaySidebar,
'd-flex overflow-auto': displaySidebar, 'd-flex w-25 w-xs-100 w-lg-25 overflow-auto h-100 pb-2': displaySidebar,
'w-100': !isOnDesktop,
'sidebar-desktop-width': isOnDesktop && !isOnXLDesktop,
'w-25 sidebar-XL-width': isOnXLDesktop,
})} })}
style={{ minWidth: '30rem' }}
data-testid="sidebar" data-testid="sidebar"
> >
<Switch> <Switch>
@@ -56,14 +29,7 @@ export default function DiscussionSidebar({ displaySidebar, postActionBarRef })
component={PostsView} component={PostsView}
/> />
<Route path={Routes.TOPICS.PATH} component={TopicsView} /> <Route path={Routes.TOPICS.PATH} component={TopicsView} />
{redirectToLearnersTab && (
<Route path={Routes.LEARNERS.POSTS} component={LearnerPostsView} />
)}
{redirectToLearnersTab && (
<Route path={Routes.LEARNERS.PATH} component={LearnersView} /> <Route path={Routes.LEARNERS.PATH} component={LearnersView} />
)}
{configStatus === RequestStatus.SUCCESSFUL && (
<Redirect <Redirect
from={Routes.DISCUSSIONS.PATH} from={Routes.DISCUSSIONS.PATH}
to={{ to={{
@@ -71,7 +37,6 @@ export default function DiscussionSidebar({ displaySidebar, postActionBarRef })
pathname: Routes.POSTS.ALL_POSTS, pathname: Routes.POSTS.ALL_POSTS,
}} }}
/> />
)}
</Switch> </Switch>
</div> </div>
); );
@@ -79,13 +44,8 @@ export default function DiscussionSidebar({ displaySidebar, postActionBarRef })
DiscussionSidebar.defaultProps = { DiscussionSidebar.defaultProps = {
displaySidebar: false, displaySidebar: false,
postActionBarRef: null,
}; };
DiscussionSidebar.propTypes = { DiscussionSidebar.propTypes = {
displaySidebar: PropTypes.bool, displaySidebar: PropTypes.bool,
postActionBarRef: PropTypes.oneOfType([
PropTypes.func,
PropTypes.shape({ current: PropTypes.instanceOf(Element) }),
]),
}; };

View File

@@ -11,8 +11,6 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react'; import { AppProvider } from '@edx/frontend-platform/react';
import { initializeStore } from '../../store'; import { initializeStore } from '../../store';
import { DiscussionContext } from '../common/context';
import { fetchConfigSuccess } from '../data/slices';
import { threadsApiUrl } from '../posts/data/api'; import { threadsApiUrl } from '../posts/data/api';
import DiscussionSidebar from './DiscussionSidebar'; import DiscussionSidebar from './DiscussionSidebar';
@@ -28,11 +26,9 @@ function renderComponent(displaySidebar = true, location = `/${courseId}/`) {
<IntlProvider locale="en"> <IntlProvider locale="en">
<ResponsiveContext.Provider value={{ width: 1280 }}> <ResponsiveContext.Provider value={{ width: 1280 }}>
<AppProvider store={store}> <AppProvider store={store}>
<DiscussionContext.Provider value={{ courseId }}> <MemoryRouter initialEntries={[location]}>
<MemoryRouter initialEntries={[location]}> <DiscussionSidebar displaySidebar={displaySidebar} />
<DiscussionSidebar displaySidebar={displaySidebar} postActionBarRef={null} /> </MemoryRouter>
</MemoryRouter>
</DiscussionContext.Provider>
</AppProvider> </AppProvider>
</ResponsiveContext.Provider> </ResponsiveContext.Provider>
</IntlProvider>, </IntlProvider>,
@@ -55,7 +51,6 @@ describe('DiscussionSidebar', () => {
store = initializeStore({ store = initializeStore({
blocks: { blocks: { 'test-usage-key': { topics: ['some-topic-2', 'some-topic-0'] } } }, blocks: { blocks: { 'test-usage-key': { topics: ['some-topic-2', 'some-topic-0'] } } },
}); });
store.dispatch(fetchConfigSuccess({}));
Factory.resetAll(); Factory.resetAll();
axiosMock = new MockAdapter(getAuthenticatedHttpClient()); axiosMock = new MockAdapter(getAuthenticatedHttpClient());
}); });
@@ -78,10 +73,7 @@ describe('DiscussionSidebar', () => {
test('User will be redirected to "All Posts" by default', async () => { test('User will be redirected to "All Posts" by default', async () => {
axiosMock.onGet(threadsApiUrl) axiosMock.onGet(threadsApiUrl)
.reply(({ params }) => [200, Factory.build('threadsResult', {}, { .reply(({ params }) => [200, Factory.build('threadsResult', {}, {
threadAttrs: { threadAttrs: { title: `Thread by ${params.author || 'other users'}` },
title: `Thread by ${params.author || 'other users'}`,
previewBody: 'thread preview body',
},
})]); })]);
renderComponent(); renderComponent();
await act(async () => expect(await screen.findAllByText('Thread by other users')).toBeTruthy()); await act(async () => expect(await screen.findAllByText('Thread by other users')).toBeTruthy());
@@ -93,10 +85,7 @@ describe('DiscussionSidebar', () => {
axiosMock.onGet(threadsApiUrl) axiosMock.onGet(threadsApiUrl)
.reply(({ params }) => [200, Factory.build('threadsResult', {}, { .reply(({ params }) => [200, Factory.build('threadsResult', {}, {
count: postCount, count: postCount,
threadAttrs: { threadAttrs: { title: `Thread by ${params.author || 'other users'}` },
title: `Thread by ${params.author || 'other users'}`,
previewBody: 'thread preview body',
},
})]); })]);
renderComponent(); renderComponent();
await act(async () => expect(await screen.findAllByText('Thread by other users')).toBeTruthy()); await act(async () => expect(await screen.findAllByText('Thread by other users')).toBeTruthy());

View File

@@ -1,46 +1,36 @@
import React, { useEffect, useRef } from 'react'; import React, { useEffect } from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { import {
Route, Switch, useLocation, useRouteMatch, Route, Switch, useLocation, useRouteMatch,
} from 'react-router'; } from 'react-router';
import Footer from '@edx/frontend-component-footer';
import { LearningHeader as Header } from '@edx/frontend-component-header';
import { getConfig } from '@edx/frontend-platform';
import { PostActionsBar } from '../../components'; import { PostActionsBar } from '../../components';
import { CourseTabsNavigation } from '../../components/NavigationBar';
import { ALL_ROUTES, DiscussionProvider, Routes } from '../../data/constants'; import { ALL_ROUTES, DiscussionProvider, Routes } from '../../data/constants';
import { useTheme } from '../../theme-hooks';
import { DiscussionContext } from '../common/context'; import { DiscussionContext } from '../common/context';
import { import {
useCourseDiscussionData, useIsOnDesktop, useRedirectToThread, useShowLearnersTab, useSidebarVisible, useCourseDiscussionData, useIsOnDesktop, useRedirectToThread, useSidebarVisible,
} from '../data/hooks'; } from '../data/hooks';
import { selectDiscussionProvider } from '../data/selectors'; import { selectDiscussionProvider } from '../data/selectors';
import { EmptyLearners, EmptyPosts, EmptyTopics } from '../empty-posts'; import { EmptyPosts, EmptyTopics } from '../empty-posts';
import messages from '../messages'; import messages from '../messages';
import { BreadcrumbMenu, LegacyBreadcrumbMenu, NavigationBar } from '../navigation'; import { BreadcrumbMenu, LegacyBreadcrumbMenu, NavigationBar } from '../navigation';
import { postMessageToParent } from '../utils'; import { postMessageToParent } from '../utils';
import BlackoutInformationBanner from './BlackoutInformationBanner';
import DiscussionContent from './DiscussionContent'; import DiscussionContent from './DiscussionContent';
import DiscussionSidebar from './DiscussionSidebar'; import DiscussionSidebar from './DiscussionSidebar';
import InformationBanner from './InformationsBanner';
export default function DiscussionsHome() { export default function DiscussionsHome() {
const location = useLocation(); const location = useLocation();
const postActionBarRef = useRef(null); const ready = useTheme('red_theme');
const postEditorVisible = useSelector( const postEditorVisible = useSelector(
(state) => state.threads.postEditorVisible, (state) => state.threads.postEditorVisible,
); );
const { const {
params: { page }, params: { page },
} = useRouteMatch(`${Routes.COMMENTS.PAGE}?`); } = useRouteMatch(`${Routes.COMMENTS.PAGE}?`);
const { params: { path } } = useRouteMatch(`${Routes.DISCUSSIONS.PATH}/:path*`); const { params: { path } } = useRouteMatch(`${Routes.DISCUSSIONS.PATH}/:path*`);
const { params } = useRouteMatch(ALL_ROUTES); const { params } = useRouteMatch(ALL_ROUTES);
const isRedirectToLearners = useShowLearnersTab();
const isFeedbackBannerVisible = getConfig().DISPLAY_FEEDBACK_BANNER === 'true';
const { const {
courseId, courseId,
postId, postId,
@@ -49,15 +39,14 @@ export default function DiscussionsHome() {
learnerUsername, learnerUsername,
} = params; } = params;
const inContext = new URLSearchParams(location.search).get('inContext') !== null; const inContext = new URLSearchParams(location.search).get('inContext') !== null;
// Display the content area if we are currently viewing/editing a post or creating one. // Display the content area if we are currently viewing/editing a post or creating one.
const displayContentArea = postId || postEditorVisible || (learnerUsername && postId); const displayContentArea = postId || postEditorVisible || learnerUsername;
let displaySidebar = useSidebarVisible(); let displaySidebar = useSidebarVisible();
const isOnDesktop = useIsOnDesktop(); const isOnDesktop = useIsOnDesktop();
const { courseNumber, courseTitle, org } = useSelector(
(state) => state.courseTabs,
);
if (displayContentArea) { if (displayContentArea) {
// If the window is larger than a particular size, show the sidebar for navigating between posts/topics. // If the window is larger than a particular size, show the sidebar for navigating between posts/topics.
// However, for smaller screens or embeds, only show the sidebar if the content area isn't displayed. // However, for smaller screens or embeds, only show the sidebar if the content area isn't displayed.
@@ -72,6 +61,9 @@ export default function DiscussionsHome() {
postMessageToParent('discussions.navigate', { path }); postMessageToParent('discussions.navigate', { path });
} }
}, [path]); }, [path]);
if (!ready) {
return null;
}
return ( return (
<DiscussionContext.Provider value={{ <DiscussionContext.Provider value={{
@@ -84,27 +76,22 @@ export default function DiscussionsHome() {
learnerUsername, learnerUsername,
}} }}
> >
<Header courseOrg={org} courseNumber={courseNumber} courseTitle={courseTitle} /> <main className="container-fluid d-flex flex-column p-0 h-100 w-100 overflow-hidden">
<main className="container-fluid d-flex flex-column p-0 w-100" id="main" tabIndex="-1"> <div
<CourseTabsNavigation activeTab="discussion" courseId={courseId} /> className="d-flex flex-row justify-content-between navbar fixed-top"
<div className="header-action-bar" ref={postActionBarRef}> style={{ boxShadow: '0px 2px 4px rgb(0 0 0 / 15%), 0px 2px 8px rgb(0 0 0 / 15%)' }}
<div >
className="d-flex flex-row justify-content-between navbar fixed-top" {!inContext && (
> <Route path={Routes.DISCUSSIONS.PATH} component={NavigationBar} />
{!inContext && ( )}
<Route path={Routes.DISCUSSIONS.PATH} component={NavigationBar} /> <PostActionsBar inContext={inContext} />
)}
<PostActionsBar inContext={inContext} />
</div>
{isFeedbackBannerVisible && <InformationBanner />}
<BlackoutInformationBanner />
</div> </div>
<Route <Route
path={[Routes.POSTS.PATH, Routes.TOPICS.CATEGORY]} path={[Routes.POSTS.PATH, Routes.TOPICS.CATEGORY]}
component={provider === DiscussionProvider.LEGACY ? LegacyBreadcrumbMenu : BreadcrumbMenu} component={provider === DiscussionProvider.LEGACY ? LegacyBreadcrumbMenu : BreadcrumbMenu}
/> />
<div className="d-flex flex-row"> <div className="d-flex flex-row overflow-hidden flex-grow-1">
<DiscussionSidebar displaySidebar={displaySidebar} postActionBarRef={postActionBarRef} /> <DiscussionSidebar displaySidebar={displaySidebar} />
{displayContentArea && <DiscussionContent />} {displayContentArea && <DiscussionContent />}
{!displayContentArea && ( {!displayContentArea && (
<Switch> <Switch>
@@ -114,15 +101,13 @@ export default function DiscussionsHome() {
render={routeProps => <EmptyPosts {...routeProps} subTitleMessage={messages.emptyMyPosts} />} render={routeProps => <EmptyPosts {...routeProps} subTitleMessage={messages.emptyMyPosts} />}
/> />
<Route <Route
path={[Routes.POSTS.PATH, Routes.POSTS.ALL_POSTS, Routes.LEARNERS.POSTS]} path={[Routes.POSTS.PATH, Routes.POSTS.ALL_POSTS]}
render={routeProps => <EmptyPosts {...routeProps} subTitleMessage={messages.emptyAllPosts} />} render={routeProps => <EmptyPosts {...routeProps} subTitleMessage={messages.emptyAllPosts} />}
/> />
{isRedirectToLearners && <Route path={Routes.LEARNERS.PATH} component={EmptyLearners} /> }
</Switch> </Switch>
)} )}
</div> </div>
</main> </main>
<Footer />
</DiscussionContext.Provider> </DiscussionContext.Provider>
); );
} }

View File

@@ -87,11 +87,4 @@ describe('DiscussionsHome', () => {
await waitFor(() => expect(window.parent.postMessage).toHaveBeenCalled()); await waitFor(() => expect(window.parent.postMessage).toHaveBeenCalled());
window.parent = parent; window.parent = parent;
}); });
test('header, course navigation bar and footer are visible', async () => {
renderComponent();
expect(screen.queryByRole('banner')).toBeInTheDocument();
expect(document.getElementById('courseTabsNavigation')).toBeInTheDocument();
expect(screen.queryByRole('contentinfo')).toBeInTheDocument();
});
}); });

View File

@@ -1,136 +0,0 @@
import { render, screen } from '@testing-library/react';
import { IntlProvider } from 'react-intl';
import { initializeMockApp } from '@edx/frontend-platform';
import { AppProvider } from '@edx/frontend-platform/react';
import { initializeStore } from '../../store';
import { DiscussionContext } from '../common/context';
import { fetchConfigSuccess } from '../data/slices';
import messages from '../messages';
import InformationBanner from './InformationsBanner';
import '../posts/data/__factories__';
let store;
let container;
const courseId = 'course-v1:edX+DemoX+Demo_Course';
const getConfigData = (isAdmin = true, roles = []) => ({
id: 'course-v1:edX+DemoX+Demo_Course',
userRoles: roles,
hasModerationPrivileges: false,
isGroupTa: false,
isUserAdmin: isAdmin,
});
function renderComponent() {
const wrapper = render(
<IntlProvider locale="en">
<AppProvider store={store}>
<DiscussionContext.Provider value={{ courseId }}>
<InformationBanner />
</DiscussionContext.Provider>
</AppProvider>
</IntlProvider>,
);
container = wrapper.container;
return container;
}
describe('Information Banner learner view', () => {
let element;
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: false,
roles: ['Student'],
},
});
store = initializeStore();
store.dispatch(fetchConfigSuccess(getConfigData(false, ['Student'])));
renderComponent(true);
element = await screen.findByRole('alert');
});
test('Test Banner is visible on app load', async () => {
expect(element).toHaveTextContent(messages.bannerMessage.defaultMessage);
});
test('Test Banner do not have learn more button', async () => {
expect(element).not.toHaveTextContent(messages.learnMoreBannerLink.defaultMessage);
});
test('Test Banner has share feedback button', async () => {
expect(element).toHaveTextContent(messages.shareFeedback.defaultMessage);
});
});
describe('Information Banner moderators/staff/admin view', () => {
let element;
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
store.dispatch(fetchConfigSuccess(getConfigData(true, ['Student', 'Moderator'])));
renderComponent(true);
element = await screen.findByRole('alert');
});
test('Test Banner is visible on app load', async () => {
expect(element).toHaveTextContent(messages.bannerMessage.defaultMessage);
});
test('Test Banner has learn more button', async () => {
expect(element).toHaveTextContent(messages.learnMoreBannerLink.defaultMessage);
});
test('Test Banner has share feedback button', async () => {
expect(element).toHaveTextContent(messages.shareFeedback.defaultMessage);
});
});
describe('User is redirected according to url according to role', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
});
test('TAs are redirected to learners feedback form', async () => {
store.dispatch(fetchConfigSuccess(getConfigData(false, ['Student', 'Community TA'])));
renderComponent(true);
expect(screen.getByText(messages.shareFeedback.defaultMessage)
.closest('a'))
.toHaveAttribute('href', process.env.TA_FEEDBACK_FORM);
});
test('moderators/administrators are redirected to moderators feedback form', async () => {
store.dispatch(fetchConfigSuccess(getConfigData(false, ['Student', 'Moderator', 'Administrator'])));
renderComponent(true);
expect(screen.getByText(messages.shareFeedback.defaultMessage)
.closest('a'))
.toHaveAttribute('href', process.env.STAFF_FEEDBACK_FORM);
});
test('user with only isAdmin true are redirected to moderators feedback form', async () => {
store.dispatch(fetchConfigSuccess(getConfigData(true, ['Student'])));
renderComponent(true);
expect(screen.getByText(messages.shareFeedback.defaultMessage)
.closest('a'))
.toHaveAttribute('href', process.env.STAFF_FEEDBACK_FORM);
});
});

View File

@@ -1,64 +0,0 @@
import React, { useState } from 'react';
import { useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Hyperlink, PageBanner } from '@edx/paragon';
import { selectUserIsStaff, selectUserRoles } from '../data/selectors';
import messages from '../messages';
function InformationBanner({
intl,
}) {
const [showBanner, setShowBanner] = useState(true);
const userRoles = useSelector(selectUserRoles);
const isAdmin = useSelector(selectUserIsStaff);
const learnMoreLink = 'https://openedx.atlassian.net/wiki/spaces/COMM/pages/3509551260/Overview+New+discussions+experience';
const TAFeedbackLink = process.env.TA_FEEDBACK_FORM;
const staffFeedbackLink = process.env.STAFF_FEEDBACK_FORM;
const hideLearnMoreButton = ((userRoles.includes('Student') && userRoles.length === 1) || !userRoles.length) && !isAdmin;
const showStaffLink = isAdmin || userRoles.includes('Moderator') || userRoles.includes('Administrator');
return (
<PageBanner
variant="light"
show={showBanner}
dismissible
onDismiss={() => setShowBanner(false)}
>
<div className="font-weight-500">
{intl.formatMessage(messages.bannerMessage)}
{!hideLearnMoreButton
&& (
<Hyperlink
destination={learnMoreLink}
target="_blank"
showLaunchIcon={false}
className="pl-2.5"
variant="muted"
isInline
>
{intl.formatMessage(messages.learnMoreBannerLink)}
</Hyperlink>
)}
<Hyperlink
destination={showStaffLink ? staffFeedbackLink : TAFeedbackLink}
target="_blank"
showLaunchIcon={false}
variant="muted"
className="pl-2.5"
isInline
>
{intl.formatMessage(messages.shareFeedback)}
</Hyperlink>
</div>
</PageBanner>
);
}
InformationBanner.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(InformationBanner);

View File

@@ -1,25 +0,0 @@
import React from 'react';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIsOnDesktop } from '../data/hooks';
import messages from '../messages';
import EmptyPage from './EmptyPage';
function EmptyLearners({ intl }) {
const isOnDesktop = useIsOnDesktop();
if (!isOnDesktop) {
return null;
}
return (
<EmptyPage title={intl.formatMessage(messages.emptyTitle)} />
);
}
EmptyLearners.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(EmptyLearners);

View File

@@ -15,7 +15,7 @@ function EmptyPage({
fullWidth = false, fullWidth = false,
}) { }) {
const containerClasses = classNames( const containerClasses = classNames(
'min-content-height justify-content-center align-items-center d-flex w-100 flex-column pt-5', 'justify-content-center align-items-center d-flex w-100 flex-column pt-5',
{ 'bg-light-400': !fullWidth }, { 'bg-light-400': !fullWidth },
); );

View File

@@ -1,4 +1,3 @@
export { default as EmptyLearners } from './EmptyLearners';
export { default as EmptyPage } from './EmptyPage'; export { default as EmptyPage } from './EmptyPage';
export { default as EmptyPosts } from './EmptyPosts'; export { default as EmptyPosts } from './EmptyPosts';
export { default as EmptyTopics } from './EmptyTopics'; export { default as EmptyTopics } from './EmptyTopics';

View File

@@ -0,0 +1,70 @@
import React, { useContext } from 'react';
import classNames from 'classnames';
import { useSelector } from 'react-redux';
import { generatePath, NavLink } from 'react-router-dom';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Avatar, ButtonGroup, Icon } from '@edx/paragon';
import { Report } from '@edx/paragon/icons';
import { Routes } from '../../data/constants';
import { DiscussionContext } from '../common/context';
import { selectLearner, selectLearnerAvatar, selectLearnerProfile } from './data/selectors';
import messages from './messages';
function LearnerPageHeader({ intl }) {
const { courseId, learnerUsername } = useContext(DiscussionContext);
const params = { courseId, learnerUsername };
const learner = useSelector(selectLearner(learnerUsername));
const profile = useSelector(selectLearnerProfile(learnerUsername));
const avatar = useSelector(selectLearnerAvatar(learnerUsername));
const activeTabClass = (active) => classNames('btn', { 'btn-primary': active, 'btn-outline-primary': !active });
return (
<div className="d-flex flex-column w-100 bg-white shadow-sm">
<div className="d-flex flex-row align-items-center m-4">
<Avatar src={avatar} alt={learnerUsername} />
<span className="font-weight-bold mx-3">
{profile.username}
</span>
</div>
<div className="d-flex pb-0 bg-light-200 justify-content-center p-2 flex-fill">
<ButtonGroup className="my-2 bg-white">
<NavLink
className={activeTabClass}
to={generatePath(Routes.LEARNERS.TABS.posts, params)}
>
{intl.formatMessage(messages.postsTab)} <span className="ml-3">{learner.threads}</span>
{
learner.activeFlags ? (
<span className="ml-3">
<Icon src={Report} />
</span>
) : null
}
</NavLink>
<NavLink
className={activeTabClass}
to={generatePath(Routes.LEARNERS.TABS.responses, params)}
>
{intl.formatMessage(messages.responsesTab)} <span className="ml-3">{learner.responses}</span>
</NavLink>
<NavLink
className={activeTabClass}
to={generatePath(Routes.LEARNERS.TABS.comments, params)}
>
{intl.formatMessage(messages.commentsTab)} <span className="ml-3">{learner.replies}</span>
</NavLink>
</ButtonGroup>
</div>
</div>
);
}
LearnerPageHeader.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(LearnerPageHeader);

View File

@@ -1,148 +0,0 @@
import React, {
useCallback, useContext, useEffect, useMemo,
} from 'react';
import snakeCase from 'lodash.snakecase';
import capitalize from 'lodash/capitalize';
import { useDispatch, useSelector } from 'react-redux';
import { useHistory, useLocation } from 'react-router-dom';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import {
Button, Icon, IconButton, Spinner,
} from '@edx/paragon';
import { ArrowBack } from '@edx/paragon/icons';
import {
PostsStatusFilter,
RequestStatus,
Routes,
ThreadType,
} from '../../data/constants';
import { DiscussionContext } from '../common/context';
import { selectUserHasModerationPrivileges, selectUserIsStaff } from '../data/selectors';
import {
selectAllThreads,
selectThreadNextPage,
threadsLoadingStatus,
} from '../posts/data/selectors';
import { clearPostsPages } from '../posts/data/slices';
import NoResults from '../posts/NoResults';
import { PostLink } from '../posts/post';
import { discussionsPath, filterPosts } from '../utils';
import { fetchUserPosts } from './data/thunks';
import LearnerPostFilterBar from './learner-post-filter-bar/LearnerPostFilterBar';
import messages from './messages';
function LearnerPostsView({ intl }) {
const location = useLocation();
const history = useHistory();
const dispatch = useDispatch();
const posts = useSelector(selectAllThreads);
const loadingStatus = useSelector(threadsLoadingStatus());
const postFilter = useSelector(state => state.learners.postFilter);
const { courseId, learnerUsername: username } = useContext(DiscussionContext);
const nextPage = useSelector(selectThreadNextPage());
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
const userIsStaff = useSelector(selectUserIsStaff);
const countFlagged = userHasModerationPrivileges || userIsStaff;
const params = {
orderBy: snakeCase(postFilter.orderBy),
username,
page: 1,
};
if (postFilter.type !== ThreadType.ALL) {
params.threadType = postFilter.type;
}
if (postFilter.status !== PostsStatusFilter.ALL) {
const statusMapping = {
statusUnread: 'unread',
statusReported: 'flagged',
statusUnanswered: 'unanswered',
statusUnresponded: 'unresponded',
};
params.status = statusMapping[postFilter.status];
}
if (postFilter.cohort !== '') {
params.groupId = postFilter.cohort;
}
if (countFlagged) {
params.countFlagged = countFlagged;
}
useEffect(() => {
dispatch(fetchUserPosts(courseId, params));
}, [courseId, username]);
useEffect(() => {
dispatch(clearPostsPages());
dispatch(fetchUserPosts(courseId, params));
}, [postFilter]);
const loadMorePosts = () => (
dispatch(fetchUserPosts(courseId, {
...params,
page: nextPage,
}))
);
const checkIsSelected = (id) => window.location.pathname.includes(id);
const pinnedPosts = useMemo(() => filterPosts(posts, 'pinned'), [posts]);
const unpinnedPosts = useMemo(() => filterPosts(posts, 'unpinned'), [posts]);
const postInstances = useCallback((sortedPosts) => (
sortedPosts.map((post, idx) => (
<PostLink
post={post}
key={post.id}
isSelected={checkIsSelected}
idx={idx}
showDivider={(sortedPosts.length - 1) !== idx}
/>
))
), []);
return (
<div className="discussion-posts d-flex flex-column">
<div className="d-flex align-items-center justify-content-between px-2.5">
<IconButton
src={ArrowBack}
iconAs={Icon}
style={{ padding: '18px' }}
size="inline"
onClick={() => history.push(discussionsPath(Routes.LEARNERS.PATH, { courseId })(location))}
alt={intl.formatMessage(messages.back)}
/>
<div className="text-primary-500 font-style-normal font-family-inter font-weight-bold py-2.5">
{intl.formatMessage(messages.activityForLearner, { username: capitalize(username) })}
</div>
<div style={{ padding: '18px' }} />
</div>
<div className="bg-light-400 border border-light-300" />
<LearnerPostFilterBar />
<div className="list-group list-group-flush">
{postInstances(pinnedPosts)}
{postInstances(unpinnedPosts)}
{loadingStatus !== RequestStatus.IN_PROGRESS && posts?.length === 0 && <NoResults />}
{loadingStatus === RequestStatus.IN_PROGRESS ? (
<div className="d-flex justify-content-center p-4">
<Spinner animation="border" variant="primary" size="lg" />
</div>
) : (
nextPage && loadingStatus === RequestStatus.SUCCESSFUL && (
<Button onClick={() => loadMorePosts()} variant="primary" size="md">
{intl.formatMessage(messages.loadMore)}
</Button>
)
)}
</div>
</div>
);
}
LearnerPostsView.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(LearnerPostsView);

View File

@@ -1,90 +0,0 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import { act } from 'react-dom/test-utils';
import { IntlProvider } from 'react-intl';
import { MemoryRouter, Route } from 'react-router';
import { Factory } from 'rosie';
import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
import { initializeStore } from '../../store';
import { executeThunk } from '../../test-utils';
import { DiscussionContext } from '../common/context';
import { courseConfigApiUrl } from '../data/api';
import { fetchCourseConfig } from '../data/thunks';
import { coursesApiUrl } from './data/api';
import LearnerPostsView from './LearnerPostsView';
import './data/__factories__';
let store;
let axiosMock;
const courseId = 'course-v1:edX+TestX+Test_Course';
const username = 'abc123';
function renderComponent(path = `/${courseId}/learners/${username}/posts`) {
return render(
<IntlProvider locale="en">
<AppProvider store={store}>
<DiscussionContext.Provider
value={{
learnerUsername: username,
courseId,
}}
>
<MemoryRouter initialEntries={[path]}>
<Route path={path}>
<LearnerPostsView />
</Route>
</MemoryRouter>
</DiscussionContext.Provider>
</AppProvider>
</IntlProvider>,
);
}
describe('LearnerPostsView', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username,
administrator: true,
roles: [],
},
});
store = initializeStore();
Factory.resetAll();
const learnerPosts = Factory.build('learnerPosts', {}, {
abuseFlaggedCount: 1,
});
const apiUrl = `${coursesApiUrl}${courseId}/learner/`;
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock.onGet(apiUrl, { username, count_flagged: true })
.reply(() => [200, learnerPosts]);
});
describe('Basic', () => {
test('Reported icon is visible to moderator for post with reported comment', async () => {
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`).reply(200, {
has_moderation_privileges: true,
});
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/settings`).reply(200, {});
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
await act(async () => {
renderComponent();
});
expect(screen.queryAllByTestId('reported-post')[0]).toBeInTheDocument();
});
test('Reported icon is not visible to learner for post with reported comment', async () => {
await renderComponent();
expect(screen.queryByTestId('reported-post')).not.toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,53 @@
import React, { useContext } from 'react';
import { useSelector } from 'react-redux';
import {
generatePath, Redirect, Route, Switch,
} from 'react-router-dom';
import { Spinner } from '@edx/paragon';
import { LearnerTabs, RequestStatus, Routes } from '../../data/constants';
import { DiscussionContext } from '../common/context';
import { learnersLoadingStatus } from './data/selectors';
import CommentsTabContent from './learner/CommentsTabContent';
import PostsTabContent from './learner/PostsTabContent';
function LearnersContentView() {
const { courseId, learnerUsername } = useContext(DiscussionContext);
const params = { courseId, learnerUsername };
const apiStatus = useSelector(learnersLoadingStatus());
return (
<div className="learner-content d-flex flex-column">
<Switch>
<Route path={Routes.LEARNERS.LEARNER} exact>
<Redirect to={generatePath(Routes.LEARNERS.TABS.posts, params)} />
</Route>
<Route
path={Routes.LEARNERS.TABS.posts}
component={PostsTabContent}
/>
<Route path={Routes.LEARNERS.TABS.responses}>
<CommentsTabContent tab={LearnerTabs.RESPONSES} />
</Route>
<Route path={Routes.LEARNERS.TABS.comments}>
<CommentsTabContent tab={LearnerTabs.COMMENTS} />
</Route>
</Switch>
{
apiStatus === RequestStatus.IN_PROGRESS && (
<div className="my-3 text-center">
<Spinner animation="border" className="mie-3" />
</div>
)
}
</div>
);
}
LearnersContentView.propTypes = {
};
export default LearnersContentView;

View File

@@ -0,0 +1,176 @@
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import { act } from 'react-dom/test-utils';
import { IntlProvider } from 'react-intl';
import { MemoryRouter, Route } from 'react-router';
import { Factory } from 'rosie';
import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
import { LearnerTabs } from '../../data/constants';
import { initializeStore } from '../../store';
import { executeThunk } from '../../test-utils';
import { commentsApiUrl } from '../comments/data/api';
import { DiscussionContext } from '../common/context';
import DiscussionContent from '../discussions-home/DiscussionContent';
import { threadsApiUrl } from '../posts/data/api';
import { coursesApiUrl, userProfileApiUrl } from './data/api';
import { fetchLearners } from './data/thunks';
import '../comments/data/__factories__';
import '../posts/data/__factories__';
import './data/__factories__';
let store;
let axiosMock;
const courseId = 'course-v1:edX+TestX+Test_Course';
const testUsername = 'leaner-1';
function renderComponent(username = testUsername) {
return render(
<IntlProvider locale="en">
<AppProvider store={store}>
<DiscussionContext.Provider value={{ learnerUsername: username, courseId }}>
<MemoryRouter initialEntries={[`/${courseId}/learners/${username}/${LearnerTabs.POSTS}`]}>
<Route path="/:courseId/learners/:learnerUsername">
<DiscussionContent />
</Route>
</MemoryRouter>
</DiscussionContext.Provider>
</AppProvider>
</IntlProvider>,
);
}
describe('LearnersContentView', () => {
const learnerCount = 1;
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
store = initializeStore({});
Factory.resetAll();
axiosMock.onGet(`${coursesApiUrl}${courseId}/activity_stats/`)
.reply(
200,
Factory.build('learnersResult', {}, {
count: learnerCount,
pageSize: 5,
}),
);
axiosMock.onGet(`${userProfileApiUrl}?username=${testUsername}`)
.reply(
200,
Factory.build('learnersProfile', {}, {
username: [testUsername],
}).profiles,
);
await executeThunk(fetchLearners(courseId), store.dispatch, store.getState);
axiosMock.onGet(threadsApiUrl)
.reply(200, Factory.build('threadsResult', {}, {
topicId: undefined,
count: 6,
pageSize: 5,
}));
axiosMock.onGet(commentsApiUrl)
.reply(200, Factory.build('commentsResult', {}, {
count: 9,
pageSize: 8,
}));
});
test('it loads the posts view by default', async () => {
await act(async () => {
await renderComponent();
});
expect(screen.queryAllByTestId('post')).toHaveLength(5);
expect(screen.queryAllByText('This is Thread', { exact: false })).toHaveLength(5);
});
test('it renders all the comments WITHOUT parent id in responses tab', async () => {
await act(async () => {
await renderComponent();
});
await act(async () => {
fireEvent.click(screen.getByText('Responses', { exact: false }));
});
expect(screen.queryAllByText('comment number', { exact: false })).toHaveLength(8);
});
test('it renders all the comments with parent id in comments tab', async () => {
axiosMock.onGet(commentsApiUrl)
.reply(200, Factory.build('commentsResult', {}, {
count: 4,
parentId: 'test_parent_id',
}));
await act(async () => {
await renderComponent();
});
await act(async () => {
fireEvent.click(screen.getByRole('link', { name: /Comments \d+/i }));
});
expect(screen.queryAllByText('comment number', { exact: false })).toHaveLength(4);
});
test('it can switch back to the posts tab', async () => {
await act(async () => {
await renderComponent();
});
await act(async () => {
fireEvent.click(screen.getByRole('link', { name: /Responses \d+/i }));
});
expect(screen.queryAllByText('comment number', { exact: false })).toHaveLength(8);
await act(async () => {
fireEvent.click(screen.getByRole('link', { name: /Posts \d+/i }));
});
expect(screen.queryAllByTestId('post')).toHaveLength(5);
});
describe('Posts Tab Button', () => {
it('does not show Report Icon when the learner has NO active flags', async () => {
await act(async () => {
await renderComponent('leaner-2');
});
const button = screen.getByRole('link', { name: /Posts/i });
expect(button.innerHTML).not.toContain('svg');
});
it('shows the Report Icon when the learner has active Flags', async () => {
axiosMock.onGet(`${coursesApiUrl}${courseId}/activity_stats/`)
.reply(() => [200, Factory.build('learnersResult', {}, {
count: 1,
pageSize: 5,
activeFlags: 1,
})]);
axiosMock.onGet(`${userProfileApiUrl}?username=leaner-2`)
.reply(() => [200, Factory.build('learnersProfile', {}, {
username: ['leaner-2'],
}).profiles]);
await executeThunk(fetchLearners(courseId), store.dispatch, store.getState);
await act(async () => {
await renderComponent('leaner-2');
});
const button = screen.getByRole('link', { name: /Posts/i });
expect(button.innerHTML).toContain('svg');
});
});
});

View File

@@ -5,70 +5,49 @@ import {
Redirect, useLocation, useParams, Redirect, useLocation, useParams,
} from 'react-router'; } from 'react-router';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { Spinner } from '@edx/paragon';
import { Button, Spinner } from '@edx/paragon';
import SearchInfo from '../../components/SearchInfo'; import ScrollThreshold from '../../components/ScrollThreshold';
import { RequestStatus, Routes } from '../../data/constants'; import { RequestStatus, Routes } from '../../data/constants';
import { selectconfigLoadingStatus, selectLearnersTabEnabled } from '../data/selectors'; import { selectconfigLoadingStatus, selectLearnersTabEnabled } from '../data/selectors';
import NoResults from '../posts/NoResults';
import { import {
learnersLoadingStatus, learnersLoadingStatus,
selectAllLearners, selectAllLearners,
selectLearnerNextPage, selectLearnerNextPage,
selectLearnerSorting, selectLearnerSorting,
selectUsernameSearch,
} from './data/selectors'; } from './data/selectors';
import { setUsernameSearch } from './data/slices';
import { fetchLearners } from './data/thunks'; import { fetchLearners } from './data/thunks';
import { LearnerCard, LearnerFilterBar } from './learner'; import { LearnerCard } from './learner';
import messages from './messages';
function LearnersView({ intl }) { function LearnersView() {
const { courseId } = useParams(); const {
courseId,
} = useParams();
const location = useLocation(); const location = useLocation();
const dispatch = useDispatch(); const dispatch = useDispatch();
const orderBy = useSelector(selectLearnerSorting()); const orderBy = useSelector(selectLearnerSorting());
const nextPage = useSelector(selectLearnerNextPage()); const nextPage = useSelector(selectLearnerNextPage());
const loadingStatus = useSelector(learnersLoadingStatus()); const loadingStatus = useSelector(learnersLoadingStatus());
const usernameSearch = useSelector(selectUsernameSearch());
const courseConfigLoadingStatus = useSelector(selectconfigLoadingStatus); const courseConfigLoadingStatus = useSelector(selectconfigLoadingStatus);
const learnersTabEnabled = useSelector(selectLearnersTabEnabled); const learnersTabEnabled = useSelector(selectLearnersTabEnabled);
const learners = useSelector(selectAllLearners); const learners = useSelector(selectAllLearners);
useEffect(() => { useEffect(() => {
if (learnersTabEnabled) { if (learnersTabEnabled) {
if (usernameSearch) { dispatch(fetchLearners(courseId, { orderBy }));
dispatch(fetchLearners(courseId, { orderBy, usernameSearch }));
} else {
dispatch(fetchLearners(courseId, { orderBy }));
}
} }
}, [courseId, orderBy, learnersTabEnabled, usernameSearch]); }, [courseId, orderBy, learnersTabEnabled]);
const loadPage = async () => { const loadPage = async () => {
if (nextPage) { if (nextPage) {
dispatch(fetchLearners(courseId, { dispatch(fetchLearners(courseId, {
orderBy, orderBy,
page: nextPage, page: nextPage,
usernameSearch,
})); }));
} }
}; };
return ( return (
<div className="d-flex flex-column border-right border-light-400"> <div className="d-flex flex-column border-right border-light-300 h-100">
{!usernameSearch && <LearnerFilterBar /> } <div className="list-group list-group-flush ">
<div className="border-bottom border-light-400" />
{usernameSearch && (
<SearchInfo
text={usernameSearch}
count={learners.length}
loadingStatus={loadingStatus}
onClear={() => dispatch(setUsernameSearch(''))}
/>
)}
<div className="list-group list-group-flush learner" role="list">
{courseConfigLoadingStatus === RequestStatus.SUCCESSFUL && !learnersTabEnabled && ( {courseConfigLoadingStatus === RequestStatus.SUCCESSFUL && !learnersTabEnabled && (
<Redirect <Redirect
to={{ to={{
@@ -77,31 +56,21 @@ function LearnersView({ intl }) {
}} }}
/> />
)} )}
{courseConfigLoadingStatus === RequestStatus.SUCCESSFUL {courseConfigLoadingStatus === RequestStatus.SUCCESSFUL && learnersTabEnabled && learners.map((learner) => (
&& learnersTabEnabled <LearnerCard learner={learner} key={learner.username} courseId={courseId} />
&& learners.map((learner, index) => ( ))}
// eslint-disable-next-line react/no-array-index-key
<LearnerCard learner={learner} key={index} courseId={courseId} />
))}
{loadingStatus === RequestStatus.IN_PROGRESS ? ( {loadingStatus === RequestStatus.IN_PROGRESS ? (
<div className="d-flex justify-content-center p-4"> <div className="d-flex justify-content-center p-4">
<Spinner animation="border" variant="primary" size="lg" /> <Spinner animation="border" variant="primary" size="lg" />
</div> </div>
) : ( ) : (
nextPage && loadingStatus === RequestStatus.SUCCESSFUL && ( nextPage && (
<Button onClick={() => loadPage()} variant="primary" size="md"> <ScrollThreshold onScroll={loadPage} />
{intl.formatMessage(messages.loadMore)}
</Button>
) )
)} )}
{ usernameSearch !== '' && learners.length === 0 && loadingStatus === RequestStatus.SUCCESSFUL && <NoResults />}
</div> </div>
</div> </div>
); );
} }
LearnersView.propTypes = { export default LearnersView;
intl: intlShape.isRequired,
};
export default injectIntl(LearnersView);

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