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
198 changed files with 6517 additions and 41969 deletions

5
.env
View File

@@ -15,11 +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= ''
DISPLAY_FEEDBACK_BANNER='false'

View File

@@ -16,11 +16,8 @@ LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
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'
DISPLAY_FEEDBACK_BANNER='false'

View File

@@ -8,17 +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'
DISPLAY_FEEDBACK_BANNER='false'

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

View File

@@ -8,4 +8,5 @@ openedx-release:
# The openedx-release key is described in OEP-10:
# https://open-edx-proposals.readthedocs.io/en/latest/oep-0010-proc-openedx-releases.html
# The FAQ might also be helpful: https://openedx.atlassian.net/wiki/spaces/COMM/pages/1331268879/Open+edX+Release+FAQ
maybe: true # Delete this "maybe" line when you have decided about Open edX inclusion.
ref: master

38062
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,19 +27,17 @@
},
"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": "12.0.0",
"@edx/frontend-component-header": "4.0.0",
"@edx/frontend-platform": "4.2.0",
"@edx/paragon": "20.15.0",
"@edx/frontend-platform": "1.15.4",
"@edx/paragon": "19.10.1",
"@reduxjs/toolkit": "1.8.0",
"@tinymce/tinymce-react": "3.13.1",
"babel-polyfill": "6.26.0",
@@ -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>
<div id="root" class="small"></div>
<body class="vh-100 vw-100 overflow-hidden">
<div id="root" class="vh-100 vw-100 small"></div>
</body>
</html>

View File

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

View File

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

View File

@@ -1,64 +0,0 @@
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useDispatch, useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { fetchTab } from './data/thunks';
import Tabs from './tabs/Tabs';
import messages from './messages';
import './navBar.scss';
function CourseTabsNavigation({
activeTab, className, intl, courseId, rootSlug,
}) {
const dispatch = useDispatch();
const tabs = useSelector(state => state.courseTabs.tabs);
useEffect(() => {
dispatch(fetchTab(courseId, rootSlug));
}, [courseId]);
return (
<div id="courseTabsNavigation" className={classNames('course-tabs-navigation', className)}>
<div className="container-xl">
{!!tabs.length
&& (
<Tabs
className="nav-underline-tabs"
aria-label={intl.formatMessage(messages.courseMaterial)}
>
{tabs.map(({ url, title, slug }) => (
<a
key={slug}
className={classNames('nav-item flex-shrink-0 nav-link', { active: slug === activeTab })}
href={url}
>
{title}
</a>
))}
</Tabs>
)}
</div>
</div>
);
}
CourseTabsNavigation.propTypes = {
activeTab: PropTypes.string,
className: PropTypes.string,
rootSlug: PropTypes.string,
courseId: PropTypes.string.isRequired,
intl: intlShape.isRequired,
};
CourseTabsNavigation.defaultProps = {
activeTab: undefined,
className: null,
rootSlug: 'outline',
};
export default injectIntl(CourseTabsNavigation);

View File

@@ -1,29 +0,0 @@
/* eslint-disable import/prefer-default-export */
import { camelCaseObject } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { getApiBaseUrl } from '../../../data/constants';
function normalizeCourseHomeCourseMetadata(metadata, rootSlug) {
const data = camelCaseObject(metadata);
return {
...data,
tabs: data.tabs.map(tab => ({
// The API uses "courseware" as a slug for both courseware and the outline tab.
// If needed, we switch it to "outline" here for
// use within the MFE to differentiate between course home and courseware.
slug: tab.tabId === 'courseware' ? rootSlug : tab.tabId,
title: tab.title,
url: tab.url,
})),
isMasquerading: data.originalUserIsStaff && !data.isStaff,
};
}
export async function getCourseHomeCourseMetadata(courseId, rootSlug) {
const url = `${getApiBaseUrl()}/api/course_home/course_metadata/${courseId}`;
// don't know the context of adding timezone in url. hence omitting it
// url = appendBrowserTimezoneToUrl(url);
const { data } = await getAuthenticatedHttpClient().get(url);
return normalizeCourseHomeCourseMetadata(data, rootSlug);
}

View File

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

View File

@@ -1,51 +0,0 @@
/* eslint-disable no-param-reassign */
import { createSlice } from '@reduxjs/toolkit';
export const LOADING = 'loading';
export const LOADED = 'loaded';
export const FAILED = 'failed';
export const DENIED = 'denied';
const slice = createSlice({
name: 'courseTabs',
initialState: {
courseStatus: 'loading',
courseId: null,
tabs: [],
courseTitle: null,
courseNumber: null,
org: null,
},
reducers: {
fetchTabDenied: (state, { payload }) => {
state.courseId = payload.courseId;
state.courseStatus = DENIED;
},
fetchTabFailure: (state, { payload }) => {
state.courseId = payload.courseId;
state.courseStatus = FAILED;
},
fetchTabRequest: (state, { payload }) => {
state.courseId = payload.courseId;
state.courseStatus = LOADING;
},
fetchTabSuccess: (state, { payload }) => {
state.courseId = payload.courseId;
state.targetUserId = payload.targetUserId;
state.tabs = payload.tabs;
state.courseStatus = LOADED;
state.courseTitle = payload.courseTitle;
state.courseNumber = payload.courseNumber;
state.org = payload.org;
},
},
});
export const {
fetchTabDenied,
fetchTabFailure,
fetchTabRequest,
fetchTabSuccess,
} = slice.actions;
export const courseTabsReducer = slice.reducer;

View File

@@ -1,33 +0,0 @@
/* eslint-disable import/prefer-default-export, no-unused-expressions */
import { logError } from '@edx/frontend-platform/logging';
import { getCourseHomeCourseMetadata } from './api';
import {
fetchTabDenied,
fetchTabFailure,
fetchTabRequest,
fetchTabSuccess,
} from './slice';
export function fetchTab(courseId, rootSlug) {
return async (dispatch) => {
dispatch(fetchTabRequest({ courseId }));
try {
const courseHomeCourseMetadata = await getCourseHomeCourseMetadata(courseId, rootSlug);
if (!courseHomeCourseMetadata.courseAccess.hasAccess) {
dispatch(fetchTabDenied({ courseId }));
} else {
dispatch(fetchTabSuccess({
courseId,
tabs: courseHomeCourseMetadata.tabs,
org: courseHomeCourseMetadata.org,
courseNumber: courseHomeCourseMetadata.number,
courseTitle: courseHomeCourseMetadata.title,
}));
}
} catch (e) {
dispatch(fetchTabFailure({ courseId }));
logError(e);
}
};
}

View File

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

View File

@@ -1,11 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
courseMaterial: {
id: 'navigation.course.tabs.label',
defaultMessage: 'Course Material',
description: 'The accessible label for course tabs navigation',
},
});
export default messages;

View File

@@ -1,47 +0,0 @@
@import "@edx/brand/paragon/fonts.scss";
@import "@edx/brand/paragon/variables.scss";
@import "@edx/paragon/scss/core/core.scss";
@import "@edx/brand/paragon/overrides.scss";
$fa-font-path: "~font-awesome/fonts";
@import "~font-awesome/scss/font-awesome";
.course-tabs-navigation {
border-bottom: solid 1px #eaeaea;
.nav a,
.nav button {
&:hover {
background-color: $light-400;
}
}
.nav a {
&:not(.active):hover {
background-color: $light-400;
border-bottom: none;
}
}
}
.nav-underline-tabs {
margin: 0 0 -1px;
.nav-link {
border-bottom: 4px solid transparent;
border-top: 4px solid transparent;
color: $gray-700;
// temporary until we can remove .btn class from dropdowns
border-left: 0;
border-right: 0;
border-radius: 0;
&:hover,
&:focus,
&.active {
font-weight: $font-weight-normal;
color: $primary-500;
border-bottom-color: $primary-500;
}
}
}

View File

