Compare commits

..

3 Commits

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

4
.env
View File

@@ -15,10 +15,8 @@ LOGO_WHITE_URL=''
FAVICON_URL='' FAVICON_URL=''
MARKETING_SITE_BASE_URL='' MARKETING_SITE_BASE_URL=''
ORDER_HISTORY_URL='' ORDER_HISTORY_URL=''
POST_MARK_AS_READ_DELAY=2000
REFRESH_ACCESS_TOKEN_ENDPOINT='' REFRESH_ACCESS_TOKEN_ENDPOINT=''
SEGMENT_KEY='' SEGMENT_KEY=''
SITE_NAME='' SITE_NAME=''
USER_INFO_COOKIE_NAME='' USER_INFO_COOKIE_NAME=''
SUPPORT_URL=''
TA_FEEDBACK_FORM: ''
STAFF_FEEDBACK_FORM: ''

View File

@@ -16,10 +16,8 @@ LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
MARKETING_SITE_BASE_URL='http://localhost:18000' MARKETING_SITE_BASE_URL='http://localhost:18000'
ORDER_HISTORY_URL='http://localhost:1996/orders' ORDER_HISTORY_URL='http://localhost:1996/orders'
POST_MARK_AS_READ_DELAY=2000
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh' REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
SEGMENT_KEY='' SEGMENT_KEY=''
SITE_NAME=localhost SITE_NAME=localhost
USER_INFO_COOKIE_NAME='edx-user-info' USER_INFO_COOKIE_NAME='edx-user-info'
SUPPORT_URL='https://support.edx.org'
TA_FEEDBACK_FORM: 'https://learner-form.test'
STAFF_FEEDBACK_FORM: 'https://staff-form.test'

View File

@@ -8,16 +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'

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

37284
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,18 +27,16 @@
}, },
"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/frontend-platform": "2.6.1",
"@edx/paragon": "19.10.1", "@edx/paragon": "19.10.1",
"@reduxjs/toolkit": "1.8.0", "@reduxjs/toolkit": "1.8.0",
"@tinymce/tinymce-react": "3.13.1", "@tinymce/tinymce-react": "3.13.1",
@@ -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 class="vh-100 vw-100 h-100 m-0"> <body class="vh-100 vw-100 overflow-hidden">
<div id="root" class="vh-100 vw-100 small"></div> <div id="root" class="vh-100 vw-100 small"></div>
</body> </body>
</html> </html>

View File

@@ -1,60 +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]'],
],
displayMath: [
['[mathjax]', '[/mathjax]'],
['$$', '$$'],
['\\\\[', '\\\\]'],
['\\[', '\\]'],
],
},
skipStartupTypeset: true,
};
function HTMLLoader({ htmlNode, componentId, cssClassName }) {
const isLatex = htmlNode.match(/(\${1,2})((?:\\.|.)*)\1/)
|| htmlNode.match(/(\[mathjax](.+?)\[\/mathjax])+/)
|| htmlNode.match(/(\[mathjaxinline](.+?)\[\/mathjaxinline])+/)
|| 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={`p-2 bg-light-200 rounded box-shadow-down-1 post-preview ${isPost ? 'mt-2 mb-5' : 'my-3'}`}
style={{ maxHeight: '200px', overflow: 'scroll' }}
>
<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,47 +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,
}) {
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">
{
loadingStatus === RequestStatus.SUCCESSFUL
? intl.formatMessage(messages.searchInfo, { count, text })
: intl.formatMessage(messages.searchInfoSearching)
}
</Button>
<Button variant="link" size="inline" className="ml-auto mr-4" onClick={onClear}>
{intl.formatMessage(messages.clearSearch)}
</Button>
</div>
);
}
SearchInfo.propTypes = {
intl: intlShape.isRequired,
count: PropTypes.number.isRequired,
text: PropTypes.string.isRequired,
loadingStatus: PropTypes.string.isRequired,
onClear: PropTypes.func,
};
SearchInfo.defaultProps = {
onClear: () => {},
};
export default injectIntl(SearchInfo);

View File

