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=''
MARKETING_SITE_BASE_URL=''
ORDER_HISTORY_URL=''
POST_MARK_AS_READ_DELAY=2000
REFRESH_ACCESS_TOKEN_ENDPOINT=''
SEGMENT_KEY=''
SITE_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
MARKETING_SITE_BASE_URL='http://localhost:18000'
ORDER_HISTORY_URL='http://localhost:1996/orders'
POST_MARK_AS_READ_DELAY=2000
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
SEGMENT_KEY=''
SITE_NAME=localhost
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'
LOGIN_URL='http://localhost:18000/login'
LOGOUT_URL='http://localhost:18000/logout'
LOGO_URL='https://edx-cdn.org/v3/default/logo.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'
FAVICON_URL='https://edx-cdn.org/v3/default/favicon.ico'
LOGO_URL=https://edx-cdn.org/v3/default/logo.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
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
MARKETING_SITE_BASE_URL='http://localhost:18000'
ORDER_HISTORY_URL='http://localhost:1996/orders'
POST_MARK_AS_READ_DELAY=2000
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
SEGMENT_KEY=''
SITE_NAME='localhost'
SITE_NAME=localhost
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"],
"rules": {
'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': [
'error', {
groups: [

View File

@@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
node: [16]
node: [12, 14, 16]
steps:
- name: Checkout
uses: actions/checkout@v2

View File

@@ -7,4 +7,4 @@ on:
jobs:
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_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
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 .
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:
npm run lint
npm audit
requirements: ## install ci requirements
npm ci
requirements:
npm install
i18n.extract:
# Pulling display strings from .jsx files into .json files...
@@ -51,17 +38,17 @@ push_translations:
# Pushing strings to Transifex...
tx push -s
# 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...
$(transifex_utils) $(transifex_temp) --comments --v3-scripts-path
$(transifex_utils) $(transifex_temp) --comments
# 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.
pull_translations:
tx pull -f --mode reviewed --languages=$(transifex_langs)
tx pull -f --mode reviewed --language=$(transifex_langs)
# This target is used by Travis.
validate-no-uncommitted-package-lock-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:
``git clone https://github.com/openedx/frontend-app-discussions.git``
``git clone https://github.com/edx/frontend-app-discussions.git``
2. Install npm dependencies:
@@ -29,7 +29,7 @@ The dev server is running at `http://localhost:2002 <http://localhost:2002>`_.
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
-------------------
@@ -41,7 +41,7 @@ The production build is created with ``npm run build``.
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
:target: https://travis-ci.org/edx/frontend-app-discussions

View File

@@ -2,4 +2,4 @@
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', {
// 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.
setupFiles: ['<rootDir>/.jest/setEnvVars.js'],
// If you want to add config BEFORE jest loads, use setupFiles instead.
setupFilesAfterEnv: [
'<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",
"repository": {
"type": "git",
"url": "git+https://github.com/openedx/frontend-app-discussions.git"
"url": "git+https://github.com/edx/frontend-app-discussions.git"
},
"browserslist": [
"extends @edx/browserslist-config"
"last 2 versions",
"ie 11"
],
"scripts": {
"build": "fedx-scripts webpack",
"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:fix": "fedx-scripts eslint --ext .js --ext .jsx . --fix",
"snapshot": "fedx-scripts jest --updateSnapshot",
@@ -25,18 +27,16 @@
},
"author": "edX",
"license": "AGPL-3.0",
"homepage": "https://github.com/openedx/frontend-app-discussions#readme",
"homepage": "https://github.com/edx/frontend-app-discussions#readme",
"publishConfig": {
"access": "public"
},
"bugs": {
"url": "https://github.com/openedx/frontend-app-discussions/issues"
"url": "https://github.com/edx/frontend-app-discussions/issues"
},
"dependencies": {
"@edx/brand": "npm:@edx/brand-openedx@1.1.0",
"@edx/frontend-component-footer": "11.2.0",
"@edx/frontend-component-header": "3.2.0",
"@edx/frontend-platform": "2.6.1",
"@edx/frontend-platform": "1.15.4",
"@edx/paragon": "19.10.1",
"@reduxjs/toolkit": "1.8.0",
"@tinymce/tinymce-react": "3.13.1",
@@ -49,7 +49,6 @@
"raw-loader": "4.0.2",
"react": "16.14.0",
"react-dom": "16.14.0",
"react-mathjax-preview": "2.2.6",
"react-redux": "7.2.6",
"react-router": "5.2.1",
"react-router-dom": "5.3.0",
@@ -60,20 +59,18 @@
"yup": "0.31.1"
},
"devDependencies": {
"@edx/browserslist-config": "1.1.0",
"@edx/frontend-build": "11.0.1",
"@edx/reactifex": "1.0.3",
"@edx/frontend-build": "9.1.4",
"@testing-library/jest-dom": "5.16.2",
"@testing-library/react": "12.1.4",
"@testing-library/user-event": "13.5.0",
"axios-mock-adapter": "1.20.0",
"babel-plugin-react-intl": "8.2.25",
"codecov": "3.8.3",
"es-check": "6.2.1",
"eslint-plugin-simple-import-sort": "7.0.0",
"glob": "7.2.0",
"husky": "7.0.4",
"jest": "27.5.1",
"reactifex": "1.1.1",
"rosie": "2.1.0"
}
}

View File

@@ -1,16 +1,12 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en-us">
<head>
<title>Discussions | <%= process.env.SITE_NAME %></title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link
rel="shortcut icon"
href="<%=htmlWebpackPlugin.options.FAVICON_URL%>"
type="image/x-icon"
/>
<title>Discussions | <%= process.env.SITE_NAME %></title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="shortcut icon" href="<%=htmlWebpackPlugin.options.FAVICON_URL%>" type="image/x-icon" />
</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>
</body>
</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
import tinymce from 'tinymce/tinymce';
import { MAX_UPLOAD_FILE_SIZE } from '../data/constants';
import { uploadFile } from '../discussions/posts/data/api';
import 'tinymce/plugins/code';
@@ -24,9 +23,6 @@ import 'tinymce/plugins/image';
import 'tinymce/plugins/imagetools';
import 'tinymce/plugins/link';
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-disable-next-line import/no-unresolved
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
import contentUiCss from '!!raw-loader!tinymce/skins/ui/oxide/content.min.css';
/* istanbul ignore next */
const setup = (editor) => {
editor.ui.registry.addButton('openedx_code', {
icon: 'sourcecode',
@@ -51,7 +46,6 @@ const setup = (editor) => {
});
};
/* istanbul ignore next */
export default function TinyMCEEditor(props) {
// note that skin and content_css is disabled to avoid the normal
// 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) => {
try {
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 { location } = await uploadFile(blob, filename, courseId, postId || 'root');
success(location);
@@ -92,21 +81,17 @@ export default function TinyMCEEditor(props) {
browser_spellcheck: true,
a11y_advanced_options: true,
autosave_interval: '1s',
autosave_restore_when_empty: false,
plugins: 'autosave codesample link lists image imagetools code emoticons charmap',
toolbar: 'undo redo'
+ ' | formatselect | bold italic underline'
autosave_restore_when_empty: true,
plugins: 'autosave codesample link lists image imagetools code',
toolbar: 'formatselect | bold italic underline'
+ ' | link blockquote openedx_code image'
+ ' | bullist numlist outdent indent'
+ ' | removeformat'
+ ' | openedx_html'
+ ' | emoticons'
+ ' | charmap',
+ ' | undo redo',
content_css: false,
content_style: contentStyle,
body_class: 'm-2 text-editor',
default_link_target: '_blank',
target_list: false,
body_class: 'm-2',
images_upload_handler: uploadHandler,
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 Search } from './Search';
export { default as TinyMCEEditor } from './TinyMCEEditor';

View File

@@ -45,7 +45,6 @@ export const ContentActions = {
PIN: 'pinned',
ENDORSE: 'endorsed',
CLOSE: 'closed',
COPY_LINK: 'copy_link',
REPORT: 'abuse_flagged',
DELETE: 'delete',
FOLLOWING: 'following',
@@ -74,9 +73,9 @@ export const RequestStatus = {
* @readonly
* @enum {string}
*/
export const AvatarOutlineAndLabelColors = {
Staff: 'staff-color',
'Community TA': 'TA-color',
export const AvatarBorderAndLabelColors = {
Staff: 'warning-700',
'Community TA': 'success-700',
};
/**
@@ -135,6 +134,17 @@ export const LearnersOrdering = {
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.
* @type {{OPEN_EDX: string, LEGACY: string}}
@@ -152,7 +162,12 @@ export const Routes = {
},
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: {
PATH: `${BASE_PATH}/topics/:topicId`,
@@ -164,57 +179,38 @@ export const Routes = {
`${BASE_PATH}`,
],
EDIT_POST: [
`${BASE_PATH}/category/:category/posts/:postId/edit`,
`${BASE_PATH}/topics/:topicId/posts/:postId/edit`,
`${BASE_PATH}/posts/:postId/edit`,
`${BASE_PATH}/my-posts/:postId/edit`,
`${BASE_PATH}/learners/:learnerUsername/posts/:postId/edit`,
],
},
COMMENTS: {
PATH: [
`${BASE_PATH}/category/:category/posts/:postId`,
`${BASE_PATH}/topics/:topicId/posts/:postId`,
`${BASE_PATH}/posts/:postId`,
`${BASE_PATH}/my-posts/:postId`,
`${BASE_PATH}/learners/:learnerUsername/posts/:postId`,
],
PAGE: `${BASE_PATH}/:page`,
PAGES: {
category: `${BASE_PATH}/category/:category/posts/:postId`,
topics: `${BASE_PATH}/topics/:topicId/posts/:postId`,
posts: `${BASE_PATH}/posts/:postId`,
'my-posts': `${BASE_PATH}/my-posts/:postId`,
learners: `${BASE_PATH}/learners/:learnerUsername/posts/:postId`,
},
},
TOPICS: {
PATH: [
`${BASE_PATH}/topics/:topicId?`,
`${BASE_PATH}/category/:category`,
`${BASE_PATH}/topics`,
],
ALL: `${BASE_PATH}/topics`,
CATEGORY: `${BASE_PATH}/category/:category`,
CATEGORY_POST: `${BASE_PATH}/category/:category/posts/:postId`,
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 = []
.concat([Routes.TOPICS.CATEGORY_POST, Routes.TOPICS.CATEGORY])
.concat([Routes.TOPICS.CATEGORY])
.concat(Routes.COMMENTS.PATH)
.concat(Routes.TOPICS.PATH)
.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]);
export const MAX_UPLOAD_FILE_SIZE = 1024;

View File

@@ -8,7 +8,6 @@ import { initializeStore } from '../store';
import { executeThunk } from '../test-utils';
import { getBlocksAPIResponse } from './__factories__';
import { blocksAPIURL } from './api';
import { RequestStatus } from './constants';
import { fetchCourseBlocks } from './thunks';
const courseId = 'course-v1:edX+DemoX+Demo_Course';
@@ -36,7 +35,7 @@ describe('Course blocks data layer tests', () => {
axiosMock.reset();
});
it('successfully processes block data', async () => {
test('successfully processes block data', async () => {
axiosMock.onGet(blocksAPIURL)
.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])) || [],
);
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;

View File

@@ -58,21 +58,19 @@ function normaliseCourseBlocks({
} else {
blocks[verticalId].children?.forEach(discussionId => {
const discussion = camelCaseObject(blocks[discussionId]);
const { topicId } = discussion.studentViewData || {};
if (topicId) {
blockData[discussionId] = discussion;
// Add this topic id to the list of topics for the current chapter, sequential, and vertical
chapterData.topics.push(topicId);
blockData[sequentialId].topics.push(topicId);
blockData[verticalId].topics.push(topicId);
// Store the topic's context in the course in a map
topics[topicId] = {
chapterName: blockData[chapterId].displayName,
verticalName: blockData[sequentialId].displayName,
unitName: blockData[verticalId].displayName,
unitLink: blockData[verticalId].lmsWebUrl,
};
}
const { topicId } = discussion.studentViewData;
blockData[discussionId] = discussion;
// Add this topic id to the list of topics for the current chapter, sequential, and vertical
chapterData.topics.push(topicId);
blockData[sequentialId].topics.push(topicId);
blockData[verticalId].topics.push(topicId);
// Store the topic's context in the course in a map
topics[topicId] = {
chapterName: blockData[chapterId].displayName,
verticalName: blockData[sequentialId].displayName,
unitName: blockData[verticalId].displayName,
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 { useDispatch, useSelector } from 'react-redux';
import { useParams } from 'react-router';
import { ensureConfig, getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button, Spinner } from '@edx/paragon';
@@ -11,23 +12,27 @@ import { EndorsementStatus, ThreadType } from '../../data/constants';
import { useDispatchWithState } from '../../data/hooks';
import { Post } from '../posts';
import { selectThread } from '../posts/data/selectors';
import { fetchThread, markThreadAsRead } from '../posts/data/thunks';
import { filterPosts } from '../utils';
import { markThreadAsRead } from '../posts/data/thunks';
import { selectThreadComments, selectThreadCurrentPage, selectThreadHasMorePages } from './data/selectors';
import { fetchThreadComments } from './data/thunks';
import { Comment, ResponseEditor } from './comment';
import messages from './messages';
ensureConfig(['POST_MARK_AS_READ_DELAY'], 'Comment thread view');
function usePost(postId) {
const dispatch = useDispatch();
const thread = useSelector(selectThread(postId));
useEffect(() => {
if (thread && !thread.read) {
dispatch(markThreadAsRead(postId));
}
const markReadTimer = setTimeout(() => {
if (thread && !thread.read) {
dispatch(markThreadAsRead(postId));
}
}, getConfig().POST_MARK_AS_READ_DELAY);
return () => {
clearTimeout(markReadTimer);
};
}, [postId]);
return thread;
}
@@ -59,7 +64,6 @@ function DiscussionCommentsView({
postId,
intl,
endorsed,
isClosed,
}) {
const {
comments,
@@ -67,48 +71,41 @@ function DiscussionCommentsView({
isLoading,
handleLoadMoreResponses,
} = usePostComments(postId, endorsed);
const sortedComments = useMemo(() => [...filterPosts(comments, 'endorsed'),
...filterPosts(comments, 'unendorsed')], [comments]);
return (
<>
<div className="mx-4 text-primary-700" role="heading" aria-level="2" style={{ lineHeight: '28px' }}>
<div className="m-3">
<div className="my-3">
{endorsed === EndorsementStatus.ENDORSED
? intl.formatMessage(messages.endorsedResponseCount, { num: sortedComments.length })
: intl.formatMessage(messages.responseCount, { num: sortedComments.length })}
? intl.formatMessage(messages.endorsedResponseCount, { num: comments.length })
: intl.formatMessage(messages.responseCount, { num: comments.length })}
</div>
<div className="mx-4" role="list">
{sortedComments.map(comment => (
<Comment comment={comment} key={comment.id} postType={postType} isClosedPost={isClosed} />
))}
{!!sortedComments.length && !isClosed
&& <ResponseEditor postId={postId} addWrappingDiv />}
{hasMorePages && !isLoading && (
<Button
onClick={handleLoadMoreResponses}
variant="link"
block="true"
className="card p-4"
data-testid="load-more-comments"
>
{intl.formatMessage(messages.loadMoreResponses)}
</Button>
)}
{isLoading
{comments.map(comment => (
<Comment comment={comment} key={comment.id} postType={postType} />
))}
{hasMorePages && !isLoading && (
<Button
onClick={handleLoadMoreResponses}
variant="link"
block="true"
className="card p-4"
data-testid="load-more-comments"
>
{intl.formatMessage(messages.loadMoreResponses)}
</Button>
)}
{isLoading
&& (
<div className="card my-4 p-4 d-flex align-items-center">
<Spinner animation="border" variant="primary" />
</div>
)}
</div>
</>
</div>
);
}
DiscussionCommentsView.propTypes = {
postId: PropTypes.string.isRequired,
postType: PropTypes.string.isRequired,
isClosed: PropTypes.bool.isRequired,
intl: intlShape.isRequired,
endorsed: PropTypes.oneOf([
EndorsementStatus.ENDORSED, EndorsementStatus.UNENDORSED, EndorsementStatus.DISCUSSION,
@@ -118,18 +115,16 @@ DiscussionCommentsView.propTypes = {
function CommentsView({ intl }) {
const { postId } = useParams();
const thread = usePost(postId);
const dispatch = useDispatch();
if (!thread) {
dispatch(fetchThread(postId, true));
return (
<Spinner animation="border" variant="primary" data-testid="loading-indicator" />
);
}
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} />
{!thread.closed && <ResponseEditor postId={postId} /> }
<ResponseEditor postId={postId} />
</div>
{thread.type === ThreadType.DISCUSSION
&& (
@@ -138,7 +133,6 @@ function CommentsView({ intl }) {
intl={intl}
postType={thread.type}
endorsed={EndorsementStatus.DISCUSSION}
isClosed={thread.closed}
/>
)}
{thread.type === ThreadType.QUESTION && (
@@ -148,14 +142,12 @@ function CommentsView({ intl }) {
intl={intl}
postType={thread.type}
endorsed={EndorsementStatus.ENDORSED}
isClosed={thread.closed}
/>
<DiscussionCommentsView
postId={postId}
intl={intl}
postType={thread.type}
endorsed={EndorsementStatus.UNENDORSED}
isClosed={thread.closed}
/>
</>
)}

View File

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

View File

@@ -4,18 +4,16 @@ import PropTypes from 'prop-types';
import { useDispatch } from 'react-redux';
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 { editComment } from '../data/thunks';
function CommentIcons({
comment,
intl,
}) {
const dispatch = useDispatch();
timeago.register('time-locale', timeLocale);
const handleLike = () => dispatch(editComment(comment.id, { voted: !comment.voted }));
return (
<div className="d-flex flex-row align-items-center">
@@ -24,8 +22,8 @@ function CommentIcons({
onClick={handleLike}
voted={comment.voted}
/>
<div className="d-flex flex-fill text-gray-500 justify-content-end" title={comment.createdAt}>
{timeago.format(comment.createdAt, 'time-locale')}
<div className="d-flex flex-fill text-gray-500 justify-content-end mt-2" title={comment.createdAt}>
{timeago.format(comment.createdAt, intl.locale)}
</div>
</div>
);
@@ -39,6 +37,7 @@ CommentIcons.propTypes = {
voted: PropTypes.bool,
createdAt: PropTypes.string,
}).isRequired,
intl: intlShape.isRequired,
};
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 { Button, useToggle } from '@edx/paragon';
import HTMLLoader from '../../../components/HTMLLoader';
import { ContentActions } from '../../../data/constants';
import { AlertBanner, DeleteConfirmation, EndorsedAlertBanner } from '../../common';
import { selectBlackoutDate } from '../../data/selectors';
import { AlertBanner, DeleteConfirmation } from '../../common';
import { fetchThread } from '../../posts/data/thunks';
import { inBlackoutDateRange } from '../../utils';
import CommentIcons from '../comment-icons/CommentIcons';
import { selectCommentCurrentPage, selectCommentHasMorePages, selectCommentResponses } from '../data/selectors';
import { editComment, fetchCommentResponses, removeComment } from '../data/thunks';
@@ -26,7 +23,6 @@ function Comment({
postType,
comment,
showFullThread = true,
isClosedPost,
intl,
}) {
const dispatch = useDispatch();
@@ -38,110 +34,93 @@ function Comment({
const [isReplying, setReplying] = useState(false);
const hasMorePages = useSelector(selectCommentHasMorePages(comment.id));
const currentPage = useSelector(selectCommentCurrentPage(comment.id));
const blackoutDateRange = useSelector(selectBlackoutDate);
useEffect(() => {
// If the comment has a parent comment, it won't have any children, so don't fetch them.
if (hasChildren && !currentPage && showFullThread) {
dispatch(fetchCommentResponses(comment.id, { page: 1 }));
}
}, [comment.id]);
const actionHandlers = {
[ContentActions.EDIT_CONTENT]: () => setEditing(true),
[ContentActions.ENDORSE]: async () => {
await dispatch(editComment(comment.id, { endorsed: !comment.endorsed }, ContentActions.ENDORSE));
await dispatch(editComment(comment.id, { endorsed: !comment.endorsed }));
await dispatch(fetchThread(comment.threadId));
},
[ContentActions.DELETE]: showDeleteConfirmation,
[ContentActions.REPORT]: () => dispatch(editComment(comment.id, { flagged: !comment.abuseFlagged })),
};
const handleLoadMoreComments = () => (
dispatch(fetchCommentResponses(comment.id, { page: currentPage + 1 }))
);
const commentClasses = classNames('d-flex flex-column card', { 'my-3': showFullThread });
return (
<div className={classNames({ 'py-2 my-3': showFullThread })}>
<div className="d-flex flex-column card" data-testid={`comment-${comment.id}`} role="listitem">
<DeleteConfirmation
isOpen={isDeleting}
title={intl.formatMessage(messages.deleteResponseTitle)}
description={intl.formatMessage(messages.deleteResponseDescription)}
onClose={hideDeleteConfirmation}
onDelete={() => {
dispatch(removeComment(comment.id));
hideDeleteConfirmation();
}}
<div className={commentClasses} data-testid={`comment-${comment.id}`}>
<DeleteConfirmation
isOpen={isDeleting}
title={intl.formatMessage(messages.deleteResponseTitle)}
description={intl.formatMessage(messages.deleteResponseDescription)}
onClose={hideDeleteConfirmation}
onDelete={() => {
dispatch(removeComment(comment.id));
hideDeleteConfirmation();
}}
/>
<AlertBanner postType={postType} content={comment} />
<div className="d-flex flex-column p-4">
<CommentHeader comment={comment} actionHandlers={actionHandlers} postType={postType} />
{isEditing
? (
<CommentEditor comment={comment} onCloseEditor={() => setEditing(false)} />
)
// eslint-disable-next-line react/no-danger
: <div className="comment-body px-2" dangerouslySetInnerHTML={{ __html: comment.renderedBody }} />}
<CommentIcons
comment={comment}
following={comment.following}
onLike={() => dispatch(editComment(comment.id, { voted: !comment.voted }))}
createdAt={comment.createdAt}
/>
<EndorsedAlertBanner postType={postType} content={comment} />
<div className="d-flex flex-column p-4.5">
<AlertBanner content={comment} />
<CommentHeader comment={comment} actionHandlers={actionHandlers} postType={postType} />
{isEditing
? (
<CommentEditor comment={comment} onCloseEditor={() => setEditing(false)} />
)
: <HTMLLoader cssClassName="comment-body pt-4 text-primary-500" componentId="comment" htmlNode={comment.renderedBody} />}
<CommentIcons
comment={comment}
following={comment.following}
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 && (
<div className="d-flex my-2 flex-column">
{/* Pass along intl since component used here is the one before it's injected with `injectIntl` */}
{inlineReplies.map(inlineReply => (
<Reply
reply={inlineReply}
postType={postType}
key={inlineReply.id}
intl={intl}
/>
))}
</div>
{hasMorePages && (
<Button
onClick={handleLoadMoreComments}
variant="link"
block="true"
className="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"
style={{
lineHeight: '20px',
}}
>
{intl.formatMessage(messages.loadMoreResponses)}
</Button>
)}
{!isNested && showFullThread
&& (
isReplying
? (
<CommentEditor
comment={{
threadId: comment.threadId,
parentId: comment.id,
}}
onCloseEditor={() => setReplying(false)}
/>
)
: (
<Button className="d-flex flex-grow " variant="outline-secondary" onClick={() => setReplying(true)}>
{intl.formatMessage(messages.addComment)}
</Button>
)
)}
{!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>
);
@@ -151,13 +130,11 @@ Comment.propTypes = {
postType: PropTypes.oneOf(['discussion', 'question']).isRequired,
comment: commentShape.isRequired,
showFullThread: PropTypes.bool,
isClosedPost: PropTypes.bool,
intl: intlShape.isRequired,
};
Comment.defaultProps = {
showFullThread: true,
isClosedPost: false,
};
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 { TinyMCEEditor } from '../../../components';
import FormikErrorFeedback from '../../../components/FormikErrorFeedback';
import PostPreviewPane from '../../../components/PostPreviewPane';
import { useDispatchWithState } from '../../../data/hooks';
import {
selectModerationSettings,
selectUserHasModerationPrivileges,
selectUserIsGroupTa,
} from '../../data/selectors';
import { selectModerationSettings, selectUserIsPrivileged } from '../../data/selectors';
import { formikCompatibleHandler, isFormikFieldInvalid } from '../../utils';
import { addComment, editComment } from '../data/thunks';
import messages from '../messages';
@@ -26,46 +20,15 @@ function CommentEditor({
intl,
comment,
onCloseEditor,
edit,
}) {
const editorRef = useRef(null);
const { authenticatedUser } = useContext(AppContext);
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
const userIsGroupTa = useSelector(selectUserIsGroupTa);
const userIsPrivileged = useSelector(selectUserIsPrivileged);
const { reasonCodesEnabled, editReasons } = useSelector(selectModerationSettings);
const [submitting, dispatch] = useDispatchWithState();
const canDisplayEditReason = (reasonCodesEnabled && (userHasModerationPrivileges || userIsGroupTa)
&& 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 }) => {
const editorRef = useRef(null);
const saveUpdatedComment = async (values) => {
if (comment.id) {
const payload = {
...values,
editReasonCode: values.editReasonCode || undefined,
};
await dispatch(editComment(comment.id, payload));
await dispatch(editComment(comment.id, values));
} else {
await dispatch(addComment(values.comment, comment.threadId, comment.parentId));
}
@@ -73,16 +36,22 @@ function CommentEditor({
if (editorRef.current) {
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 current comment id, or the current comment parent or the curren thread.
const editorId = `comment-editor-${comment.id || comment.parentId || comment.threadId}`;
return (
<Formik
initialValues={initialValues}
validationSchema={validationSchema}
initialValues={{ comment: comment.rawBody }}
validationSchema={Yup.object()
.shape({
comment: Yup.string()
.required(),
editReasonCode: Yup.string()
.nullable()
.default(undefined),
})}
onSubmit={saveUpdatedComment}
>
{({
@@ -92,16 +61,12 @@ function CommentEditor({
handleSubmit,
handleBlur,
handleChange,
resetForm,
}) => (
<Form onSubmit={handleSubmit}>
{canDisplayEditReason && (
<Form.Group
isInvalid={isFormikFieldInvalid('editReasonCode', {
errors,
touched,
})}
>
{(reasonCodesEnabled
&& userIsPrivileged
&& comment.author !== authenticatedUser.username) && (
<Form.Group>
<Form.Control
name="editReasonCode"
className="mt-2"
@@ -120,7 +85,6 @@ function CommentEditor({
<option key={code} value={code}>{label}</option>
))}
</Form.Control>
<FormikErrorFeedback name="editReasonCode" />
</Form.Group>
)}
<TinyMCEEditor
@@ -144,11 +108,10 @@ function CommentEditor({
{intl.formatMessage(messages.commentError)}
</Form.Control.Feedback>
)}
<PostPreviewPane htmlNode={values.comment} />
<div className="d-flex py-2 justify-content-end">
<Button
variant="outline-primary"
onClick={() => handleCloseEditor(resetForm)}
onClick={onCloseEditor}
>
{intl.formatMessage(messages.cancel)}
</Button>
@@ -176,15 +139,9 @@ CommentEditor.propTypes = {
parentId: PropTypes.string,
rawBody: PropTypes.string,
author: PropTypes.string,
lastEdit: PropTypes.object,
}).isRequired,
onCloseEditor: PropTypes.func.isRequired,
intl: intlShape.isRequired,
edit: PropTypes.bool,
};
CommentEditor.defaultProps = {
edit: true,
};
export default injectIntl(CommentEditor);

View File

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

View File

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

View File

@@ -7,13 +7,10 @@ import * as timeago from 'timeago.js';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Avatar, useToggle } from '@edx/paragon';
import HTMLLoader from '../../../components/HTMLLoader';
import { AvatarOutlineAndLabelColors, ContentActions } from '../../../data/constants';
import { AvatarBorderAndLabelColors, ContentActions } from '../../../data/constants';
import {
ActionsDropdown, AlertBanner, AuthorLabel, DeleteConfirmation,
} from '../../common';
import timeLocale from '../../common/time-locale';
import { useAlertBannerVisible } from '../../data/hooks';
import { selectAuthorAvatars } from '../../posts/data/selectors';
import { editComment, removeComment } from '../data/thunks';
import messages from '../messages';
@@ -25,26 +22,19 @@ function Reply({
postType,
intl,
}) {
timeago.register('time-locale', timeLocale);
const dispatch = useDispatch();
const [isEditing, setEditing] = useState(false);
const [isDeleting, showDeleteConfirmation, hideDeleteConfirmation] = useToggle(false);
const actionHandlers = {
[ContentActions.EDIT_CONTENT]: () => setEditing(true),
[ContentActions.ENDORSE]: () => dispatch(editComment(
reply.id,
{ endorsed: !reply.endorsed },
ContentActions.ENDORSE,
)),
[ContentActions.ENDORSE]: () => dispatch(editComment(reply.id, { endorsed: !reply.endorsed })),
[ContentActions.DELETE]: showDeleteConfirmation,
[ContentActions.REPORT]: () => dispatch(editComment(reply.id, { flagged: !reply.abuseFlagged })),
};
const authorAvatars = useSelector(selectAuthorAvatars(reply.author));
const colorClass = AvatarOutlineAndLabelColors[reply.authorLabel];
const hasAnyAlert = useAlertBannerVisible(reply);
const colorClass = AvatarBorderAndLabelColors[reply.authorLabel];
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
isOpen={isDeleting}
title={intl.formatMessage(messages.deleteCommentTitle)}
@@ -55,36 +45,22 @@ function Reply({
hideDeleteConfirmation();
}}
/>
{hasAnyAlert && (
<div className="d-flex">
<div className="d-flex invisible">
<Avatar />
</div>
<div className="w-100">
<AlertBanner content={reply} intl={intl} />
</div>
</div>
)}
<div className="d-flex flex-fill ml-6">
<AlertBanner postType={null} content={reply} intl={intl} />
</div>
<div className="d-flex">
<div className="d-flex mr-3 mt-2.5">
<div className="d-flex m-3">
<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}
src={authorAvatars?.imageUrlSmall}
style={{
width: '32px',
height: '32px',
}}
/>
</div>
<div
className="bg-light-300 px-4 pb-2 pt-2.5 flex-fill"
style={{ borderRadius: '0rem 0.375rem 0.375rem' }}
>
<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 />
<div className="rounded bg-light-300 px-4 py-2 flex-fill">
<div className="d-flex flex-row justify-content-between align-items-center">
<AuthorLabel author={reply.author} authorLabel={reply.authorLabel} labelColor={colorClass && `text-${colorClass}`} />
<ActionsDropdown
commentOrPost={{
...reply,
@@ -95,11 +71,13 @@ function Reply({
</div>
{isEditing
? <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 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>
);

View File

@@ -1,43 +1,23 @@
import React, { useEffect, useState } from 'react';
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button } from '@edx/paragon';
import { selectBlackoutDate } from '../../data/selectors';
import { inBlackoutDateRange } from '../../utils';
import messages from '../messages';
import CommentEditor from './CommentEditor';
function ResponseEditor({
postId,
intl,
addWrappingDiv,
}) {
const [addingResponse, setAddingResponse] = useState(false);
useEffect(() => {
setAddingResponse(false);
}, [postId]);
const blackoutDateRange = useSelector(selectBlackoutDate);
return addingResponse
? (
<div className={classNames({ 'bg-white p-4 mb-4 rounded': addWrappingDiv })}>
<CommentEditor
comment={{ threadId: postId }}
edit={false}
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)}>
<CommentEditor comment={{ threadId: postId }} onCloseEditor={() => setAddingResponse(false)} />
) : (
<div className="actions d-flex">
<Button variant="primary" onClick={() => setAddingResponse(true)}>
{intl.formatMessage(messages.addResponse)}
</Button>
</div>
@@ -47,11 +27,6 @@ function ResponseEditor({
ResponseEditor.propTypes = {
postId: PropTypes.string.isRequired,
intl: intlShape.isRequired,
addWrappingDiv: PropTypes.bool,
};
ResponseEditor.defaultProps = {
addWrappingDiv: false,
};
export default injectIntl(ResponseEditor);

View File

@@ -117,3 +117,28 @@ export async function deleteComment(commentId) {
await getAuthenticatedHttpClient()
.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.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) => {
state.postStatus = RequestStatus.IN_PROGRESS;
},
@@ -201,7 +189,6 @@ export const {
updateCommentFailed,
updateCommentRequest,
updateCommentSuccess,
updateCommentsList,
deleteCommentDenied,
deleteCommentFailed,
deleteCommentRequest,

View File

@@ -2,7 +2,7 @@
import { camelCaseObject } from '@edx/frontend-platform';
import { logError } from '@edx/frontend-platform/logging';
import { ContentActions, EndorsementStatus } from '../../../data/constants';
import { EndorsementStatus } from '../../../data/constants';
import { getHttpErrorStatus } from '../../utils';
import {
deleteComment, getCommentResponses, getThreadComments, postComment, updateComment,
@@ -27,7 +27,6 @@ import {
updateCommentDenied,
updateCommentFailed,
updateCommentRequest,
updateCommentsList,
updateCommentSuccess,
} 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) => {
try {
dispatch(updateCommentRequest({ commentId }));
const data = await updateComment(commentId, comment);
dispatch(updateCommentSuccess(camelCaseObject(data)));
if (action === ContentActions.ENDORSE) {
dispatch(updateCommentsList(camelCaseObject(data)));
}
} catch (error) {
if (getHttpErrorStatus(error) === 403) {
dispatch(updateCommentDenied());

View File

@@ -26,7 +26,7 @@ const messages = defineMessages({
},
endorsedResponseCount: {
id: 'discussions.comments.comment.endorsedResponseCount',
defaultMessage: `{num, plural,
defaultMessage: `{num, plural,
=0 {No endorsed responses}
one {Showing # endorsed response}
other {Showing # endorsed responses}
@@ -55,7 +55,6 @@ const messages = defineMessages({
defaultMessage: `{postType, select,
discussion {Discussion}
question {Question}
other {{postType}}
} posted {relativeTime} by`,
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',
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: {
id: 'discussions.comment.comments.editedBy',
defaultMessage: 'Edited by',
@@ -167,16 +161,6 @@ const messages = defineMessages({
id: 'discussions.post.closedBy',
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;

View File

@@ -1,8 +1,6 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { logError } from '@edx/frontend-platform/logging';
import {
@@ -12,10 +10,9 @@ import { MoreHoriz } from '@edx/paragon/icons';
import { ContentActions } from '../../data/constants';
import { commentShape } from '../comments/comment/proptypes';
import { selectBlackoutDate } from '../data/selectors';
import messages from '../messages';
import { postShape } from '../posts/post/proptypes';
import { inBlackoutDateRange, useActions } from '../utils';
import { useActions } from '../utils';
function ActionsDropdown({
intl,
@@ -34,22 +31,17 @@ function ActionsDropdown({
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 (
<>
<IconButton
onClick={() => setOpen(!isOpen)}
alt={intl.formatMessage(messages.actionsAlt)}
src={MoreHoriz}
iconAs={Icon}
disabled={disabled}
size="sm"
ref={dropdownIconRef}
/>
<span ref={dropdownIconRef}>
<IconButton
onClick={() => setOpen(!isOpen)}
alt={intl.formatMessage(messages.actionsAlt)}
src={MoreHoriz}
iconAs={Icon}
disabled={disabled}
/>
</span>
<ModalPopup
onClose={() => setOpen(false)}
positionRef={dropdownIconRef}

View File

@@ -9,7 +9,6 @@ import { camelCaseObject, initializeMockApp, snakeCaseObject } from '@edx/fronte
import { AppProvider } from '@edx/frontend-platform/react';
import { ContentActions } from '../../data/constants';
import { initializeStore } from '../../store';
import messages from '../messages';
import { ACTIONS_LIST } from '../utils';
import ActionsDropdown from './ActionsDropdown';
@@ -127,7 +126,6 @@ describe('ActionsDropdown', () => {
roles: [],
},
});
store = initializeStore();
});
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());
});
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', ({
testFor, action, label, reason, ...commentOrPost
}) => {

View File

@@ -2,63 +2,86 @@ import React from 'react';
import PropTypes from 'prop-types';
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 { 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 messages from '../comments/messages';
import { selectModerationSettings, selectUserHasModerationPrivileges, selectUserIsGroupTa } from '../data/selectors';
import { selectModerationSettings, selectUserIsPrivileged } from '../data/selectors';
import { postShape } from '../posts/post/proptypes';
import AuthorLabel from './AuthorLabel';
function AlertBanner({
intl,
content,
postType,
}) {
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
const userIsGroupTa = useSelector(selectUserIsGroupTa);
const isQuestion = postType === ThreadType.QUESTION;
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 userIsContentAuthor = getAuthenticatedUser().username === content.author;
const canSeeLastEditOrClosedAlert = (userHasModerationPrivileges || userIsContentAuthor || userIsGroupTa);
const isReportedByCurrentUser = getAuthenticatedUser().username === content?.abuseFlaggedBy;
const canSeeReportedBanner = (userHasModerationPrivileges || userIsGroupTa || isReportedByCurrentUser);
return (
<>
{content.abuseFlagged && canSeeReportedBanner && (
<Alert icon={Error} variant="danger" className="px-3 mb-2 py-10px shadow-none flex-fill">
{content.endorsed && (
<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)}
</Alert>
)}
{reasonCodesEnabled && canSeeLastEditOrClosedAlert && (
<>
{content.lastEdit?.reason && (
<Alert variant="info" className="px-3 shadow-none mb-2 py-10px bg-light-200">
<div className="d-flex align-items-center flex-wrap">
{intl.formatMessage(messages.editedBy)}
<span className="ml-1 mr-3">
<AuthorLabel author={content.lastEdit.editorUsername} linkToProfile />
</span>
{intl.formatMessage(messages.reason)}:&nbsp;{content.lastEdit.reason}
</div>
</Alert>
)}
{content.closed && (
<Alert variant="info" className="px-3 shadow-none mb-2 py-10px bg-light-200">
<div className="d-flex align-items-center flex-wrap">
{intl.formatMessage(messages.closedBy)}
<span className="ml-1 ">
<AuthorLabel author={content.closedBy} linkToProfile />
</span>
<span className="mx-1" />
{content.closeReason && (`${intl.formatMessage(messages.reason)}: ${content.closeReason}`)}
</div>
</Alert>
)}
</>
{reasonCodesEnabled && userIsPrivileged && content.lastEdit?.reason && (
<Alert variant="info" className="p-3 m-0 shadow-none mb-1 bg-light-200">
<div className="d-flex align-items-center">
{intl.formatMessage(messages.editedBy)}
<span className="ml-1 mr-3">
<AuthorLabel author={content.lastEdit.editorUsername} linkToProfile />
</span>
{intl.formatMessage(messages.reason)}:&nbsp;{content.lastEdit.reason}
</div>
</Alert>
)}
{reasonCodesEnabled && content.closed && (
<Alert variant="info" className="p-3 m-0 shadow-none mb-1 bg-light-200">
<div className="d-flex align-items-center">
{intl.formatMessage(messages.closedBy)}
<span className="ml-1 ">
<AuthorLabel author={content.closedBy} linkToProfile />
</span>
<span className="mx-1" />
{intl.formatMessage(messages.reason)}:&nbsp;{content.closeReason}
</div>
</Alert>
)}
</>
);
@@ -67,6 +90,11 @@ function AlertBanner({
AlertBanner.propTypes = {
intl: intlShape.isRequired,
content: PropTypes.oneOfType([commentShape.isRequired, postShape.isRequired]).isRequired,
postType: PropTypes.string,
};
AlertBanner.defaultProps = {
postType: null,
};
export default injectIntl(AlertBanner);

View File

@@ -9,7 +9,6 @@ import { ThreadType } from '../../data/constants';
import { initializeStore } from '../../store';
import messages from '../comments/messages';
import AlertBanner from './AlertBanner';
import { DiscussionContext } from './context';
import '../comments/data/__factories__';
import '../posts/data/__factories__';
@@ -23,17 +22,15 @@ function buildTestContent(type, buildParams) {
function renderComponent(
content,
postType,
) {
render(
<IntlProvider locale="en">
<AppProvider store={store}>
<DiscussionContext.Provider
value={{ courseId: 'course-v1:edX+TestX+Test_Course' }}
>
<AlertBanner
content={content}
/>
</DiscussionContext.Provider>
<AlertBanner
content={content}
postType={postType}
/>
</AppProvider>
</IntlProvider>,
);
@@ -47,6 +44,27 @@ describe.each([
props: { abuseFlagged: true },
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',
type: 'thread',
@@ -58,7 +76,7 @@ describe.each([
label: 'edited content',
type: 'thread',
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'],
},
{
@@ -69,7 +87,7 @@ describe.each([
expectText: [messages.closedBy.defaultMessage, 'closing-user', 'test-close-reason'],
},
])('AlertBanner', ({
label, type, props, expectText,
label, type, postType, props, expectText,
}) => {
beforeEach(async () => {
initializeMockApp({
@@ -82,12 +100,12 @@ describe.each([
});
store = initializeStore({
config: {
hasModerationPrivileges: true,
userIsPrivileged: true,
reasonCodesEnabled: true,
},
});
const content = buildTestContent(type, props);
renderComponent(content);
renderComponent(content, postType);
});
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 classNames from 'classnames';
import { Link, useLocation } from 'react-router-dom';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Icon } from '@edx/paragon';
import { Institution, School } from '@edx/paragon/icons';
import { Routes } from '../../data/constants';
import { useShowLearnersTab } from '../data/hooks';
import messages from '../messages';
import { discussionsPath } from '../utils';
import { DiscussionContext } from './context';
function AuthorLabel({
intl,
@@ -21,11 +16,8 @@ function AuthorLabel({
linkToProfile,
labelColor,
}) {
const location = useLocation();
const { courseId } = useContext(DiscussionContext);
let icon = null;
let authorLabelMessage = null;
if (authorLabel === 'Staff') {
icon = Institution;
authorLabelMessage = intl.formatMessage(messages.authorLabelStaff);
@@ -34,26 +26,9 @@ function AuthorLabel({
icon = School;
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 = (
<div className={className}>
<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>
<>
<span className="mr-1">{author}</span>
{icon && (
<Icon
style={{
@@ -64,35 +39,20 @@ function AuthorLabel({
/>
)}
{authorLabelMessage && (
<span
className={classNames('mr-3 font-size-14 font-style-normal font-family-inter font-weight-500', {
'text-primary-500': !authorLabelMessage,
})}
style={{ marginLeft: '2px' }}
>
<span className="mr-3 ml-1">
{authorLabelMessage}
</span>
)}
</div>
</>
);
return showUserNameAsLink
? (
<Link
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}</>;
const className = classNames('d-flex align-items-center', labelColor);
return linkToProfile
? React.createElement('a', { href: '#nowhere', className }, labelContents)
: React.createElement('div', { className }, labelContents);
}
AuthorLabel.propTypes = {
intl: intlShape.isRequired,
intl: intlShape,
author: PropTypes.string.isRequired,
authorLabel: PropTypes.string,
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';
export const DiscussionContext = React.createContext({
page: null,
courseId: null,
postId: null,
topicId: null,
inContext: false,
category: null,
commentId: 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 AuthorLabel } from './AuthorLabel';
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({
allow_anonymous: 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 */
import {
useContext, useEffect, useRef, useState,
} from 'react';
import { useContext, useEffect, useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useHistory, useLocation, useRouteMatch } from 'react-router';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { AppContext } from '@edx/frontend-platform/react';
import { breakpoints, useWindowSize } from '@edx/paragon';
@@ -15,14 +12,8 @@ import { fetchCourseBlocks } from '../../data/thunks';
import { clearRedirect } from '../posts/data';
import { selectTopics } from '../topics/data/selectors';
import { fetchCourseTopics } from '../topics/data/thunks';
import { discussionsPath } from '../utils';
import {
selectAreThreadsFiltered, selectLearnersTabEnabled,
selectModerationSettings,
selectPostThreadCount,
selectUserHasModerationPrivileges,
selectUserIsGroupTa, selectUserIsStaff, selectUserRoles,
} from './selectors';
import { discussionsPath, postMessageToParent } from '../utils';
import { selectAreThreadsFiltered, selectPostThreadCount } from './selectors';
import { fetchCourseConfig } from './thunks';
export function useTotalTopicThreadCount() {
@@ -93,68 +84,60 @@ export function useRedirectToThread(courseId) {
}
export function useIsOnDesktop() {
return window.outerWidth >= breakpoints.large.minWidth;
const windowSize = useWindowSize();
return windowSize.width >= breakpoints.large.minWidth;
}
export function useIsOnXLDesktop() {
const windowSize = useWindowSize();
return windowSize.width >= breakpoints.extraLarge.minWidth;
/**
* Given an element this attempts to get the height of the entire UI.
*
* @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
* @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 [height, setHeight] = useState();
const enabled = window.parent !== window;
const resizeObserver = useRef(new ResizeObserver(() => {
/* istanbul ignore if: ResizeObserver isn't available in the testing env */
if (refContainer?.current) {
setHeight(refContainer?.current?.clientHeight);
if (refContainer.current) {
postResizeMessage(getOuterHeight(refContainer.current));
}
}));
useEffect(() => {
const container = refContainer?.current;
const observer = resizeObserver?.current;
if (container && observer) {
const container = refContainer.current;
const observer = resizeObserver.current;
if (container && observer && enabled) {
observer.observe(container);
setHeight(container.clientHeight);
postResizeMessage(getOuterHeight(container));
}
return () => {
if (container && observer) {
if (container && observer && enabled) {
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]);
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,
});
export const selectUserHasModerationPrivileges = state => state.config.hasModerationPrivileges;
export const selectUserIsStaff = state => state.config.isUserAdmin;
export const selectUserIsGroupTa = state => state.config.isGroupTa;
export const selectUserIsPrivileged = state => state.config.userIsPrivileged;
export const selectconfigLoadingStatus = state => state.config.status;
export const selectLearnersTabEnabled = state => state.config.learnersTabEnabled;
export const selectUserRoles = state => state.config.userRoles;
export const selectDivisionSettings = state => state.config.settings;
export const selectBlackoutDate = state => state.config.blackouts;
export const selectModerationSettings = state => ({
postCloseReasons: state.config.postCloseReasons,
editReasons: state.config.editReasons,

View File

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

View File

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

View File

@@ -1,46 +1,22 @@
import React, { useContext } from 'react';
import React, { useRef } from 'react';
import { useSelector } from 'react-redux';
import { Route, Switch } from 'react-router';
import { useHistory, useLocation } from 'react-router-dom';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Icon, IconButton } from '@edx/paragon';
import { ArrowBack } from '@edx/paragon/icons';
import { PostsPages, Routes } from '../../data/constants';
import { Routes } from '../../data/constants';
import { CommentsView } from '../comments';
import { DiscussionContext } from '../common/context';
import { useIsOnDesktop } from '../data/hooks';
import messages from '../messages';
import { useContainerSizeForParent } from '../data/hooks';
import { LearnersContentView } from '../learners';
import { PostEditor } from '../posts';
import { discussionsPath } from '../utils';
function DiscussionContent({ intl }) {
const location = useLocation();
const history = useHistory();
export default function DiscussionContent() {
const refContainer = useRef(null);
const postEditorVisible = useSelector((state) => state.threads.postEditorVisible);
const isOnDesktop = useIsOnDesktop();
const {
courseId, learnerUsername, category, topicId, page,
} = useContext(DiscussionContext);
useContainerSizeForParent(refContainer);
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 flex-column w-100">
{!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)}
/>
)}
<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 mw-xl" ref={refContainer}>
{postEditorVisible ? (
<Route path={Routes.POSTS.NEW_POST}>
<PostEditor />
@@ -53,15 +29,12 @@ function DiscussionContent({ intl }) {
<Route path={Routes.COMMENTS.PATH}>
<CommentsView />
</Route>
<Route path={Routes.LEARNERS.LEARNER}>
<LearnersContentView />
</Route>
</Switch>
)}
</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 classNames from 'classnames';
import { useSelector } from 'react-redux';
import {
Redirect, Route, Switch, useLocation,
} from 'react-router';
import { RequestStatus, Routes } from '../../data/constants';
import {
useContainerSize, useIsOnDesktop, useIsOnXLDesktop, useShowLearnersTab,
} from '../data/hooks';
import { selectconfigLoadingStatus } from '../data/selectors';
import { LearnerPostsView, LearnersView } from '../learners';
import { Routes } from '../../data/constants';
import { LearnersView } from '../learners';
import { PostsView } from '../posts';
import { TopicsView } from '../topics';
export default function DiscussionSidebar({ displaySidebar, postActionBarRef }) {
export default function DiscussionSidebar({ displaySidebar }) {
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 (
<div
ref={sidebarRef}
className={classNames('flex-column min-content-height position-sticky', {
className={classNames('flex-column', {
'd-none': !displaySidebar,
'd-flex overflow-auto': displaySidebar,
'w-100': !isOnDesktop,
'sidebar-desktop-width': isOnDesktop && !isOnXLDesktop,
'w-25 sidebar-XL-width': isOnXLDesktop,
'd-flex w-25 w-xs-100 w-lg-25 overflow-auto h-100 pb-2': displaySidebar,
})}
style={{ minWidth: '30rem' }}
data-testid="sidebar"
>
<Switch>
<Route path={Routes.POSTS.MY_POSTS}>
<PostsView showOwnPosts />
</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}
/>
<Route path={Routes.TOPICS.PATH} component={TopicsView} />
{redirectToLearnersTab && (
<Route path={Routes.LEARNERS.POSTS} component={LearnerPostsView} />
)}
{redirectToLearnersTab && (
<Route path={Routes.LEARNERS.PATH} component={LearnersView} />
)}
{configStatus === RequestStatus.SUCCESSFUL && (
<Redirect
from={Routes.DISCUSSIONS.PATH}
to={{
@@ -68,7 +40,6 @@ export default function DiscussionSidebar({ displaySidebar, postActionBarRef })
pathname: Routes.POSTS.ALL_POSTS,
}}
/>
)}
</Switch>
</div>
);
@@ -76,13 +47,8 @@ export default function DiscussionSidebar({ displaySidebar, postActionBarRef })
DiscussionSidebar.defaultProps = {
displaySidebar: false,
postActionBarRef: null,
};
DiscussionSidebar.propTypes = {
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 { initializeStore } from '../../store';
import { DiscussionContext } from '../common/context';
import { fetchConfigSuccess } from '../data/slices';
import { threadsApiUrl } from '../posts/data/api';
import DiscussionSidebar from './DiscussionSidebar';
@@ -28,11 +26,9 @@ function renderComponent(displaySidebar = true, location = `/${courseId}/`) {
<IntlProvider locale="en">
<ResponsiveContext.Provider value={{ width: 1280 }}>
<AppProvider store={store}>
<DiscussionContext.Provider value={{ courseId }}>
<MemoryRouter initialEntries={[location]}>
<DiscussionSidebar displaySidebar={displaySidebar} postActionBarRef={null} />
</MemoryRouter>
</DiscussionContext.Provider>
<MemoryRouter initialEntries={[location]}>
<DiscussionSidebar displaySidebar={displaySidebar} />
</MemoryRouter>
</AppProvider>
</ResponsiveContext.Provider>
</IntlProvider>,
@@ -55,7 +51,6 @@ describe('DiscussionSidebar', () => {
store = initializeStore({
blocks: { blocks: { 'test-usage-key': { topics: ['some-topic-2', 'some-topic-0'] } } },
});
store.dispatch(fetchConfigSuccess({}));
Factory.resetAll();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
});
@@ -78,10 +73,7 @@ describe('DiscussionSidebar', () => {
test('User will be redirected to "All Posts" by default', async () => {
axiosMock.onGet(threadsApiUrl)
.reply(({ params }) => [200, Factory.build('threadsResult', {}, {
threadAttrs: {
title: `Thread by ${params.author || 'other users'}`,
previewBody: 'thread preview body',
},
threadAttrs: { title: `Thread by ${params.author || 'other users'}` },
})]);
renderComponent();
await act(async () => expect(await screen.findAllByText('Thread by other users')).toBeTruthy());
@@ -93,10 +85,7 @@ describe('DiscussionSidebar', () => {
axiosMock.onGet(threadsApiUrl)
.reply(({ params }) => [200, Factory.build('threadsResult', {}, {
count: postCount,
threadAttrs: {
title: `Thread by ${params.author || 'other users'}`,
previewBody: 'thread preview body',
},
threadAttrs: { title: `Thread by ${params.author || 'other users'}` },
})]);
renderComponent();
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 {
Route, Switch, useLocation, useRouteMatch,
} from 'react-router';
import Footer from '@edx/frontend-component-footer';
import { LearningHeader as Header } from '@edx/frontend-component-header';
import { PostActionsBar } from '../../components';
import { CourseTabsNavigation } from '../../components/NavigationBar';
import { ALL_ROUTES, DiscussionProvider, Routes } from '../../data/constants';
import { DiscussionContext } from '../common/context';
import {
useCourseDiscussionData, useIsOnDesktop, useRedirectToThread, useShowLearnersTab, useSidebarVisible,
useCourseDiscussionData, useIsOnDesktop, useRedirectToThread, useSidebarVisible,
} from '../data/hooks';
import { selectDiscussionProvider } from '../data/selectors';
import { EmptyLearners, EmptyPosts, EmptyTopics } from '../empty-posts';
import { EmptyPosts, EmptyTopics } from '../empty-posts';
import messages from '../messages';
import { BreadcrumbMenu, LegacyBreadcrumbMenu, NavigationBar } from '../navigation';
import { postMessageToParent } from '../utils';
import DiscussionContent from './DiscussionContent';
import DiscussionSidebar from './DiscussionSidebar';
import InformationBanner from './InformationsBanner';
export default function DiscussionsHome() {
const location = useLocation();
const postActionBarRef = useRef(null);
const postEditorVisible = useSelector(
(state) => state.threads.postEditorVisible,
);
const {
params: { page },
} = useRouteMatch(`${Routes.COMMENTS.PAGE}?`);
const { params: { path } } = useRouteMatch(`${Routes.DISCUSSIONS.PATH}/:path*`);
const { params } = useRouteMatch(ALL_ROUTES);
const isRedirectToLearners = useShowLearnersTab();
const {
courseId,
postId,
@@ -46,16 +37,14 @@ export default function DiscussionsHome() {
learnerUsername,
} = params;
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.
const displayContentArea = postId || postEditorVisible || (learnerUsername && postId);
const displayContentArea = postId || postEditorVisible || learnerUsername;
let displaySidebar = useSidebarVisible();
const isOnDesktop = useIsOnDesktop();
const { courseNumber, courseTitle, org } = useSelector(
(state) => state.courseTabs,
);
if (displayContentArea) {
// 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.
@@ -82,27 +71,22 @@ export default function DiscussionsHome() {
learnerUsername,
}}
>
{!inIframe && <Header courseOrg={org} courseNumber={courseNumber} courseTitle={courseTitle} />}
<main className="container-fluid d-flex flex-column p-0 w-100" id="main" tabIndex="-1">
{!inIframe
&& <CourseTabsNavigation activeTab="discussion" courseId={courseId} />}
<div className="header-action-bar" ref={postActionBarRef}>
<div
className="d-flex flex-row justify-content-between navbar fixed-top"
>
{!inContext && (
<Route path={Routes.DISCUSSIONS.PATH} component={NavigationBar} />
)}
<PostActionsBar inContext={inContext} />
</div>
<InformationBanner />
<main className="container-fluid d-flex flex-column p-0 h-100 w-100 overflow-hidden">
<div
className="d-flex flex-row justify-content-between navbar fixed-top"
style={{ boxShadow: '0px 2px 4px rgb(0 0 0 / 15%), 0px 2px 8px rgb(0 0 0 / 15%)' }}
>
{!inContext && (
<Route path={Routes.DISCUSSIONS.PATH} component={NavigationBar} />
)}
<PostActionsBar inContext={inContext} />
</div>
<Route
path={[Routes.POSTS.PATH, Routes.TOPICS.CATEGORY]}
component={provider === DiscussionProvider.LEGACY ? LegacyBreadcrumbMenu : BreadcrumbMenu}
/>
<div className="d-flex flex-row">
<DiscussionSidebar displaySidebar={displaySidebar} postActionBarRef={postActionBarRef} />
<div className="d-flex flex-row overflow-hidden flex-grow-1">
<DiscussionSidebar displaySidebar={displaySidebar} />
{displayContentArea && <DiscussionContent />}
{!displayContentArea && (
<Switch>
@@ -112,15 +96,13 @@ export default function DiscussionsHome() {
render={routeProps => <EmptyPosts {...routeProps} subTitleMessage={messages.emptyMyPosts} />}
/>
<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} />}
/>
{isRedirectToLearners && <Route path={Routes.LEARNERS.PATH} component={EmptyLearners} /> }
</Switch>
)}
</div>
</main>
{!inIframe && <Footer />}
</DiscussionContext.Provider>
);
}

View File

@@ -87,36 +87,4 @@ describe('DiscussionsHome', () => {
await waitFor(() => expect(window.parent.postMessage).toHaveBeenCalled());
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,
}) {
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 },
);

View File

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

View File

@@ -85,6 +85,7 @@ describe('LearnersView', () => {
await act(async () => {
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 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.
* @param {string} courseId
* @param {object} params {page, order_by}
* @returns {Promise<{}>}
*/
export async function getLearners(courseId, params) {
export async function getLearners(
courseId,
) {
const url = `${coursesApiUrl}${courseId}/activity_stats/`;
const { data } = await getAuthenticatedHttpClient().get(url, { params });
const { data } = await getAuthenticatedHttpClient().get(url);
return data;
}
@@ -32,23 +35,3 @@ export async function getUserProfiles(usernames) {
const { data } = await getAuthenticatedHttpClient().get(url);
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 { LearnerTabs } from '../../../data/constants';
export const selectAllLearners = createSelector(
state => state.learners.pages,
pages => pages.flat(),
state => state.learners,
learners => learners.learners,
);
export const learnersLoadingStatus = () => state => state.learners.status;
export const selectUsernameSearch = () => state => state.learners.usernameSearch;
export const selectLearnerSorting = () => state => state.learners.sortedBy;
export const selectLearnerFilters = () => state => state.learners.filters;
export const selectLearnerNextPage = () => state => state.learners.nextPage;
export const selectLearnerAvatar = author => state => (
state.learners.learnerProfiles[author]?.profileImage?.imageUrlLarge
state.learners.learnerProfiles[author]?.profileImage?.imageUrlSmall
);
export const selectLearnerLastLogin = author => state => (
@@ -27,3 +29,21 @@ export const selectLearner = (username) => createSelector(
[selectAllLearners],
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',
initialState: {
status: RequestStatus.IN_PROGRESS,
avatars: {},
learners: [],
learnerProfiles: {},
pages: [],
nextPage: null,
totalPages: null,
totalLearners: null,
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: {
fetchLearnersSuccess: (state, { payload }) => {
state.status = RequestStatus.SUCCESSFUL;
state.pages[payload.page - 1] = payload.results;
state.learners = payload.results;
state.learnerProfiles = {
...state.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.totalLearners = payload.pagination.count;
},
@@ -40,13 +53,32 @@ const learnersSlice = createSlice({
state.status = RequestStatus.IN_PROGRESS;
},
setSortedBy: (state, { payload }) => {
state.pages = [];
state.sortedBy = payload;
},
setUsernameSearch: (state, { payload }) => {
state.usernameSearch = payload;
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,
fetchLearnersDenied,
setSortedBy,
setUsernameSearch,
fetchUserCommentsRequest,
fetchUserCommentsDenied,
fetchUserCommentsSuccess,
fetchUserPostsRequest,
fetchUserPostsDenied,
fetchUserPostsSuccess,
} = learnersSlice.actions;
export const learnersReducer = learnersSlice.reducer;

View File

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