@@ -1,75 +0,0 @@
import React, { useMemo } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { Dropdown } from '@edx/paragon';
import useIndexOfLastVisibleChild from './useIndexOfLastVisibleChild';
export default function Tabs({ children, className, ...attrs }) {
const [
indexOfLastVisibleChild,
containerElementRef,
invisibleStyle,
overflowElementRef,
] = useIndexOfLastVisibleChild();
const tabChildren = useMemo(() => {
const childrenArray = React.Children.toArray(children);
const indexOfOverflowStart = indexOfLastVisibleChild + 1;
// All tabs will be rendered. Those that would overflow are set to invisible.
const wrappedChildren = childrenArray.map((child, index) => React.cloneElement(child, {
style: index > indexOfLastVisibleChild ? invisibleStyle : null,
}));
// Build the list of items to put in the overflow menu
const overflowChildren = childrenArray.slice(indexOfOverflowStart)
.map(overflowChild => React.cloneElement(overflowChild, { className: 'dropdown-item' }));
// Insert the overflow menu at the cut off index (even if it will be hidden
// it so it can be part of measurements)
wrappedChildren.splice(indexOfOverflowStart, 0, (
<div
className="nav-item flex-shrink-0"
style={indexOfOverflowStart >= React.Children.count(children) ? invisibleStyle : null}
ref={overflowElementRef}
key="overflow"
>
<Dropdown className="h-100">
<Dropdown.Toggle variant="link" className="nav-link h-100" id="learn.course.tabs.navigation.overflow.menu">
<FormattedMessage
id="learn.course.tabs.navigation.overflow.menu"
description="The title of the overflow menu for course tabs"
defaultMessage="More..."
/>
</Dropdown.Toggle>
<Dropdown.Menu className="dropdown-menu-right">{overflowChildren}</Dropdown.Menu>
</Dropdown>
</div>
));
return wrappedChildren;
}, [children, indexOfLastVisibleChild]);
return (
<nav
{...attrs}
className={classNames('nav flex-nowrap', className)}
ref={containerElementRef}
>
{tabChildren}
</nav>
);
}
Tabs.propTypes = {
children: PropTypes.node,
className: PropTypes.string,
};
Tabs.defaultProps = {
children: null,
className: undefined,
};

View File

@@ -1,60 +0,0 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { IntlProvider } from 'react-intl';
import { initializeMockApp } from '@edx/frontend-platform';
import Tabs from './Tabs';
import useIndexOfLastVisibleChild from './useIndexOfLastVisibleChild';
jest.mock('./useIndexOfLastVisibleChild');
describe('Tabs', () => {
const mockChildren = [...Array(4).keys()].map(i => (<button key={i} type="button">{`Item ${i}`}</button>));
// Only half of the children will be visible. The rest of them will be in the dropdown.
const indexOfLastVisibleChild = mockChildren.length / 2 - 1;
const invisibleStyle = { visibility: 'hidden' };
useIndexOfLastVisibleChild.mockReturnValue([indexOfLastVisibleChild, null, invisibleStyle, null]);
function renderComponent(children = null) {
render(
<IntlProvider locale="en">
<Tabs>
{children}
</Tabs>
</IntlProvider>,
);
}
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
});
it('renders without children', async () => {
renderComponent();
expect(screen.getByRole('button', { text: 'More...', hidden: true })).toBeInTheDocument();
});
it('hides invisible children', async () => {
renderComponent(mockChildren);
// adding hidden property is necessary because everything enclosed in a div with property hidden
const allButtons = screen.getAllByRole('button', { hidden: true });
expect(screen.getAllByRole('button', { hidden: false })).toHaveLength(3);
[...Array(mockChildren.length).keys()].forEach(i => {
if (i <= indexOfLastVisibleChild + 1) {
expect(allButtons[i]).not.toHaveAttribute('style');
} else {
expect(allButtons[i]).toHaveStyle('visibility: hidden;');
}
});
});
});

View File

@@ -1,77 +0,0 @@
import { useLayoutEffect, useRef, useState } from 'react';
import { useWindowSize } from '@edx/paragon';
const invisibleStyle = {
position: 'absolute',
left: 0,
pointerEvents: 'none',
visibility: 'hidden',
};
/**
* This hook will find the index of the last child of a containing element
* that fits within its bounding rectangle. This is done by summing the widths
* of the children until they exceed the width of the container.
*
* The hook returns an array containing:
* [indexOfLastVisibleChild, containerElementRef, invisibleStyle, overflowElementRef]
*
* indexOfLastVisibleChild - the index of the last visible child
* containerElementRef - a ref to be added to the containing html node
* invisibleStyle - a set of styles to be applied to child of the containing node
* if it needs to be hidden. These styles remove the element visually, from
* screen readers, and from normal layout flow. But, importantly, these styles
* preserve the width of the element, so that future width calculations will
* still be accurate.
* overflowElementRef - a ref to be added to an html node inside the container
* that is likely to be used to contain a "More" type dropdown or other
* mechanism to reveal hidden children. The width of this element is always
* included when determining which children will fit or not. Usage of this ref
* is optional.
*/
export default function useIndexOfLastVisibleChild() {
const containerElementRef = useRef(null);
const overflowElementRef = useRef(null);
const containingRectRef = useRef({});
const [indexOfLastVisibleChild, setIndexOfLastVisibleChild] = useState(-1);
const windowSize = useWindowSize();
useLayoutEffect(() => {
const containingRect = containerElementRef.current.getBoundingClientRect();
// No-op if the width is unchanged.
// (Assumes tabs themselves don't change count or width).
if (!containingRect.width === containingRectRef.current.width) {
return;
}
// Update for future comparison
containingRectRef.current = containingRect;
// Get array of child nodes from NodeList form
const childNodesArr = Array.prototype.slice.call(containerElementRef.current.children);
const { nextIndexOfLastVisibleChild } = childNodesArr
// filter out the overflow element
.filter(childNode => childNode !== overflowElementRef.current)
// sum the widths to find the last visible element's index
.reduce((acc, childNode, index) => {
// use floor to prevent rounding errors
acc.sumWidth += Math.floor(childNode.getBoundingClientRect().width);
if (acc.sumWidth <= containingRect.width) {
acc.nextIndexOfLastVisibleChild = index;
}
return acc;
}, {
// Include the overflow element's width to begin with. Doing this means
// sometimes we'll show a dropdown with one item in it when it would fit,
// but allowing this case dramatically simplifies the calculations we need
// to do above.
sumWidth: overflowElementRef.current ? overflowElementRef.current.getBoundingClientRect().width : 0,
nextIndexOfLastVisibleChild: -1,
});
setIndexOfLastVisibleChild(nextIndexOfLastVisibleChild);
}, [windowSize, containerElementRef.current]);
return [indexOfLastVisibleChild, containerElementRef, invisibleStyle, overflowElementRef];
}

View File

@@ -1,64 +0,0 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button, Icon, IconButton } from '@edx/paragon';
import { Close } from '@edx/paragon/icons';
import messages from '../discussions/posts/post-editor/messages';
import HTMLLoader from './HTMLLoader';
function PostPreviewPane({
htmlNode, intl, isPost, editExisting,
}) {
const [showPreviewPane, setShowPreviewPane] = useState(false);
return (
<>
{showPreviewPane && (
<div
className={`w-100 p-2 bg-light-200 rounded box-shadow-down-1 post-preview ${isPost ? 'mt-2 mb-5' : 'my-3'}`}
style={{ minHeight: '200px', wordBreak: 'break-word' }}
>
<IconButton
onClick={() => setShowPreviewPane(false)}
alt={intl.formatMessage(messages.actionsAlt)}
src={Close}
iconAs={Icon}
size="inline"
className="float-right p-3"
iconClassNames="icon-size"
/>
<HTMLLoader htmlNode={htmlNode} cssClassName="text-primary" />
</div>
)}
<div className="d-flex justify-content-end">
{!showPreviewPane
&& (
<Button
variant="link"
size="md"
onClick={() => setShowPreviewPane(true)}
className={`text-primary-500 px-0 ${editExisting && 'mb-4.5'}`}
>
{intl.formatMessage(messages.showPreviewButton)}
</Button>
)}
</div>
</>
);
}
PostPreviewPane.propTypes = {
intl: intlShape.isRequired,
htmlNode: PropTypes.node.isRequired,
isPost: PropTypes.bool,
editExisting: PropTypes.bool,
};
PostPreviewPane.defaultProps = {
isPost: false,
editExisting: false,
};
export default injectIntl(PostPreviewPane);

View File

@@ -0,0 +1,41 @@
import React, {
useEffect,
useRef,
} from 'react';
import PropTypes from 'prop-types';
function ScrollThreshold({ onScroll }) {
const elementRef = useRef(null);
useEffect(() => {
if (!elementRef.current) {
return undefined;
}
// create the observer
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
onScroll();
}
},
);
observer.observe(elementRef.current);
// cleanup callback
return () => {
observer.disconnect();
};
}, [elementRef]);
return (
<div ref={elementRef} />
);
}
ScrollThreshold.propTypes = {
onScroll: PropTypes.func.isRequired,
};
export default ScrollThreshold;

View File

@@ -1,89 +0,0 @@
import React, { useContext, useEffect, useState } 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 [previousSearchValue, setPreviousSearchValue] = useState('');
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(''));
setPreviousSearchValue('');
};
const onChange = (query) => {
searchValue = query;
};
const onSubmit = (query) => {
if (query === '' || query === previousSearchValue) {
return;
}
if (isPostSearch) {
dispatch(setSearchQuery(query));
} else if (page === 'topics') {
dispatch(setTopicFilter(query));
} else if (page === 'learners') {
dispatch(setUsernameSearch(query));
}
setPreviousSearchValue(query);
};
useEffect(() => onClear(), [page]);
return (
<>
<SearchField.Advanced
onClear={onClear}
onChange={onChange}
onSubmit={onSubmit}
value={currentValue}
>
<SearchField.Label />
<SearchField.Input
style={{ paddingRight: '1rem' }}
placeholder={intl.formatMessage(postsMessages.search, { page: camelCase(page) })}
/>
<span className="mt-auto mb-auto mr-2.5 pointer-cursor-hover">
<Icon
src={SearchIcon}
onClick={() => onSubmit(searchValue)}
/>
</span>
</SearchField.Advanced>
</>
);
}
Search.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(Search);