@@ -6,7 +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 { MAX_UPLOAD_FILE_SIZE } from '../data/constants';
import { uploadFile } from '../discussions/posts/data/api'; import { uploadFile } from '../discussions/posts/data/api';
import 'tinymce/plugins/code'; import 'tinymce/plugins/code';
@@ -24,9 +23,6 @@ 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';
@@ -35,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',
@@ -51,7 +46,6 @@ 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
@@ -61,11 +55,6 @@ export default function TinyMCEEditor(props) {
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');
success(location); success(location);
@@ -92,21 +81,17 @@ export default function TinyMCEEditor(props) {
browser_spellcheck: true, browser_spellcheck: true,
a11y_advanced_options: true, a11y_advanced_options: true,
autosave_interval: '1s', autosave_interval: '1s',
autosave_restore_when_empty: false, autosave_restore_when_empty: true,
plugins: 'autosave codesample link lists image imagetools code emoticons charmap', plugins: 'autosave codesample link lists image imagetools code',
toolbar: 'undo redo' toolbar: 'formatselect | bold italic underline'
+ ' | formatselect | bold italic underline'
+ ' | link blockquote openedx_code image' + ' | link blockquote openedx_code image'
+ ' | bullist numlist outdent indent' + ' | bullist numlist outdent indent'
+ ' | removeformat' + ' | removeformat'
+ ' | openedx_html' + ' | openedx_html'
+ ' | emoticons' + ' | undo redo',
+ ' | charmap',
content_css: false, content_css: false,
content_style: contentStyle, content_style: contentStyle,
body_class: 'm-2 text-editor', body_class: 'm-2',
default_link_target: '_blank',
target_list: false,
images_upload_handler: uploadHandler, images_upload_handler: uploadHandler,
setup, setup,
}} }}

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,24 +0,0 @@
import React from 'react';
export default function ReportGmailerrorred() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
fill="none"
viewBox="0 0 20 20"
>
<g clipPath="url(#clip0_6935_1296)">
<path d="M13.1083 2.5H6.89167L2.5 6.89167V13.1083L6.89167 17.5H13.1083L17.5 13.1083V6.89167L13.1083 2.5ZM15.8333 12.4167L12.4167 15.8333H7.58333L4.16667 12.4167V7.58333L7.58333 4.16667H12.4167L15.8333 7.58333V12.4167Z" fill="#00262B" />
<path d="M9.99996 14.1667C10.4602 14.1667 10.8333 13.7936 10.8333 13.3333C10.8333 12.8731 10.4602 12.5 9.99996 12.5C9.53972 12.5 9.16663 12.8731 9.16663 13.3333C9.16663 13.7936 9.53972 14.1667 9.99996 14.1667Z" fill="#00262B" />
<path d="M9.16663 5.83331H10.8333V11.6666H9.16663V5.83331Z" fill="#00262B" />
</g>
<defs>
<clipPath id="clip0_6935_1296">
<rect width="20" height="20" fill="white" />
</clipPath>
</defs>
</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,12 +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 ReportGmailerrorred } from './ReportGmailerrorred';
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',
}; };
/** /**
@@ -135,6 +134,17 @@ export const LearnersOrdering = {
BY_LAST_ACTIVITY: 'activity', BY_LAST_ACTIVITY: 'activity',
}; };
/**
* Enum for Learner content tabs
* @readonly
* @enum {string}
*/
export const LearnerTabs = {
POSTS: 'posts',
COMMENTS: 'comments',
RESPONSES: 'responses',
};
/** /**
* Enum for discussion provider types supported by the MFE. * Enum for discussion provider types supported by the MFE.
* @type {{OPEN_EDX: string, LEGACY: string}} * @type {{OPEN_EDX: string, LEGACY: string}}
@@ -152,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`,
@@ -164,57 +179,38 @@ export const Routes = {
`${BASE_PATH}`, `${BASE_PATH}`,
], ],
EDIT_POST: [ EDIT_POST: [
`${BASE_PATH}/category/:category/posts/:postId/edit`,
`${BASE_PATH}/topics/:topicId/posts/:postId/edit`, `${BASE_PATH}/topics/:topicId/posts/:postId/edit`,
`${BASE_PATH}/posts/:postId/edit`, `${BASE_PATH}/posts/:postId/edit`,
`${BASE_PATH}/my-posts/:postId/edit`, `${BASE_PATH}/my-posts/:postId/edit`,
`${BASE_PATH}/learners/:learnerUsername/posts/:postId/edit`,
], ],
}, },
COMMENTS: { COMMENTS: {
PATH: [ PATH: [
`${BASE_PATH}/category/:category/posts/:postId`,
`${BASE_PATH}/topics/:topicId/posts/:postId`, `${BASE_PATH}/topics/:topicId/posts/:postId`,
`${BASE_PATH}/posts/:postId`, `${BASE_PATH}/posts/:postId`,
`${BASE_PATH}/my-posts/:postId`, `${BASE_PATH}/my-posts/:postId`,
`${BASE_PATH}/learners/:learnerUsername/posts/:postId`,
], ],
PAGE: `${BASE_PATH}/:page`, PAGE: `${BASE_PATH}/:page`,
PAGES: { PAGES: {
category: `${BASE_PATH}/category/:category/posts/:postId`,
topics: `${BASE_PATH}/topics/:topicId/posts/:postId`, topics: `${BASE_PATH}/topics/:topicId/posts/:postId`,
posts: `${BASE_PATH}/posts/:postId`, posts: `${BASE_PATH}/posts/:postId`,
'my-posts': `${BASE_PATH}/my-posts/:postId`, 'my-posts': `${BASE_PATH}/my-posts/:postId`,
learners: `${BASE_PATH}/learners/:learnerUsername/posts/:postId`,
}, },
}, },
TOPICS: { TOPICS: {
PATH: [ PATH: [
`${BASE_PATH}/topics/:topicId?`, `${BASE_PATH}/topics/:topicId?`,
`${BASE_PATH}/category/:category`,
`${BASE_PATH}/topics`,
], ],
ALL: `${BASE_PATH}/topics`, ALL: `${BASE_PATH}/topics`,
CATEGORY: `${BASE_PATH}/category/:category`, CATEGORY: `${BASE_PATH}/category/:category`,
CATEGORY_POST: `${BASE_PATH}/category/:category/posts/:postId`,
TOPIC: `${BASE_PATH}/topics/:topicId`, TOPIC: `${BASE_PATH}/topics/:topicId`,
}, },
}; };
export const PostsPages = {
category: `${BASE_PATH}/category/:category/posts`,
topics: `${BASE_PATH}/topics/:topicId/posts`,
posts: `${BASE_PATH}/posts`,
'my-posts': `${BASE_PATH}/my-posts`,
learners: `${BASE_PATH}/learners/:learnerUsername/posts`,
};
export const ALL_ROUTES = [] export const ALL_ROUTES = []
.concat([Routes.TOPICS.CATEGORY_POST, Routes.TOPICS.CATEGORY]) .concat([Routes.TOPICS.CATEGORY])
.concat(Routes.COMMENTS.PATH) .concat(Routes.COMMENTS.PATH)
.concat(Routes.TOPICS.PATH) .concat(Routes.TOPICS.PATH)
.concat([Routes.POSTS.ALL_POSTS, Routes.POSTS.MY_POSTS]) .concat([Routes.POSTS.ALL_POSTS, Routes.POSTS.MY_POSTS])
.concat([Routes.LEARNERS.POSTS, Routes.LEARNERS.PATH]) .concat([Routes.LEARNERS.LEARNER, Routes.LEARNERS.PATH])
.concat([Routes.DISCUSSIONS.PATH]); .concat([Routes.DISCUSSIONS.PATH]);
export const MAX_UPLOAD_FILE_SIZE = 1024;

View File

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

@@ -32,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,48 +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">
<div className="mx-4 text-primary-700" role="heading" aria-level="2" style={{ lineHeight: '28px' }}> <div className="my-3">
{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>
<div className="mx-4" role="list"> {comments.map(comment => (
{sortedComments.map(comment => ( <Comment comment={comment} key={comment.id} postType={postType} />
<Comment comment={comment} key={comment.id} postType={postType} isClosedPost={isClosed} /> ))}
))}
{!!sortedComments.length && !isClosed {hasMorePages && !isLoading && (
&& <ResponseEditor postId={postId} addWrappingDiv />} <Button
{hasMorePages && !isLoading && ( onClick={handleLoadMoreResponses}
<Button variant="link"
onClick={handleLoadMoreResponses} block="true"
variant="link" className="card p-4"
block="true" data-testid="load-more-comments"
className="card p-4" >
data-testid="load-more-comments" {intl.formatMessage(messages.loadMoreResponses)}
> </Button>
{intl.formatMessage(messages.loadMoreResponses)} )}
</Button> {isLoading
)}
{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>
)} )}
</div> </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,
@@ -118,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
&& ( && (
@@ -138,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 && (
@@ -148,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,110 +34,93 @@ 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)} /> 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.loadMoreResponses)} {intl.formatMessage(messages.loadMoreResponses)}
</Button> </Button>
)}
{!isNested && showFullThread
&& (
isReplying
? (
<CommentEditor
comment={{
threadId: comment.threadId,
parentId: comment.id,
}}
onCloseEditor={() => setReplying(false)}
/>
)
: (
<Button className="d-flex flex-grow " variant="outline-secondary" onClick={() => setReplying(true)}>
{intl.formatMessage(messages.addComment)}
</Button>
)
)} )}
{!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-4.5"
variant="outline-primary"
onClick={() => setReplying(true)}
>
{intl.formatMessage(messages.addComment)}
</Button>
)}
</>
)
)}
</div>
</div> </div>
</div> </div>
); );
@@ -151,13 +130,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,14 +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,
} 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';
@@ -26,46 +20,15 @@ function CommentEditor({
intl, intl,
comment, comment,
onCloseEditor, onCloseEditor,
edit,
}) { }) {
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 { reasonCodesEnabled, editReasons } = useSelector(selectModerationSettings); const { reasonCodesEnabled, editReasons } = useSelector(selectModerationSettings);
const [submitting, dispatch] = useDispatchWithState(); const [submitting, dispatch] = useDispatchWithState();
const editorRef = useRef(null);
const canDisplayEditReason = (reasonCodesEnabled && (userHasModerationPrivileges || userIsGroupTa) const saveUpdatedComment = async (values) => {
&& edit && 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 || '',
};
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));
} }
@@ -73,16 +36,22 @@ function CommentEditor({
if (editorRef.current) { if (editorRef.current) {
editorRef.current.plugins.autosave.removeDraft(); editorRef.current.plugins.autosave.removeDraft();
} }
handleCloseEditor(resetForm); onCloseEditor();
}; };
// The editorId is used to autosave contents to localstorage. This format means that the autosave is scoped to // The editorId is used to autosave contents to localstorage. This format means that the autosave is scoped to
// the current comment id, or the current comment parent or the curren thread. // the current comment id, or the current comment parent or the curren thread.
const editorId = `comment-editor-${comment.id || comment.parentId || comment.threadId}`; const editorId = `comment-editor-${comment.id || comment.parentId || comment.threadId}`;
return ( return (
<Formik <Formik
initialValues={initialValues} initialValues={{ comment: comment.rawBody }}
validationSchema={validationSchema} validationSchema={Yup.object()
.shape({
comment: Yup.string()
.required(),
editReasonCode: Yup.string()
.nullable()
.default(undefined),
})}
onSubmit={saveUpdatedComment} onSubmit={saveUpdatedComment}
> >
{({ {({
@@ -92,16 +61,12 @@ function CommentEditor({
handleSubmit, handleSubmit,
handleBlur, handleBlur,
handleChange, handleChange,
resetForm,
}) => ( }) => (
<Form onSubmit={handleSubmit}> <Form onSubmit={handleSubmit}>
{canDisplayEditReason && ( {(reasonCodesEnabled
<Form.Group && userIsPrivileged
isInvalid={isFormikFieldInvalid('editReasonCode', { && comment.author !== authenticatedUser.username) && (
errors, <Form.Group>
touched,
})}
>
<Form.Control <Form.Control
name="editReasonCode" name="editReasonCode"
className="mt-2" className="mt-2"
@@ -120,7 +85,6 @@ function CommentEditor({
<option key={code} value={code}>{label}</option> <option key={code} value={code}>{label}</option>
))} ))}
</Form.Control> </Form.Control>
<FormikErrorFeedback name="editReasonCode" />
</Form.Group> </Form.Group>
)} )}
<TinyMCEEditor <TinyMCEEditor
@@ -144,11 +108,10 @@ function CommentEditor({
{intl.formatMessage(messages.commentError)} {intl.formatMessage(messages.commentError)}
</Form.Control.Feedback> </Form.Control.Feedback>
)} )}
<PostPreviewPane htmlNode={values.comment} />
<div className="d-flex py-2 justify-content-end"> <div className="d-flex py-2 justify-content-end">
<Button <Button
variant="outline-primary" variant="outline-primary"
onClick={() => handleCloseEditor(resetForm)} onClick={onCloseEditor}
> >
{intl.formatMessage(messages.cancel)} {intl.formatMessage(messages.cancel)}
</Button> </Button>
@@ -176,15 +139,9 @@ CommentEditor.propTypes = {
parentId: PropTypes.string, parentId: PropTypes.string,
rawBody: PropTypes.string, rawBody: PropTypes.string,
author: PropTypes.string, author: PropTypes.string,
lastEdit: PropTypes.object,
}).isRequired, }).isRequired,
onCloseEditor: PropTypes.func.isRequired, onCloseEditor: PropTypes.func.isRequired,
intl: intlShape.isRequired, intl: intlShape.isRequired,
edit: PropTypes.bool,
};
CommentEditor.defaultProps = {
edit: true,
}; };
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,32 +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,43 +1,23 @@
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 comment={{ threadId: postId }} onCloseEditor={() => setAddingResponse(false)} />
<CommentEditor ) : (
comment={{ threadId: postId }} <div className="actions d-flex">
edit={false} <Button variant="primary" onClick={() => setAddingResponse(true)}>
onCloseEditor={() => setAddingResponse(false)}
/>
</div>
)
: !inBlackoutDateRange(blackoutDateRange) && (
<div className={classNames({ 'mb-4': addWrappingDiv }, 'actions d-flex')}>
<Button variant="primary" className="px-2.5 py-2" onClick={() => setAddingResponse(true)}>
{intl.formatMessage(messages.addResponse)} {intl.formatMessage(messages.addResponse)}
</Button> </Button>
</div> </div>
@@ -47,11 +27,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,28 @@ 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) {
const { data } = await getAuthenticatedHttpClient()
.get(commentsApiUrl, {
params: {
course_id: courseId,
username,
},
});
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,8 +1,6 @@
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 {
@@ -12,10 +10,9 @@ 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,
@@ -34,22 +31,17 @@ 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={() => setOpen(!isOpen)} <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={dropdownIconRef} />
/> </span>
<ModalPopup <ModalPopup
onClose={() => setOpen(false)} onClose={() => setOpen(false)}
positionRef={dropdownIconRef} positionRef={dropdownIconRef}

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,63 +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 isReportedByCurrentUser = getAuthenticatedUser().username === content?.abuseFlaggedBy;
const canSeeReportedBanner = (userHasModerationPrivileges || userIsGroupTa || isReportedByCurrentUser);
return ( return (
<> <>
{content.abuseFlagged && 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>
)}
</>
)} )}
</> </>
); );
@@ -67,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 !== 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,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,11 +2,10 @@
import React from 'react'; import React from 'react';
export const DiscussionContext = React.createContext({ export const DiscussionContext = React.createContext({
page: null,
courseId: null, courseId: null,
postId: null, postId: null,
topicId: null,
inContext: false,
category: null, category: null,
commentId: null,
learnerUsername: null, learnerUsername: null,
inContext: false,
}); });

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,12 +1,9 @@
/* 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';
@@ -15,14 +12,8 @@ import { fetchCourseBlocks } from '../../data/thunks';
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, selectUserIsStaff, selectUserRoles,
} from './selectors';
import { fetchCourseConfig } from './thunks'; import { fetchCourseConfig } from './thunks';
export function useTotalTopicThreadCount() { export function useTotalTopicThreadCount() {
@@ -93,68 +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 isReportedByCurrentUser = getAuthenticatedUser().username === content?.abuseFlaggedBy;
const canSeeReportedBanner = (userHasModerationPrivileges || userIsGroupTa || isReportedByCurrentUser);
return (
(reasonCodesEnabled && canSeeLastEditOrClosedAlert && (content.lastEdit?.reason || content.closed))
|| (content.abuseFlagged && canSeeReportedBanner)
);
};
export const useShowLearnersTab = () => {
const learnersTabEnabled = useSelector(selectLearnersTabEnabled);
const userRoles = useSelector(selectUserRoles);
const isAdmin = useSelector(selectUserIsStaff);
const IsGroupTA = useSelector(selectUserIsGroupTa);
const privileged = useSelector(selectUserHasModerationPrivileges);
const allowedUsers = isAdmin || IsGroupTA || privileged || (userRoles.includes('Student') && userRoles.length > 1);
return learnersTabEnabled && allowedUsers;
};

View File

@@ -0,0 +1,68 @@
import { useRef } from 'react';
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 { initializeStore } from '../../store';
import { useContainerSizeForParent } from './hooks';
let store;
initializeMockApp();
describe('Hooks', () => {
function ComponentWithHook() {
const refContainer = useRef(null);
useContainerSizeForParent(refContainer);
return (
<div>
<div ref={refContainer} />
</div>
);
}
function renderComponent() {
return render(
<IntlProvider locale="en">
<ResponsiveContext.Provider value={{ width: 1280 }}>
<AppProvider store={store}>
<MemoryRouter initialEntries={['/']}>
<ComponentWithHook />
</MemoryRouter>
</AppProvider>
</ResponsiveContext.Provider>
</IntlProvider>,
);
}
let parent;
beforeEach(() => {
store = initializeStore();
parent = window.parent;
});
afterEach(() => {
window.parent = parent;
});
test('useContainerSizeForParent enabled', async () => {
delete window.parent;
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 that size is reset on unmount
unmount();
await waitFor(() => expect(window.parent.postMessage).toHaveBeenCalledTimes(4));
expect(window.parent.postMessage).toHaveBeenLastCalledWith(
{ type: 'plugin.resize', payload: { height: null } },
getConfig().LMS_BASE_URL,
);
});
test('useContainerSizeForParent disabled', async () => {
window.parent.postMessage = jest.fn();
renderComponent();
await waitFor(() => expect(window.parent.postMessage).not.toHaveBeenCalled());
});
});

View File

@@ -6,22 +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 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,9 +11,7 @@ const configSlice = createSlice({
allowAnonymous: false, allowAnonymous: false,
allowAnonymousToPeers: false, allowAnonymousToPeers: false,
userRoles: [], userRoles: [],
hasModerationPrivileges: false, userIsPrivileged: 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,46 +1,22 @@
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 { 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"> <div className="d-flex flex-column w-100 mw-xl" ref={refContainer}>
{!isOnDesktop && (
<IconButton
src={ArrowBack}
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 +29,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,66 +1,38 @@
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 { RequestStatus, Routes } from '../../data/constants'; import { Routes } from '../../data/constants';
import { import { LearnersView } from '../learners';
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);
useEffect(() => {
if (sidebarRef && postActionBarHeight) {
if (isOnDesktop) {
sidebarRef.current.style.maxHeight = `${document.body.offsetHeight - postActionBarHeight}px`;
}
sidebarRef.current.style.minHeight = `${document.body.offsetHeight - 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>
<Route path={Routes.POSTS.MY_POSTS}>
<PostsView showOwnPosts />
</Route>
<Route <Route
path={[Routes.POSTS.PATH, Routes.POSTS.ALL_POSTS, Routes.TOPICS.CATEGORY, Routes.POSTS.MY_POSTS]} path={[Routes.POSTS.PATH, Routes.POSTS.ALL_POSTS, Routes.TOPICS.CATEGORY]}
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={{
@@ -68,7 +40,6 @@ export default function DiscussionSidebar({ displaySidebar, postActionBarRef })
pathname: Routes.POSTS.ALL_POSTS, pathname: Routes.POSTS.ALL_POSTS,
}} }}
/> />
)}
</Switch> </Switch>
</div> </div>
); );
@@ -76,13 +47,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,43 +1,34 @@
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 { 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 { 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 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 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 { const {
courseId, courseId,
postId, postId,
@@ -46,16 +37,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;
const inIframe = new URLSearchParams(location.search).get('inIframe')?.toLowerCase() === 'true';
// 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.
@@ -82,27 +71,22 @@ export default function DiscussionsHome() {
learnerUsername, learnerUsername,
}} }}
> >
{!inIframe && <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
{!inIframe className="d-flex flex-row justify-content-between navbar fixed-top"
&& <CourseTabsNavigation activeTab="discussion" courseId={courseId} />} style={{ boxShadow: '0px 2px 4px rgb(0 0 0 / 15%), 0px 2px 8px rgb(0 0 0 / 15%)' }}
<div className="header-action-bar" ref={postActionBarRef}> >
<div {!inContext && (
className="d-flex flex-row justify-content-between navbar fixed-top" <Route path={Routes.DISCUSSIONS.PATH} component={NavigationBar} />
> )}
{!inContext && ( <PostActionsBar inContext={inContext} />
<Route path={Routes.DISCUSSIONS.PATH} component={NavigationBar} />
)}
<PostActionsBar inContext={inContext} />
</div>
<InformationBanner />
</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>
@@ -112,15 +96,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>
{!inIframe && <Footer />}
</DiscussionContext.Provider> </DiscussionContext.Provider>
); );
} }

View File

@@ -87,36 +87,4 @@ describe('DiscussionsHome', () => {
await waitFor(() => expect(window.parent.postMessage).toHaveBeenCalled()); await waitFor(() => expect(window.parent.postMessage).toHaveBeenCalled());
window.parent = parent; window.parent = parent;
}); });
describe.each([
{
queryParam: 'inIframe=True',
iframeView: true,
},
{
queryParam: 'inIframe=False',
iframeView: false,
},
{
queryParam: '',
iframeView: false,
},
])(
'Header/Footer visibility',
({
queryParam,
iframeView,
}) => {
test(`inIframe query param ${queryParam}`, async () => {
renderComponent(`/${courseId}/topics?${queryParam}`);
if (iframeView) {
expect(screen.queryByRole('banner')).not.toBeInTheDocument();
expect(screen.queryByRole('contentinfo')).not.toBeInTheDocument();
} else {
expect(screen.queryByRole('banner')).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 style={{ fontWeight: '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

@@ -1,105 +0,0 @@
import React, {
useCallback, useContext, useEffect, useMemo,
} from 'react';
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 { RequestStatus, Routes } from '../../data/constants';
import { DiscussionContext } from '../common/context';
import {
selectAllThreads,
selectThreadNextPage,
threadsLoadingStatus,
} from '../posts/data/selectors';
import NoResults from '../posts/NoResults';
import { PostLink } from '../posts/post';
import { discussionsPath, filterPosts } from '../utils';
import { fetchUserPosts } from './data/thunks';
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 { courseId, learnerUsername: username } = useContext(DiscussionContext);
const nextPage = useSelector(selectThreadNextPage());
useEffect(() => {
dispatch(fetchUserPosts(courseId, username));
}, [courseId, username]);
const loadMorePosts = () => (
dispatch(fetchUserPosts(courseId, username, {
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" />
<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

@@ -0,0 +1,110 @@
import React, { useContext } from 'react';
import classNames from 'classnames';
import { useSelector } from 'react-redux';
import {
generatePath, NavLink, Redirect, Route, Switch,
} from 'react-router-dom';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import {
Avatar, ButtonGroup, Card, Icon, IconButton, Spinner,
} from '@edx/paragon';
import { MoreHoriz, Report } from '@edx/paragon/icons';
import { LearnerTabs, RequestStatus, Routes } from '../../data/constants';
import { DiscussionContext } from '../common/context';
import {
learnersLoadingStatus, selectLearner, selectLearnerAvatar, selectLearnerProfile,
} from './data/selectors';
import CommentsTabContent from './learner/CommentsTabContent';
import PostsTabContent from './learner/PostsTabContent';
import messages from './messages';
function LearnersContentView({ intl }) {
const { courseId, learnerUsername } = useContext(DiscussionContext);
const params = { courseId, learnerUsername };
const apiStatus = useSelector(learnersLoadingStatus());
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="learner-content d-flex flex-column">
<Card>
<Card.Body>
<div className="d-flex flex-row align-items-center m-3">
<Avatar src={avatar} alt={learnerUsername} />
<span className="font-weight-bold mx-3">
{profile.username}
</span>
<div className="ml-auto">
<IconButton iconAs={Icon} src={MoreHoriz} alt="Options" />
</div>
</div>
</Card.Body>
<Card.Footer className="pb-0 bg-light-200 justify-content-center">
<ButtonGroup className="my-2">
<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>
</Card.Footer>
</Card>
<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 = {
intl: intlShape.isRequired,
};
export default injectIntl(LearnersContentView);

View File

@@ -0,0 +1,172 @@
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 { threadsApiUrl } from '../posts/data/api';
import { coursesApiUrl, userProfileApiUrl } from './data/api';
import { fetchLearners, fetchUserComments } from './data/thunks';
import LearnersContentView from './LearnersContentView';
import './data/__factories__';
import '../comments/data/__factories__';
import '../posts/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">
<LearnersContentView />
</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, { params: { course_id: courseId, author: testUsername } })
.reply(200, Factory.build('threadsResult', {}, {
topicId: undefined,
count: 5,
pageSize: 6,
}));
axiosMock.onGet(commentsApiUrl, { params: { course_id: courseId, username: testUsername } })
.reply(200, Factory.build('commentsResult', {}, {
count: 8,
pageSize: 10,
}));
});
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, { params: { course_id: courseId, username: testUsername } })
.reply(200, Factory.build('commentsResult', {}, {
count: 4,
parentId: 'test_parent_id',
}));
executeThunk(fetchUserComments(courseId, testUsername), store.dispatch, store.state);
await act(async () => {
await renderComponent();
});
await act(async () => {
fireEvent.click(screen.getByText('Comments', { exact: false }));
});
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.getByText('Responses', { exact: false }));
});
expect(screen.queryAllByText('comment number', { exact: false })).toHaveLength(8);
await act(async () => {
fireEvent.click(screen.getByText('Posts', { exact: false }));
});
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.getByText('Posts', { exact: false });
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.getByText('Posts', { exact: false });
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">
{!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);

View File

@@ -85,6 +85,7 @@ describe('LearnersView', () => {
await act(async () => { await act(async () => {
await renderComponent(); await renderComponent();
}); });
expect(screen.queryAllByText(/Last active/i, { exact: false }).length).toBeGreaterThan(0);
}); });
}); });
}); });

View File

@@ -10,16 +10,19 @@ const apiBaseUrl = getConfig().LMS_BASE_URL;
export const coursesApiUrl = `${apiBaseUrl}/api/discussion/v1/courses/`; export const coursesApiUrl = `${apiBaseUrl}/api/discussion/v1/courses/`;
export const userProfileApiUrl = `${apiBaseUrl}/api/user/v1/accounts`; export const userProfileApiUrl = `${apiBaseUrl}/api/user/v1/accounts`;
export const postsApiUrl = `${apiBaseUrl}/api/discussion/v1/threads/`;
export const commentsApiUrl = `${apiBaseUrl}/api/discussion/v1/comments/`;
/** /**
* Fetches all the learners in the given course. * Fetches all the learners in the given course.
* @param {string} courseId * @param {string} courseId
* @param {object} params {page, order_by}
* @returns {Promise<{}>} * @returns {Promise<{}>}
*/ */
export async function getLearners(courseId, params) { export async function getLearners(
courseId,
) {
const url = `${coursesApiUrl}${courseId}/activity_stats/`; const url = `${coursesApiUrl}${courseId}/activity_stats/`;
const { data } = await getAuthenticatedHttpClient().get(url, { params }); const { data } = await getAuthenticatedHttpClient().get(url);
return data; return data;
} }
@@ -32,23 +35,3 @@ export async function getUserProfiles(usernames) {
const { data } = await getAuthenticatedHttpClient().get(url); const { data } = await getAuthenticatedHttpClient().get(url);
return data; return data;
} }
/**
* Get the posts by a specific user in a course's discussions
*
* @param {string} courseId Course ID of the course
* @param {string} username Username of the user
* @param {number} page
* @returns API Response object in the format
* {
* results: [array of posts],
* pagination: {count, num_pages, next, previous}
* }
*/
export async function getUserPosts(courseId, username, { page }) {
const learnerPostsApiUrl = `${coursesApiUrl}${courseId}/learner/`;
const { data } = await getAuthenticatedHttpClient()
.get(learnerPostsApiUrl, { params: { username, page } });
return data;
}