View File

@@ -1,54 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button, Icon } from '@edx/paragon';
import { Search } from '@edx/paragon/icons';
import { RequestStatus } from '../data/constants';
import messages from '../discussions/posts/post-actions-bar/messages';
function SearchInfo({
intl,
count,
text,
loadingStatus,
onClear,
textSearchRewrite,
}) {
return (
<div className="d-flex flex-row border-bottom border-light-400">
<Icon src={Search} className="justify-content-start ml-3.5 mr-2 mb-2 mt-2.5" />
<Button variant="" size="inline" className="text-justify p-2">
{loadingStatus === RequestStatus.SUCCESSFUL && (
textSearchRewrite ? intl.formatMessage(messages.searchRewriteInfo, {
searchString: text,
count,
textSearchRewrite,
})
: intl.formatMessage(messages.searchInfo, { count, text })
)}
{loadingStatus !== RequestStatus.SUCCESSFUL && intl.formatMessage(messages.searchInfoSearching)}
</Button>
<Button variant="link" size="inline" className="ml-auto mr-3" onClick={onClear} style={{ minWidth: '26%' }}>
{intl.formatMessage(messages.clearSearch)}
</Button>
</div>
);
}
SearchInfo.propTypes = {
intl: intlShape.isRequired,
count: PropTypes.number.isRequired,
text: PropTypes.string.isRequired,
loadingStatus: PropTypes.string.isRequired,
textSearchRewrite: PropTypes.string,
onClear: PropTypes.func,
};
SearchInfo.defaultProps = {
onClear: () => {},
textSearchRewrite: null,
};
export default injectIntl(SearchInfo);

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React from 'react';
import { Editor } from '@tinymce/tinymce-react';
import { useParams } from 'react-router';
@@ -6,11 +6,6 @@ import { useParams } from 'react-router';
// eslint-disable-next-line no-unused-vars,import/no-extraneous-dependencies
import tinymce from 'tinymce/tinymce';
import { useIntl } from '@edx/frontend-platform/i18n';
import { ActionRow, AlertModal, Button } from '@edx/paragon';
import { MAX_UPLOAD_FILE_SIZE } from '../data/constants';
import messages from '../discussions/messages';
import { uploadFile } from '../discussions/posts/data/api';
import 'tinymce/plugins/code';
@@ -22,16 +17,12 @@ import 'tinymce/icons/default';
import 'tinymce/skins/ui/oxide/skin.css';
// importing the plugin js.
import 'tinymce/plugins/autolink';
import 'tinymce/plugins/autoresize';
import 'tinymce/plugins/autosave';
import 'tinymce/plugins/codesample';
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';
@@ -40,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',
@@ -56,29 +46,17 @@ 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
const { courseId, postId } = useParams();
const [showImageWarning, setShowImageWarning] = useState(false);
const intl = useIntl();
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');
const img = new Image();
img.onload = function () {
if (img.height > 999 || img.width > 999) { setShowImageWarning(true); }
};
img.src = location;
success(location);
} catch (e) {
failure(e.toString(), { remove: true });
@@ -94,54 +72,30 @@ export default function TinyMCEEditor(props) {
}
return (
<>
<Editor
init={{
skin: false,
menubar: false,
branding: false,
contextmenu: false,
browser_spellcheck: true,
a11y_advanced_options: true,
autosave_interval: '1s',
autosave_restore_when_empty: false,
plugins: 'autoresize autosave codesample link lists image imagetools code emoticons charmap',
toolbar: 'undo redo'
+ ' | formatselect | bold italic underline'
+ ' | link blockquote openedx_code image'
+ ' | bullist numlist outdent indent'
+ ' | removeformat'
+ ' | openedx_html'
+ ' | emoticons'
+ ' | charmap',
content_css: false,
content_style: contentStyle,
body_class: 'm-2 text-editor',
default_link_target: '_blank',
target_list: false,
images_upload_handler: uploadHandler,
setup,
}}
{...props}
/>
<AlertModal
title={intl.formatMessage(messages.imageWarningModalTitle)}
isOpen={showImageWarning}
onClose={() => setShowImageWarning(false)}
isBlocking
footerNode={(
<ActionRow>
<Button variant="danger" onClick={() => setShowImageWarning(false)}>
{intl.formatMessage(messages.imageWarningDismissButton)}
</Button>
</ActionRow>
)}
>
<p>
{intl.formatMessage(messages.imageWarningMessage)}
</p>
</AlertModal>
</>
<Editor
init={{
skin: false,
menubar: false,
branding: false,
contextmenu: false,
browser_spellcheck: true,
a11y_advanced_options: true,
autosave_interval: '1s',
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'
+ ' | undo redo',
content_css: false,
content_style: contentStyle,
body_class: 'm-2',
images_upload_handler: uploadHandler,
setup,
}}
{...props}
/>
);
}

View File

@@ -1,20 +0,0 @@
import React from 'react';
export default function InsertLink() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="none"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
fillRule="evenodd"
d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"
clipRule="evenodd"
/>
</svg>
);
}

View File

@@ -1,26 +0,0 @@
import React from 'react';
export default function Issue() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="28"
height="28"
fill="none"
viewBox="0 0 28 28"
>
<path
fill="#F2F0EF"
d="M0 14C0 6.268 6.268 0 14 0s14 6.268 14 14-6.268 14-14 14S0 21.732 0 14z"
/>
<path
fill="#2D494E"
d="M14 2.333C7.56 2.333 2.333 7.56 2.333 14c0 6.44 5.227 11.667 11.667 11.667 6.44 0 11.667-5.227 11.667-11.667C25.667 7.56 20.44 2.334 14 2.334z"
/>
<path
fill="#fff"
d="M12.833 22.167h2.334v-2.334h-2.334v2.334zM16.532 14.198l1.05-1.073a3.713 3.713 0 001.085-2.625A4.665 4.665 0 0014 5.833 4.665 4.665 0 009.333 10.5h2.334A2.34 2.34 0 0114 8.167a2.34 2.34 0 012.333 2.333c0 .642-.256 1.225-.688 1.645l-1.447 1.47a4.696 4.696 0 00-1.365 3.302v.583h2.334c0-1.75.525-2.45 1.365-3.302z"
/>
</svg>
);
}

View File

@@ -1,18 +0,0 @@
import React from 'react';
export default function People() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="none"
viewBox="0 0 16 16"
>
<path
fill="#707070"
d="M11.072 7.332a1.992 1.992 0 001.993-2 1.997 1.997 0 10-3.993 0c0 1.107.893 2 2 2zm-5.334 0a1.992 1.992 0 001.994-2 1.997 1.997 0 10-3.993 0c0 1.107.893 2 2 2zm0 1.333c-1.553 0-4.666.78-4.666 2.334v1.666h9.333V11c0-1.554-3.113-2.334-4.667-2.334zm5.334 0c-.194 0-.414.014-.647.034.773.56 1.313 1.313 1.313 2.3v1.666h4V11c0-1.554-3.113-2.334-4.666-2.334z"
/>
</svg>
);
}

View File

@@ -1,20 +0,0 @@
import React from 'react';
export default function PushPin() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="none"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
fillRule="evenodd"
d="M16 9V4H18V2H6V4H8V9C8 10.66 6.66 12 5 12V14H10.97V21L11.97 22L12.97 21V14H19V12C17.34 12 16 10.66 16 9Z"
clipRule="evenodd"
/>
</svg>
);
}

View File

@@ -1,26 +0,0 @@
import React from 'react';
export default function Question() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="28"
height="28"
fill="none"
viewBox="0 0 28 28"
>
<path
fill="#fff"
d="M0 14.001c0-7.732 6.268-14 14-14s14 6.268 14 14-6.268 14-14 14-14-6.268-14-14z"
/>
<path
fill="#2D494E"
d="M14 2.334c-6.44 0-11.667 5.227-11.667 11.667 0 6.44 5.227 11.667 11.667 11.667 6.44 0 11.666-5.227 11.666-11.667 0-6.44-5.226-11.667-11.666-11.667z"
/>
<path
fill="#fff"
d="M12.833 22.168h2.333v-2.334h-2.333v2.334zM16.531 14.2l1.05-1.074a3.712 3.712 0 001.085-2.625A4.665 4.665 0 0014 5.834a4.665 4.665 0 00-4.667 4.667h2.333A2.34 2.34 0 0114 8.168a2.34 2.34 0 012.333 2.333c0 .642-.257 1.225-.688 1.645l-1.447 1.47a4.696 4.696 0 00-1.365 3.302v.583h2.333c0-1.75.525-2.45 1.365-3.302z"
/>
</svg>
);
}