View File

@@ -2,21 +2,23 @@
import { createSelector } from '@reduxjs/toolkit'; import { createSelector } from '@reduxjs/toolkit';
import { LearnerTabs } from '../../../data/constants';
export const selectAllLearners = createSelector( export const selectAllLearners = createSelector(
state => state.learners.pages, state => state.learners,
pages => pages.flat(), learners => learners.learners,
); );
export const learnersLoadingStatus = () => state => state.learners.status; export const learnersLoadingStatus = () => state => state.learners.status;
export const selectUsernameSearch = () => state => state.learners.usernameSearch;
export const selectLearnerSorting = () => state => state.learners.sortedBy; export const selectLearnerSorting = () => state => state.learners.sortedBy;
export const selectLearnerFilters = () => state => state.learners.filters;
export const selectLearnerNextPage = () => state => state.learners.nextPage; export const selectLearnerNextPage = () => state => state.learners.nextPage;
export const selectLearnerAvatar = author => state => ( export const selectLearnerAvatar = author => state => (
state.learners.learnerProfiles[author]?.profileImage?.imageUrlLarge state.learners.learnerProfiles[author]?.profileImage?.imageUrlSmall
); );
export const selectLearnerLastLogin = author => state => ( export const selectLearnerLastLogin = author => state => (
@@ -27,3 +29,21 @@ export const selectLearner = (username) => createSelector(
[selectAllLearners], [selectAllLearners],
learners => learners.find(l => l.username === username) || {}, learners => learners.find(l => l.username === username) || {},
); );
export const selectLearnerProfile = (username) => state => state.learners.learnerProfiles[username] || {};
export const selectUserPosts = username => state => state.learners.postsByUser[username] || [];
/**
* Get the comments of a post.
* @param {string} username Username of the learner to get the comments of
* @param {LearnerTabs} commentType Type of comments to get
* @returns {Array} Array of comments
*/
export const selectUserComments = (username, commentType) => state => (
commentType === LearnerTabs.COMMENTS
? (state.learners.commentsByUser[username] || []).filter(c => c.parentId)
: (state.learners.commentsByUser[username] || []).filter(c => !c.parentId)
);
export const flaggedCommentCount = (username) => state => state.learners.flaggedCommentsByUser[username] || 0;

View File

@@ -10,23 +10,36 @@ const learnersSlice = createSlice({
name: 'learner', name: 'learner',
initialState: { initialState: {
status: RequestStatus.IN_PROGRESS, status: RequestStatus.IN_PROGRESS,
avatars: {},
learners: [],
learnerProfiles: {}, learnerProfiles: {},
pages: [], pages: [],
nextPage: null, nextPage: null,
totalPages: null, totalPages: null,
totalLearners: null, totalLearners: null,
sortedBy: LearnersOrdering.BY_LAST_ACTIVITY, sortedBy: LearnersOrdering.BY_LAST_ACTIVITY,
usernameSearch: null, commentsByUser: {
// Map username to comments
},
postsByUser: {
// Map username to posts
},
commentCountByUser: {
// Map of username and comment count
},
postCountByUser: {
// Map of username and post count
},
}, },
reducers: { reducers: {
fetchLearnersSuccess: (state, { payload }) => { fetchLearnersSuccess: (state, { payload }) => {
state.status = RequestStatus.SUCCESSFUL; state.status = RequestStatus.SUCCESSFUL;
state.pages[payload.page - 1] = payload.results; state.learners = payload.results;
state.learnerProfiles = { state.learnerProfiles = {
...state.learnerProfiles, ...state.learnerProfiles,
...(payload.learnerProfiles || {}), ...(payload.learnerProfiles || {}),
}; };
state.nextPage = (payload.page < payload.pagination.numPages) ? payload.page + 1 : null; state.nextPage = payload.pagination.next;
state.totalPages = payload.pagination.numPages; state.totalPages = payload.pagination.numPages;
state.totalLearners = payload.pagination.count; state.totalLearners = payload.pagination.count;
}, },
@@ -40,13 +53,32 @@ const learnersSlice = createSlice({
state.status = RequestStatus.IN_PROGRESS; state.status = RequestStatus.IN_PROGRESS;
}, },
setSortedBy: (state, { payload }) => { setSortedBy: (state, { payload }) => {
state.pages = [];
state.sortedBy = payload; state.sortedBy = payload;
},
setUsernameSearch: (state, { payload }) => {
state.usernameSearch = payload;
state.pages = []; state.pages = [];
}, },
fetchUserCommentsRequest: (state) => {
state.status = RequestStatus.IN_PROGRESS;
},
fetchUserCommentsSuccess: (state, { payload }) => {
state.commentsByUser[payload.username] = payload.comments;
state.commentCountByUser[payload.username] = payload.pagination.count;
state.status = RequestStatus.SUCCESS;
},
fetchUserCommentsDenied: (state) => {
state.status = RequestStatus.DENIED;
},
fetchUserPostsRequest: (state) => {
state.status = RequestStatus.IN_PROGRESS;
},
fetchUserPostsSuccess: (state, { payload }) => {
state.postsByUser[payload.username] = payload.posts;
state.postCountByUser[payload.username] = payload.pagination.count;
state.status = RequestStatus.SUCCESS;
},
fetchUserPostsDenied: (state) => {
state.status = RequestStatus.DENIED;
},
}, },
}); });
@@ -56,7 +88,13 @@ export const {
fetchLearnersSuccess, fetchLearnersSuccess,
fetchLearnersDenied, fetchLearnersDenied,
setSortedBy, setSortedBy,
setUsernameSearch, fetchUserCommentsRequest,
fetchUserCommentsDenied,
fetchUserCommentsSuccess,
fetchUserPostsRequest,
fetchUserPostsDenied,
fetchUserPostsSuccess,
} = learnersSlice.actions; } = learnersSlice.actions;
export const learnersReducer = learnersSlice.reducer; export const learnersReducer = learnersSlice.reducer;