View File

@@ -1,18 +0,0 @@
import React from 'react';
export default function QuestionAnswer() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="21"
height="20"
fill="none"
viewBox="0 0 21 20"
>
<path
fill="currentColor"
d="M18.737 5h-2.5v7.5H5.404V15h10l3.333 3.333V5zm-4.166 5.833V1.667H2.07v12.5l3.333-3.334h9.166z"
/>
</svg>
);
}

View File

@@ -1,18 +0,0 @@
import React from 'react';
export default function QuestionAnswerOutline() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
fill="none"
viewBox="0 0 20 20"
>
<path
fill="currentColor"
d="M12.6 3.267v6.067H4.08l-.512.512-.502.502v-7.08H12.6zm.867-1.733H2.198a.87.87 0 00-.867.867v12.134L4.8 11.068h8.668a.87.87 0 00.866-.867v-7.8a.87.87 0 00-.867-.867zM17.8 5h-1.733v7.8H4.799v1.734c0 .476.39.867.867.867H15.2l3.467 3.466v-13A.87.87 0 0017.8 5z"
/>
</svg>
);
}

View File

@@ -1,18 +0,0 @@
import React from 'react';
export default function StarFilled() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="21"
height="20"
fill="none"
viewBox="0 0 21 20"
>
<path
fill="currentColor"
d="M10.404 14.392l5.15 3.108-1.367-5.858 4.55-3.942-5.991-.508-2.342-5.525-2.342 5.525L2.07 7.7l4.55 3.942L5.254 17.5l5.15-3.108z"
/>
</svg>
);
}

View File

@@ -1,18 +0,0 @@
import React from 'react';
export default function StarOutline() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
fill="none"
viewBox="0 0 20 20"
>
<path
fill="currentColor"
d="M18.737 7.7l-5.991-.517-2.342-5.516-2.342 5.525L2.07 7.7l4.55 3.942L5.254 17.5l5.15-3.108 5.15 3.108-1.359-5.858L18.737 7.7zm-8.333 5.133L7.27 14.725l.834-3.567-2.767-2.4 3.65-.316 1.417-3.359 1.425 3.367 3.65.317-2.767 2.4.834 3.566-3.142-1.9z"
/>
</svg>
);
}

View File

@@ -1,18 +0,0 @@
import React from 'react';
export default function ThumbUpFilled() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="21"
height="20"
fill="none"
viewBox="0 0 21 20"
>
<path
fill="currentColor"
d="M12.212.833L6.237 6.817V17.5h10.258l3.075-7.167V6.667h-6.925l.934-4.484-1.367-1.35zM1.237 7.5H4.57v10H1.237v-10z"
/>
</svg>
);
}

View File

@@ -1,21 +0,0 @@
import React from 'react';
export default function ThumbUpOutline() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
fill="none"
viewBox="0 0 20 20"
>
<path
fill="currentColor"
fillRule="evenodd"
d="M19.57 6.667v3.666L16.495 17.5H6.238V6.817L12.212.833l1.367 1.35-.934 4.484h6.925zm-11.666.841v8.325h7.492l2.508-5.841V8.333h-7.309l.925-4.45-3.616 3.625z"
clipRule="evenodd"
/>
<path fill="currentColor" d="M4.57 17.5H1.237v-10H4.57v10z" />
</svg>
);
}

View File

@@ -1,11 +0,0 @@
export { default as InsertLink } from './InsertLink';
export { default as Issue } from './Issue';
export { default as People } from './People';
export { default as PushPin } from './PushPin';
export { default as Question } from './Question';
export { default as QuestionAnswer } from './QuestionAnswer';
export { default as QuestionAnswerOutline } from './QuestionAnswerOutline';
export { default as StarFilled } from './StarFilled';
export { default as StarOutline } from './StarOutline';
export { default as ThumbUpFilled } from './ThumbUpFilled';
export { default as ThumbUpOutline } from './ThumbUpOutline';

View File

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

View File