View File

@@ -1,44 +1,43 @@
/* eslint-disable import/prefer-default-export */ /* eslint-disable import/prefer-default-export */
import { camelCaseObject, snakeCaseObject } 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 { import { getUserComments } from '../../comments/data/api';
fetchLearnerThreadsRequest, import { getUserPosts } from '../../posts/data/api';
fetchThreadsDenied,
fetchThreadsFailed,
fetchThreadsSuccess,
} from '../../posts/data/slices';
import { normaliseThreads } from '../../posts/data/thunks';
import { getHttpErrorStatus } from '../../utils'; import { getHttpErrorStatus } from '../../utils';
import { getLearners, getUserPosts, getUserProfiles } from './api'; import {
getLearners, getUserProfiles,
} from './api';
import { import {
fetchLearnersDenied, fetchLearnersDenied,
fetchLearnersFailed, fetchLearnersFailed,
fetchLearnersRequest, fetchLearnersRequest,
fetchLearnersSuccess, fetchLearnersSuccess,
fetchUserCommentsDenied,
fetchUserCommentsRequest,
fetchUserCommentsSuccess,
fetchUserPostsDenied,
fetchUserPostsRequest,
fetchUserPostsSuccess,
} from './slices'; } from './slices';
/** /**
* Fetches the learners for the course courseId. * Fetches the learners for the course courseId.
* @param {string} courseId The course ID for the course to fetch data for. * @param {string} courseId The course ID for the course to fetch data for.
* @param {string} orderBy
* @param {number} page
* @param {usernameSearch} username
* @returns {(function(*): Promise<void>)|*} * @returns {(function(*): Promise<void>)|*}
*/ */
export function fetchLearners(courseId, { export function fetchLearners(courseId, {
orderBy, orderBy,
page = 1, page = 1,
usernameSearch = null,
} = {}) { } = {}) {
const options = {
orderBy,
page,
};
return async (dispatch) => { return async (dispatch) => {
try { try {
const params = snakeCaseObject({ orderBy, page });
if (usernameSearch) {
params.username = usernameSearch;
}
dispatch(fetchLearnersRequest({ courseId })); dispatch(fetchLearnersRequest({ courseId }));
const learnerStats = await getLearners(courseId, params); const learnerStats = await getLearners(courseId, options);
const learnerProfilesData = await getUserProfiles(learnerStats.results.map((l) => l.username)); const learnerProfilesData = await getUserProfiles(learnerStats.results.map((l) => l.username));
const learnerProfiles = {}; const learnerProfiles = {};
learnerProfilesData.forEach( learnerProfilesData.forEach(
@@ -46,7 +45,7 @@ export function fetchLearners(courseId, {
learnerProfiles[learnerProfile.username] = camelCaseObject(learnerProfile); learnerProfiles[learnerProfile.username] = camelCaseObject(learnerProfile);
}, },
); );
dispatch(fetchLearnersSuccess({ ...camelCaseObject(learnerStats), learnerProfiles, page })); dispatch(fetchLearnersSuccess({ ...camelCaseObject(learnerStats), learnerProfiles }));
} catch (error) { } catch (error) {
if (getHttpErrorStatus(error) === 403) { if (getHttpErrorStatus(error) === 403) {
dispatch(fetchLearnersDenied()); dispatch(fetchLearnersDenied());
@@ -59,30 +58,51 @@ export function fetchLearners(courseId, {
} }
/** /**
* Fetch the posts of a user for the specified course and update the * Fetch the comments of a user for the specified course and update the
* redux state * redux state
* *
* @param {string} courseId Course ID of the course eg., course-v1:X+Y+Z * @param {string} courseId Course ID of the course eg., course-v1:X+Y+Z
* @param {string} username name of the learner * @param {string} username Username of the learner
* @param page * @returns a promise that will update the state with the learner's comments
* @returns a promise that will update the state with the learner's posts
*/ */
export function fetchUserPosts(courseId, username, { page = 1 } = {}) { export function fetchUserComments(courseId, username) {
return async (dispatch) => { return async (dispatch) => {
try { try {
dispatch(fetchLearnerThreadsRequest({ courseId, author: username })); dispatch(fetchUserCommentsRequest());
const data = await getUserComments(courseId, username);
const data = await getUserPosts(courseId, username, { page }); dispatch(fetchUserCommentsSuccess(camelCaseObject({
const normalisedData = normaliseThreads(camelCaseObject(data)); username,
comments: data.results,
dispatch(fetchThreadsSuccess({ ...normalisedData, page, author: username })); pagination: data.pagination,
})));
} catch (error) { } catch (error) {
if (getHttpErrorStatus(error) === 403) { if (getHttpErrorStatus(error) === 403) {
dispatch(fetchThreadsDenied()); dispatch(fetchUserCommentsDenied());
} else { }
dispatch(fetchThreadsFailed()); }
};
}
/**
* Fetch the posts of a user for the specified course and update the
* redux state
*
* @param {sting} courseId Course ID of the course eg., course-v1:X+Y+Z
* @param {string} username Username of the learner
* @returns a promise that will update the state with the learner's posts
*/
export function fetchUserPosts(courseId, username) {
return async (dispatch) => {
try {
dispatch(fetchUserPostsRequest());
const data = await getUserPosts(courseId, username, true);
dispatch(fetchUserPostsSuccess(camelCaseObject({
username, posts: data.results, pagination: data.pagination,
})));
} catch (error) {
if (getHttpErrorStatus(error) === 403) {
dispatch(fetchUserPostsDenied());
} }
logError(error);
} }
}; };
} }

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