@@ -1,9 +1,9 @@
/* eslint-disable import/prefer-default-export */
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { getApiBaseUrl } from './constants';
import { API_BASE_URL } from './constants';
export const getBlocksAPIURL = () => `${getApiBaseUrl()}/api/courses/v1/blocks/`;
export const blocksAPIURL = `${API_BASE_URL}/api/courses/v1/blocks/`;
export async function getCourseBlocks(courseId, username) {
const params = {
course_id: courseId,
@@ -14,6 +14,6 @@ export async function getCourseBlocks(courseId, username) {
student_view_data: 'discussion',
};
const { data } = await getAuthenticatedHttpClient()
.get(getBlocksAPIURL(), { params });
.get(blocksAPIURL, { params });
return data;
}

View File

@@ -1,6 +1,6 @@
import { getConfig } from '@edx/frontend-platform';
export const getApiBaseUrl = () => getConfig().LMS_BASE_URL;
export const API_BASE_URL = getConfig().LMS_BASE_URL;
/**
* Enum for thread types.
@@ -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',
};
/**
@@ -90,6 +89,16 @@ export const ThreadOrdering = {
BY_VOTE_COUNT: 'voteCount',
};
/**
* Enum for thread view status filtering.
* @readonly
* @enum {string}
*/
export const ThreadViewStatus = {
UNREAD: 'unread',
UNANSWERED: 'unanswered',
};
/**
* Enum for filtering posts by status.
* @readonly
@@ -101,7 +110,6 @@ export const PostsStatusFilter = {
FOLLOWING: 'statusFollowing',
REPORTED: 'statusReported',
UNANSWERED: 'statusUnanswered',
UNRESPONDED: 'statusUnresponded',
};
/**
@@ -124,7 +132,17 @@ export const TopicOrdering = {
export const LearnersOrdering = {
BY_FLAG: 'flagged',
BY_LAST_ACTIVITY: 'activity',
BY_RECENCY: 'recency',
};
/**
* Enum for Learner content tabs
* @readonly
* @enum {string}
*/
export const LearnerTabs = {
POSTS: 'posts',
COMMENTS: 'comments',
RESPONSES: 'responses',
};
/**
@@ -144,7 +162,12 @@ export const Routes = {
},
LEARNERS: {
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`,
@@ -156,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

@@ -7,11 +7,9 @@ import { initializeMockApp } from '@edx/frontend-platform/testing';
import { initializeStore } from '../store';
import { executeThunk } from '../test-utils';
import { getBlocksAPIResponse } from './__factories__';
import { getBlocksAPIURL } from './api';
import { RequestStatus } from './constants';
import { blocksAPIURL } from './api';
import { fetchCourseBlocks } from './thunks';
const blocksAPIURL = getBlocksAPIURL();
const courseId = 'course-v1:edX+DemoX+Demo_Course';
let axiosMock;
@@ -37,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());
@@ -79,29 +77,4 @@ describe('Course blocks data layer tests', () => {
},
);
});
it('handles network error', async () => {
axiosMock.onGet(blocksAPIURL).networkError();
await executeThunk(fetchCourseBlocks(courseId, 'test-user'), store.dispatch, store.getState);
expect(store.getState().blocks.status)
.toBe(RequestStatus.FAILED);
});
it('handles network timeout', async () => {
axiosMock.onGet(blocksAPIURL).timeout();
await executeThunk(fetchCourseBlocks(courseId, 'test-user'), store.dispatch, store.getState);
expect(store.getState().blocks.status)
.toBe(RequestStatus.FAILED);
});
it('handles access denied', async () => {
axiosMock.onGet(blocksAPIURL).reply(403, {});
await executeThunk(fetchCourseBlocks(courseId, 'test-user'), store.dispatch, store.getState);
expect(store.getState().blocks.status)
.toBe(RequestStatus.DENIED);
});
});

View File

@@ -2,7 +2,7 @@
import { createSelector } from '@reduxjs/toolkit';
import { selectDiscussionProvider, selectGroupAtSubsection } from '../discussions/data/selectors';
import { selectDiscussionProvider } from '../discussions/data/selectors';
import { DiscussionProvider } from './constants';
export const selectTopicContext = (topicId) => (state) => state.blocks.topics[topicId];
@@ -14,18 +14,6 @@ export const selectorForUnitSubsection = createSelector(
blocks => key => blocks[blocks[key]?.parent],
);
// If subsection grouping is enabled, and the current selection is a unit, then get the current subsection.
export const selectCurrentCategoryGrouping = createSelector(
selectDiscussionProvider,
selectGroupAtSubsection,
selectBlocks,
(provider, groupAtSubsection, blocks) => blockId => (
(provider !== 'openedx' || !groupAtSubsection || blocks[blockId]?.type !== 'vertical')
? blockId
: blocks[blockId].parent
),
);
export const selectChapters = (state) => state.blocks.chapters;
export const selectTopicsUnderCategory = createSelector(
selectDiscussionProvider,
@@ -44,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

@@ -6,7 +6,9 @@ ensureConfig([
'LMS_BASE_URL',
], 'Comments API service');
export const getCohortsApiUrl = courseId => `${getConfig().LMS_BASE_URL}/api/cohorts/v1/courses/${courseId}/cohorts/`;
const apiBaseUrl = getConfig().LMS_BASE_URL;
export const getCohortsApiUrl = courseId => `${apiBaseUrl}/api/cohorts/v1/courses/${courseId}/cohorts/`;
export async function getCourseCohorts(courseId) {
const params = snakeCaseObject({ courseId });

View File

@@ -1,43 +1,38 @@
import React, { useContext, useEffect, useMemo } from 'react';
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useDispatch, useSelector } from 'react-redux';
import { useParams } from 'react-router';
import { useHistory, useLocation } from 'react-router-dom';
import { ensureConfig, getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import {
Button, Icon, IconButton, Spinner,
} from '@edx/paragon';
import { ArrowBack } from '@edx/paragon/icons';
import { Button, Spinner } from '@edx/paragon';
import {
EndorsementStatus, PostsPages, ThreadType,
} from '../../data/constants';
import { EndorsementStatus, ThreadType } from '../../data/constants';
import { useDispatchWithState } from '../../data/hooks';
import { DiscussionContext } from '../common/context';
import { useIsOnDesktop } from '../data/hooks';
import { EmptyPage } from '../empty-posts';
import { Post } from '../posts';
import { selectThread } from '../posts/data/selectors';
import { fetchThread, markThreadAsRead } from '../posts/data/thunks';
import { discussionsPath, 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;
}
@@ -69,7 +64,6 @@ function DiscussionCommentsView({
postId,
intl,
endorsed,
isClosed,
}) {
const {
comments,
@@ -77,56 +71,41 @@ function DiscussionCommentsView({
isLoading,
handleLoadMoreResponses,
} = usePostComments(postId, endorsed);
const sortedComments = useMemo(() => [...filterPosts(comments, 'endorsed'),
...filterPosts(comments, 'unendorsed')], [comments]);
return (
<>
{((hasMorePages && isLoading) || !isLoading)
&& (
<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>
)}
{comments.map(comment => (
<Comment comment={comment} key={comment.id} postType={postType} />
))}
<div className="mx-4" role="list">
{sortedComments.map(comment => (
<Comment comment={comment} key={comment.id} postType={postType} isClosedPost={isClosed} />
))}
{hasMorePages && !isLoading && (
<Button
onClick={handleLoadMoreResponses}
variant="link"
block="true"
className="card p-4 mb-4 font-weight-500 font-size-14"
style={{
lineHeight: '20px',
}}
data-testid="load-more-comments"
>
{intl.formatMessage(messages.loadMoreResponses)}
</Button>
)}
{isLoading
{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>
)}
{!!sortedComments.length && !isClosed
&& <ResponseEditor postId={postId} addWrappingDiv />}
</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,
@@ -134,82 +113,28 @@ DiscussionCommentsView.propTypes = {
};
function CommentsView({ intl }) {
const [isLoading, submitDispatch] = useDispatchWithState();
const { postId } = useParams();
const thread = usePost(postId);
const history = useHistory();
const location = useLocation();
const isOnDesktop = useIsOnDesktop();
const {
courseId, learnerUsername, category, topicId, page, inContext,
} = useContext(DiscussionContext);
useEffect(() => {
if (!thread) { submitDispatch(fetchThread(postId, courseId, true)); }
}, [postId]);
if (!thread) {
if (!isLoading) {
return (
<EmptyPage title={intl.formatMessage(messages.noThreadFound)} />
);
}
return (
<Spinner animation="border" variant="primary" data-testid="loading-indicator" />
);
}
return (
<>
{!isOnDesktop && (
inContext ? (
<>
<div className="px-4 py-1.5 bg-white">
<Button
variant="plain"
className="px-0 font-weight-light text-primary-500"
iconBefore={ArrowBack}
onClick={() => history.push(discussionsPath(PostsPages[page], {
courseId, learnerUsername, category, topicId,
})(location))}
size="sm"
>
{intl.formatMessage(messages.backAlt)}
</Button>
</div>
<div className="border-bottom border-light-400" />
</>
) : (
<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={classNames('discussion-comments d-flex flex-column card', {
'm-4 p-4.5': !inContext,
'p-4 rounded-0 border-0 mb-4': inContext,
})}
>
<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 && (
{thread.type === ThreadType.DISCUSSION
&& (
<DiscussionCommentsView
postId={postId}
intl={intl}
postType={thread.type}
endorsed={EndorsementStatus.DISCUSSION}
isClosed={thread.closed}
/>
)}
)}
{thread.type === ThreadType.QUESTION && (
<>
<DiscussionCommentsView
@@ -217,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,20 +12,16 @@ import { AppProvider } from '@edx/frontend-platform/react';
import { initializeStore } from '../../store';
import { executeThunk } from '../../test-utils';
import { DiscussionContext } from '../common/context';
import { getCourseConfigApiUrl } from '../data/api';
import { courseConfigApiUrl } from '../data/api';
import { fetchCourseConfig } from '../data/thunks';
import DiscussionContent from '../discussions-home/DiscussionContent';
import { getThreadsApiUrl } from '../posts/data/api';
import { threadsApiUrl } from '../posts/data/api';
import { fetchThreads } from '../posts/data/thunks';
import { getCommentsApiUrl } from './data/api';
import { commentsApiUrl } from './data/api';
import '../posts/data/__factories__';
import './data/__factories__';
const courseConfigApiUrl = getCourseConfigApiUrl();
const commentsApiUrl = getCommentsApiUrl();
const threadsApiUrl = getThreadsApiUrl();
const discussionPostId = 'thread-1';
const questionPostId = 'thread-2';
const closedPostId = 'thread-2';
@@ -85,27 +81,23 @@ 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>,
);
}
describe('CommentsView', () => {
beforeEach(() => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
@@ -150,7 +142,7 @@ describe('CommentsView', () => {
)];
});
executeThunk(fetchThreads(courseId), store.dispatch, store.getState);
await executeThunk(fetchThreads(courseId), store.dispatch, store.getState);
mockAxiosReturnPagedComments();
mockAxiosReturnPagedCommentsResponses();
});
@@ -163,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();
@@ -175,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' } });
});
@@ -197,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 }));
@@ -224,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 }));
@@ -260,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' },
@@ -452,9 +423,9 @@ describe('CommentsView', () => {
describe('for discussion thread', () => {
const findLoadMoreCommentsButton = () => screen.findByTestId('load-more-comments');
it('shown post not found when post id does not belong to course', async () => {
it('shown spinner when post isn\'t loaded', async () => {
renderComponent('unloaded-id');
expect(await screen.findByText('Thread not found', { exact: true }))
expect(await screen.findByTestId('loading-indicator'))
.toBeInTheDocument();
});

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

@@ -1,4 +1,4 @@
import React, { useContext, useEffect, useState } from 'react';
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
@@ -7,13 +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 { DiscussionContext } from '../../common/context';
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';
@@ -27,7 +23,6 @@ function Comment({
postType,
comment,
showFullThread = true,
isClosedPost,
intl,
}) {
const dispatch = useDispatch();
@@ -39,115 +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);
const {
courseId,
} = useContext(DiscussionContext);
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(fetchThread(comment.threadId, courseId));
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)} formClasses="pt-3" />
)
: <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.loadMoreComments)}
{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-3 py-2 font-size-14"
variant="outline-primary"
style={{
lineHeight: '20px',
}}
onClick={() => setReplying(true)}
>
{intl.formatMessage(messages.addComment)}
</Button>
)}
</>
)
)}
</div>
</div>
</div>
);
@@ -157,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,15 +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,
selectUserIsStaff,
} from '../../data/selectors';
import { selectModerationSettings, selectUserIsPrivileged } from '../../data/selectors';
import { formikCompatibleHandler, isFormikFieldInvalid } from '../../utils';
import { addComment, editComment } from '../data/thunks';
import messages from '../messages';
@@ -27,51 +20,15 @@ function CommentEditor({
intl,
comment,
onCloseEditor,
edit,
formClasses,
}) {
const editorRef = useRef(null);
const { authenticatedUser } = useContext(AppContext);
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
const userIsGroupTa = useSelector(selectUserIsGroupTa);
const userIsStaff = useSelector(selectUserIsStaff);
const userIsPrivileged = useSelector(selectUserIsPrivileged);
const { reasonCodesEnabled, editReasons } = useSelector(selectModerationSettings);
const [submitting, dispatch] = useDispatchWithState();
const canDisplayEditReason = (reasonCodesEnabled && edit
&& (userHasModerationPrivileges || userIsGroupTa || userIsStaff)
&& comment?.author !== authenticatedUser.username
);
const editReasonCodeValidation = canDisplayEditReason && {
editReasonCode: Yup.string().required(intl.formatMessage(messages.editReasonCodeError)),
};
const validationSchema = Yup.object().shape({
comment: Yup.string()
.required(),
...editReasonCodeValidation,
});
const initialValues = {
comment: comment.rawBody,
editReasonCode: comment?.lastEdit?.reasonCode || (
userIsStaff && canDisplayEditReason ? 'violates-guidelines' : undefined
),
};
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));
}
@@ -79,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}
>
{({
@@ -98,19 +61,15 @@ function CommentEditor({
handleSubmit,
handleBlur,
handleChange,
resetForm,
}) => (
<Form onSubmit={handleSubmit} className={formClasses}>
{canDisplayEditReason && (
<Form.Group
isInvalid={isFormikFieldInvalid('editReasonCode', {
errors,
touched,
})}
>
<Form onSubmit={handleSubmit}>
{(reasonCodesEnabled
&& userIsPrivileged
&& comment.author !== authenticatedUser.username) && (
<Form.Group>
<Form.Control
name="editReasonCode"
className="mt-2 mr-0"
className="mt-2"
as="select"
value={values.editReasonCode}
onChange={handleChange}
@@ -126,7 +85,6 @@ function CommentEditor({
<option key={code} value={code}>{label}</option>
))}
</Form.Control>
<FormikErrorFeedback name="editReasonCode" />
</Form.Group>
)}
<TinyMCEEditor
@@ -150,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>
@@ -182,17 +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,
formClasses: PropTypes.string,
};
CommentEditor.defaultProps = {
edit: true,
formClasses: '',
};
export default injectIntl(CommentEditor);

View File

@@ -1,21 +1,16 @@
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 { logError } from '@edx/frontend-platform/logging';
import {
Avatar, Icon,
} from '@edx/paragon';
import { Avatar, Icon } from '@edx/paragon';
import { CheckCircle, Verified } from '@edx/paragon/icons';
import { AvatarOutlineAndLabelColors, EndorsementStatus, 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 { useActions } from '../../utils';
import { commentShape } from './proptypes';
function CommentHeader({
@@ -24,63 +19,22 @@ function CommentHeader({
actionHandlers,
}) {
const authorAvatars = useSelector(selectAuthorAvatars(comment.author));
const colorClass = AvatarOutlineAndLabelColors[comment.authorLabel];
const hasAnyAlert = useAlertBannerVisible(comment);
const actions = useActions({
...comment,
postType,
});
const actionIcons = actions.find(({ action }) => action === EndorsementStatus.ENDORSED);
const handleIcons = (action) => {
const actionFunction = actionHandlers[action];
if (actionFunction) {
actionFunction();
} else {
logError(`Unknown or unimplemented action ${action}`);
}
};
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">
{actionIcons && (
<span className="btn-icon btn-icon-sm mr-1 align-items-center">
<Icon
data-testid="check-icon"
onClick={
() => {
handleIcons(actionIcons.action);
}
}
src={actionIcons.icon}
className={['endorse', 'unendorse'].includes(actionIcons.id) ? 'text-dark-500' : 'text-success-500'}
size="sm"
/>
</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>,
);
@@ -30,7 +25,7 @@ const mockComment = {
author: 'abc123',
authorLabel: 'ABC 123',
endorsed: true,
editableFields: ['endorsed'],
editableFields: [],
};
describe('Comment Header', () => {
@@ -48,7 +43,7 @@ describe('Comment Header', () => {
it('should render verified icon for endorsed discussion posts', () => {
renderComponent(mockComment, 'discussion', {});
expect(screen.queryAllByTestId('check-icon')).toHaveLength(1);
expect(screen.queryAllByTestId('verified-icon')).toHaveLength(1);
});
it('should render check icon for endorsed question posts', () => {
renderComponent(mockComment, 'question', {});

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,52 +1,23 @@
import React, { useContext, 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 { DiscussionContext } from '../../common/context';
import { selectBlackoutDate } from '../../data/selectors';
import { inBlackoutDateRange } from '../../utils';
import messages from '../messages';
import CommentEditor from './CommentEditor';
function ResponseEditor({
postId,
intl,
addWrappingDiv,
}) {
const { inContext } = useContext(DiscussionContext);
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={classNames('px-2.5 py-2 font-size-14', { 'w-100': inContext })}
onClick={() => setAddingResponse(true)}
style={{
lineHeight: '20px',
}}
>
<CommentEditor comment={{ threadId: postId }} onCloseEditor={() => setAddingResponse(false)} />
) : (
<div className="actions d-flex">
<Button variant="primary" onClick={() => setAddingResponse(true)}>
{intl.formatMessage(messages.addResponse)}
</Button>
</div>
@@ -56,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

@@ -8,7 +8,9 @@ ensureConfig([
'LMS_BASE_URL',
], 'Comments API service');
export const getCommentsApiUrl = () => `${getConfig().LMS_BASE_URL}/api/discussion/v1/comments/`;
const apiBaseUrl = getConfig().LMS_BASE_URL;
export const commentsApiUrl = `${apiBaseUrl}/api/discussion/v1/comments/`;
/**
* Returns all the comments for the specified thread.
@@ -34,7 +36,7 @@ export async function getThreadComments(
});
const { data } = await getAuthenticatedHttpClient()
.get(getCommentsApiUrl(), { params });
.get(commentsApiUrl, { params });
return data;
}
@@ -51,7 +53,7 @@ export async function getCommentResponses(
pageSize,
} = {},
) {
const url = `${getCommentsApiUrl()}${commentId}/`;
const url = `${commentsApiUrl}${commentId}/`;
const params = snakeCaseObject({
page,
pageSize,
@@ -71,7 +73,7 @@ export async function getCommentResponses(
*/
export async function postComment(comment, threadId, parentId = null) {
const { data } = await getAuthenticatedHttpClient()
.post(getCommentsApiUrl(), snakeCaseObject({ threadId, raw_body: comment, parentId }));
.post(commentsApiUrl, snakeCaseObject({ threadId, raw_body: comment, parentId }));
return data;
}
@@ -92,7 +94,7 @@ export async function updateComment(commentId, {
endorsed,
editReasonCode,
}) {
const url = `${getCommentsApiUrl()}${commentId}/`;
const url = `${commentsApiUrl}${commentId}/`;
const postData = snakeCaseObject({
raw_body: comment,
voted,
@@ -111,7 +113,32 @@ export async function updateComment(commentId, {
* @param {string} commentId ID of comment to delete
*/
export async function deleteComment(commentId) {
const url = `${getCommentsApiUrl()}${commentId}/`;
const url = `${commentsApiUrl}${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

@@ -7,14 +7,13 @@ import { initializeMockApp } from '@edx/frontend-platform/testing';
import { EndorsementStatus } from '../../../data/constants';
import { initializeStore } from '../../../store';
import { executeThunk } from '../../../test-utils';
import { getCommentsApiUrl } from './api';
import { commentsApiUrl } from './api';
import {
addComment, editComment, fetchCommentResponses, fetchThreadComments, removeComment,
} from './thunks';
import './__factories__';
const commentsApiUrl = getCommentsApiUrl();
let axiosMock;
let store;

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

@@ -16,11 +16,6 @@ const messages = defineMessages({
defaultMessage: 'Content reported for staff to review',
description: 'Alert banner over comment that has been reported for abuse',
},
backAlt: {
id: 'discussions.actions.back.alt',
defaultMessage: 'Back to list',
description: 'Back to Posts list button text',
},
responseCount: {
id: 'discussions.comments.comment.responseCount',
defaultMessage: `{num, plural,
@@ -31,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}
@@ -60,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.',
},
@@ -153,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',
@@ -172,21 +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',
},
noThreadFound: {
id: 'discussion.thread.notFound',
defaultMessage: 'Thread not found',
description: 'message to show on screen if the request thread is not found in course',
},
});
export default messages;

View File

@@ -1,22 +1,18 @@
import React, { useContext, useState } from 'react';
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 {
Button, Dropdown, Icon, IconButton, ModalPopup, useToggle,
Button, Dropdown, Icon, IconButton, ModalPopup,
} from '@edx/paragon';
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 { DiscussionContext } from './context';
import { useActions } from '../utils';
function ActionsDropdown({
intl,
@@ -24,10 +20,9 @@ function ActionsDropdown({
disabled,
actionHandlers,
}) {
const [isOpen, open, close] = useToggle(false);
const [target, setTarget] = useState(null);
const [isOpen, setOpen] = useState(false);
const dropdownIconRef = React.useRef(null);
const actions = useActions(commentOrPost);
const { inContext } = useContext(DiscussionContext);
const handleActions = (action) => {
const actionFunction = actionHandlers[action];
if (actionFunction) {
@@ -36,27 +31,22 @@ 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={open}
alt={intl.formatMessage(messages.actionsAlt)}
src={MoreHoriz}
iconAs={Icon}
disabled={disabled}
size="sm"
ref={setTarget}
/>
<span ref={dropdownIconRef}>
<IconButton
onClick={() => setOpen(!isOpen)}
alt={intl.formatMessage(messages.actionsAlt)}
src={MoreHoriz}
iconAs={Icon}
disabled={disabled}
/>
</span>
<ModalPopup
onClose={close}
positionRef={target}
onClose={() => setOpen(false)}
positionRef={dropdownIconRef}
isOpen={isOpen}
placement={inContext ? 'left' : 'auto-start'}
placement="auto-start"
>
<div
className="bg-white p-1 shadow d-flex flex-column"
@@ -72,7 +62,7 @@ function ActionsDropdown({
variant="tertiary"
size="inline"
onClick={() => {
close();
setOpen(false);
handleActions(action.action);
}}
className="d-flex justify-content-start py-1.5 mr-4"

View File

@@ -9,7 +9,6 @@ import { camelCaseObject, initializeMockApp, snakeCaseObject } from '@edx/fronte
import { AppProvider } from '@edx/frontend-platform/react';
import { 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,62 +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 canSeeReportedBanner = content?.abuseFlagged;
return (
<>
{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>
)}
</>
);
@@ -66,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,
@@ -20,13 +15,9 @@ function AuthorLabel({
authorLabel,
linkToProfile,
labelColor,
alert,
}) {
const location = useLocation();
const { courseId } = useContext(DiscussionContext);
let icon = null;
let authorLabelMessage = null;
if (authorLabel === 'Staff') {
icon = Institution;
authorLabelMessage = intl.formatMessage(messages.authorLabelStaff);
@@ -35,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 !== intl.formatMessage(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-gray-700': isRetiredUser,
'text-primary-500': !authorLabelMessage && !isRetiredUser && !alert,
})}
role="heading"
aria-level="2"
>
{isRetiredUser ? '[Deactivated]' : author }
</span>
<>
<span className="mr-1">{author}</span>
{icon && (
<Icon
style={{
@@ -65,48 +39,30 @@ function AuthorLabel({
/>
)}
{authorLabelMessage && (
<span
className={classNames('mr-1 font-size-14 font-style-normal font-family-inter font-weight-500', {
'text-primary-500': !authorLabelMessage && !isRetiredUser && !alert,
'text-gray-700': isRetiredUser,
})}
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,
labelColor: PropTypes.string,
alert: PropTypes.bool,
};
AuthorLabel.defaultProps = {
linkToProfile: false,
authorLabel: null,
labelColor: '',
alert: false,
};
export default injectIntl(AuthorLabel);

View File

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

View File

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

View File

@@ -1,73 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import * as timeago from 'timeago.js';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Alert } 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-1">
{intl.formatMessage(
isQuestion
? messages.answeredLabel
: messages.endorsedLabel,
)}
</span>
<AuthorLabel
author={content.endorsedBy}
authorLabel={content.endorsedByLabel}
linkToProfile
alert={content.endorsed}
/>
{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

@@ -7,14 +7,16 @@ ensureConfig([
'LMS_BASE_URL',
], 'Posts API service');
export const getCourseConfigApiUrl = () => `${getConfig().LMS_BASE_URL}/api/discussion/v1/courses/`;
const apiBaseUrl = getConfig().LMS_BASE_URL;
export const courseConfigApiUrl = `${apiBaseUrl}/api/discussion/v1/courses/`;
/**
* Get discussions course config
* @param {string} courseId
*/
export async function getDiscussionsConfig(courseId) {
const url = `${getCourseConfigApiUrl()}${courseId}/`;
const url = `${courseConfigApiUrl}${courseId}/`;
const { data } = await getAuthenticatedHttpClient().get(url);
return data;
}
@@ -24,7 +26,7 @@ export async function getDiscussionsConfig(courseId) {
* @param {string} courseId
*/
export async function getDiscussionsSettings(courseId) {
const url = `${getCourseConfigApiUrl()}${courseId}/settings`;
const url = `${courseConfigApiUrl}${courseId}/settings`;
const { data } = await getAuthenticatedHttpClient().get(url);
return data;
}

View File

@@ -1,30 +1,19 @@
/* 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';
import { Routes } from '../../data/constants';
import { selectTopicsUnderCategory } from '../../data/selectors';
import { fetchCourseBlocks } from '../../data/thunks';
import { DiscussionContext } from '../common/context';
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,
} from './selectors';
import { discussionsPath, postMessageToParent } from '../utils';
import { selectAreThreadsFiltered, selectPostThreadCount } from './selectors';
import { fetchCourseConfig } from './thunks';
export function useTotalTopicThreadCount() {
@@ -72,7 +61,7 @@ export function useCourseDiscussionData(courseId) {
}, [courseId]);
}
export function useRedirectToThread(courseId, inContext) {
export function useRedirectToThread(courseId) {
const dispatch = useDispatch();
const redirectToThread = useSelector(
(state) => state.threads.redirectToThread,
@@ -85,10 +74,9 @@ export function useRedirectToThread(courseId, inContext) {
// stored in redirectToThread
if (redirectToThread) {
dispatch(clearRedirect());
const newLocation = discussionsPath(Routes.COMMENTS.PAGES[inContext ? 'topics' : 'my-posts'], {
const newLocation = discussionsPath(Routes.COMMENTS.PAGES['my-posts'], {
courseId,
postId: redirectToThread.threadId,
topicId: redirectToThread.topicId,
})(location);
history.push(newLocation);
}
@@ -100,77 +88,56 @@ export function useIsOnDesktop() {
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 canSeeReportedBanner = content.abuseFlagged;
return (
(reasonCodesEnabled && canSeeLastEditOrClosedAlert && (content.lastEdit?.reason || content.closed))
|| (content.abuseFlagged && canSeeReportedBanner)
);
};
export const useShowLearnersTab = () => useSelector(selectLearnersTabEnabled);
/**
* React hook that gets the current topic ID from the current topic or category.
* The topicId in the DiscussionContext only return the direct topicId from the URL.
* If the URL has the current block ID it cannot get the topicID from that. This hook
* gets the topic ID from the URL if available, or from the current category otherwise.
* It only returns an ID if a single ID is available, if navigating a subsection it
* returns null.
* @returns {null|string} A topic ID if a single one available in the current context.
*/
export const useCurrentDiscussionTopic = () => {
const { topicId, category } = useContext(DiscussionContext);
const topics = useSelector(selectTopicsUnderCategory)(category);
if (topicId) {
return topicId;
}
if (topics?.length === 1) {
return topics[0];
}
return null;
};

View File

@@ -1,85 +1,68 @@
import { render } from '@testing-library/react';
import { IntlProvider } from 'react-intl';
import { useRef } from 'react';
import { initializeMockApp } from '@edx/frontend-platform';
import { render, waitFor } from '@testing-library/react';
import { IntlProvider } from 'react-intl';
import { Context as ResponsiveContext } from 'react-responsive';
import { MemoryRouter } from 'react-router';
import { getConfig, initializeMockApp } from '@edx/frontend-platform';
import { AppProvider } from '@edx/frontend-platform/react';
import { initializeStore } from '../../store';
import { DiscussionContext } from '../common/context';
import { useCurrentDiscussionTopic } from './hooks';
import { useContainerSizeForParent } from './hooks';
let store;
initializeMockApp();
describe('Hooks', () => {
describe('useCurrentDiscussionTopic', () => {
function ComponentWithHook() {
const topic = useCurrentDiscussionTopic();
return (
<div>
{String(topic)}
</div>
);
}
function ComponentWithHook() {
const refContainer = useRef(null);
useContainerSizeForParent(refContainer);
return (
<div>
<div ref={refContainer} />
</div>
);
}
function renderComponent({ topicId, category }) {
return render(
<IntlProvider locale="en">
function renderComponent() {
return render(
<IntlProvider locale="en">
<ResponsiveContext.Provider value={{ width: 1280 }}>
<AppProvider store={store}>
<DiscussionContext.Provider
value={{
topicId,
category,
}}
>
<MemoryRouter initialEntries={['/']}>
<ComponentWithHook />
</DiscussionContext.Provider>
</MemoryRouter>
</AppProvider>
</IntlProvider>,
);
}
</ResponsiveContext.Provider>
</IntlProvider>,
);
}
beforeEach(() => {
store = initializeStore({
blocks: {
blocks: {
'some-unit-key': { topics: ['some-topic-0'], parent: 'some-sequence-key' },
'some-sequence-key': { topics: ['some-topic-0'] },
'another-sequence-key': { topics: ['some-topic-1', 'some-topic-2'] },
'empty-key': { topics: [] },
},
},
config: { provider: 'openedx' },
});
});
test('when topicId is in context', () => {
const { queryByText } = renderComponent({ topicId: 'some-topic' });
expect(queryByText('some-topic')).toBeInTheDocument();
});
test('when the category is a unit', () => {
const { queryByText } = renderComponent({ category: 'some-unit-key' });
expect(queryByText('some-topic-0')).toBeInTheDocument();
});
test('when the category is a sequence with one unit', () => {
const { queryByText } = renderComponent({ category: 'some-sequence-key' });
expect(queryByText('some-topic-0')).toBeInTheDocument();
});
test('when the category is a sequence with multiple units', () => {
const { queryByText } = renderComponent({ category: 'another-sequence-key' });
expect(queryByText('null')).toBeInTheDocument();
});
test('when the category is invalid', () => {
const { queryByText } = renderComponent({ category: 'invalid-key' });
expect(queryByText('null')).toBeInTheDocument();
});
test('when the category has no topics', () => {
const { queryByText } = renderComponent({ category: 'empty-key' });
expect(queryByText('null')).toBeInTheDocument();
});
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,24 +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 selectGroupAtSubsection = state => state.config.groupAtSubsection;
export const selectModerationSettings = state => ({
postCloseReasons: state.config.postCloseReasons,
editReasons: state.config.editReasons,

View File

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

View File

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

View File

@@ -1,20 +1,22 @@
import React from 'react';
import React, { useRef } from 'react';
import { useSelector } from 'react-redux';
import { Route, Switch } from 'react-router';
import { injectIntl } from '@edx/frontend-platform/i18n';
import { Routes } from '../../data/constants';
import { CommentsView } from '../comments';
import { useContainerSizeForParent } from '../data/hooks';
import { LearnersContentView } from '../learners';
import { PostEditor } from '../posts';
function DiscussionContent() {
export default function DiscussionContent() {
const refContainer = useRef(null);
const postEditorVisible = useSelector((state) => state.threads.postEditorVisible);
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">
<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 />
@@ -27,11 +29,12 @@ function DiscussionContent() {
<Route path={Routes.COMMENTS.PATH}>
<CommentsView />
</Route>
<Route path={Routes.LEARNERS.LEARNER}>
<LearnersContentView />
</Route>
</Switch>
)}
</div>
</div>
);
}
export default injectIntl(DiscussionContent);

View File

@@ -1,71 +1,38 @@
import React, { useContext, 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 { useWindowSize } from '@edx/paragon';
import { RequestStatus, Routes } from '../../data/constants';
import { DiscussionContext } from '../common/context';
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);
const { height: windowHeight } = useWindowSize();
const { inContext } = useContext(DiscussionContext);
useEffect(() => {
if (sidebarRef && postActionBarHeight && !inContext) {
if (isOnDesktop) {
sidebarRef.current.style.maxHeight = `${windowHeight - postActionBarHeight}px`;
}
sidebarRef.current.style.minHeight = `${windowHeight - postActionBarHeight}px`;
sidebarRef.current.style.top = `${postActionBarHeight}px`;
}
}, [sidebarRef, postActionBarHeight, inContext]);
return (
<div
ref={sidebarRef}
className={classNames('flex-column 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,
'min-content-height': !inContext,
'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 && (
<Route path={Routes.LEARNERS.PATH} component={LearnersView} />
<Redirect
from={Routes.DISCUSSIONS.PATH}
to={{
@@ -73,7 +40,6 @@ export default function DiscussionSidebar({ displaySidebar, postActionBarRef })
pathname: Routes.POSTS.ALL_POSTS,
}}
/>
)}
</Switch>
</div>
);
@@ -81,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,9 +11,7 @@ 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 { getThreadsApiUrl } from '../posts/data/api';
import { threadsApiUrl } from '../posts/data/api';
import DiscussionSidebar from './DiscussionSidebar';
import '../posts/data/__factories__';
@@ -21,7 +19,6 @@ import '../posts/data/__factories__';
let store;
let container;
const courseId = 'course-v1:edX+DemoX+Demo_Course';
const threadsApiUrl = getThreadsApiUrl();
let axiosMock;
function renderComponent(displaySidebar = true, location = `/${courseId}/`) {
@@ -29,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>,
@@ -56,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());
});
@@ -79,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());
@@ -94,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,47 +1,34 @@
import React, { useEffect, useRef } from 'react';
import React, { useEffect } from 'react';
import classNames from 'classnames';
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 { getConfig } from '@edx/frontend-platform';
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 BlackoutInformationBanner from './BlackoutInformationBanner';
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 isFeedbackBannerVisible = getConfig().DISPLAY_FEEDBACK_BANNER === 'true';
const {
courseId,
postId,
@@ -50,13 +37,14 @@ export default function DiscussionsHome() {
learnerUsername,
} = params;
const inContext = new URLSearchParams(location.search).get('inContext') !== null;
// 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.
@@ -65,7 +53,7 @@ export default function DiscussionsHome() {
const provider = useSelector(selectDiscussionProvider);
useCourseDiscussionData(courseId);
useRedirectToThread(courseId, inContext);
useRedirectToThread(courseId);
useEffect(() => {
if (path && path !== 'undefined') {
postMessageToParent('discussions.navigate', { path });
@@ -83,50 +71,38 @@ export default function DiscussionsHome() {
learnerUsername,
}}
>
{!inContext && <Header courseOrg={org} courseNumber={courseNumber} courseTitle={courseTitle} />}
<main className="container-fluid d-flex flex-column p-0 w-100" id="main" tabIndex="-1">
{!inContext && <CourseTabsNavigation activeTab="discussion" courseId={courseId} />}
<main className="container-fluid d-flex flex-column p-0 h-100 w-100 overflow-hidden">
<div
className={classNames('header-action-bar', { 'shadow-none border-light-300 border-bottom': inContext })}
ref={postActionBarRef}
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%)' }}
>
<div
className={classNames('d-flex flex-row justify-content-between navbar fixed-top', {
'pl-4 pr-2.5 py-1.5': inContext,
})}
>
{!inContext && <Route path={Routes.DISCUSSIONS.PATH} component={NavigationBar} />}
<PostActionsBar inContext={inContext} />
</div>
{isFeedbackBannerVisible && <InformationBanner />}
<BlackoutInformationBanner />
{!inContext && (
<Route path={Routes.DISCUSSIONS.PATH} component={NavigationBar} />
)}
<PostActionsBar inContext={inContext} />
</div>
{!inContext && (
<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} />
<Route
path={[Routes.POSTS.PATH, Routes.TOPICS.CATEGORY]}
component={provider === DiscussionProvider.LEGACY ? LegacyBreadcrumbMenu : BreadcrumbMenu}
/>
<div className="d-flex flex-row overflow-hidden flex-grow-1">
<DiscussionSidebar displaySidebar={displaySidebar} />
{displayContentArea && <DiscussionContent />}
{!displayContentArea && (
<Switch>
<Route path={Routes.TOPICS.PATH} component={EmptyTopics} />
<Route
path={Routes.POSTS.MY_POSTS}
render={routeProps => <EmptyPosts {...routeProps} subTitleMessage={messages.emptyMyPosts} />}
/>
<Route
path={[Routes.POSTS.PATH, Routes.POSTS.ALL_POSTS, Routes.LEARNERS.POSTS]}
render={routeProps => <EmptyPosts {...routeProps} subTitleMessage={messages.emptyAllPosts} />}
/>
{isRedirectToLearners && <Route path={Routes.LEARNERS.PATH} component={EmptyLearners} /> }
</Switch>
<Switch>
<Route path={Routes.TOPICS.PATH} component={EmptyTopics} />
<Route
path={Routes.POSTS.MY_POSTS}
render={routeProps => <EmptyPosts {...routeProps} subTitleMessage={messages.emptyMyPosts} />}
/>
<Route
path={[Routes.POSTS.PATH, Routes.POSTS.ALL_POSTS]}
render={routeProps => <EmptyPosts {...routeProps} subTitleMessage={messages.emptyAllPosts} />}
/>
</Switch>
)}
</div>
</main>
{!inContext && <Footer />}
</DiscussionContext.Provider>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -15,7 +15,7 @@ function EmptyPage({
fullWidth = false,
}) {
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

@@ -12,7 +12,7 @@ import { AppProvider } from '@edx/frontend-platform/react';
import { initializeStore } from '../../store';
import { executeThunk } from '../../test-utils';
import messages from '../messages';
import { getThreadsApiUrl } from '../posts/data/api';
import { threadsApiUrl } from '../posts/data/api';
import { fetchThreads } from '../posts/data/thunks';
import EmptyPosts from './EmptyPosts';
@@ -20,7 +20,6 @@ import '../posts/data/__factories__';
let store;
const courseId = 'course-v1:edX+DemoX+Demo_Course';
const threadsApiUrl = getThreadsApiUrl();
function renderComponent(location = `/${courseId}/`) {
return render(

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