Compare commits
41 Commits
inf-706
...
saad/INF-7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3226c729b | ||
|
|
627390c4e3 | ||
|
|
1db94718c8 | ||
|
|
24d02350a8 | ||
|
|
f66cdda1b6 | ||
|
|
07b56e6070 | ||
|
|
be1a2ccaab | ||
|
|
ed0c73e051 | ||
|
|
1041b3e45f | ||
|
|
493a0610ca | ||
|
|
679e21c270 | ||
|
|
62eb9f5e02 | ||
|
|
dedbc25358 | ||
|
|
0f2ad8b7b4 | ||
|
|
61581ff474 | ||
|
|
3afce17a32 | ||
|
|
7e36e9f14c | ||
|
|
c662310b08 | ||
|
|
682a118a9b | ||
|
|
d34d0ebbbc | ||
|
|
6afb7c7763 | ||
|
|
7dca99dfe3 | ||
|
|
137795f254 | ||
|
|
1c2da56e3b | ||
|
|
e99c30f213 | ||
|
|
eacc16b7f1 | ||
|
|
f8800de766 | ||
|
|
f7740de54a | ||
|
|
4ca25c14d9 | ||
|
|
8389d09f22 | ||
|
|
3233d7044d | ||
|
|
22474f4b1e | ||
|
|
b5d4213074 | ||
|
|
71931c83de | ||
|
|
f9ca375853 | ||
|
|
3d8353dc87 | ||
|
|
21bec39c0f | ||
|
|
a479ba5955 | ||
|
|
bafb55afa9 | ||
|
|
62ebd4450f | ||
|
|
04e0bb3264 |
24
.github/pull_request_template.md
vendored
Normal file
24
.github/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
### Description
|
||||
|
||||
Include a description of your changes here, along with a link to any relevant Jira tickets and/or GitHub issues.
|
||||
|
||||
#### How Has This Been Tested?
|
||||
|
||||
Please describe in detail how you tested your changes.
|
||||
|
||||
#### Screenshots/sandbox (optional):
|
||||
Include a link to the sandbox for design changes or screenshot for before and after. **Remove this section if it's not applicable.**
|
||||
|
||||
|Before|After|
|
||||
|-------|-----|
|
||||
| | |
|
||||
|
||||
#### Merge Checklist
|
||||
|
||||
* [ ] If your update includes visual changes, have they been reviewed by a designer? Send them a link to the Sandbox, if applicable.
|
||||
* [ ] Is there adequate test coverage for your changes?
|
||||
|
||||
#### Post-merge Checklist
|
||||
|
||||
* [ ] Deploy the changes to prod after verifying on stage or ask **@openedx/edx-infinity** to do it.
|
||||
* [ ] 🎉 🙌 Celebrate! Thanks for your contribution.
|
||||
@@ -16,4 +16,4 @@ jobs:
|
||||
secrets:
|
||||
GITHUB_APP_ID: ${{ secrets.GRAPHQL_AUTH_APP_ID }}
|
||||
GITHUB_APP_PRIVATE_KEY: ${{ secrets.GRAPHQL_AUTH_APP_PEM }}
|
||||
SLACK_BOT_TOKEN: ${{ secrets.SLACK_ISSUE_BOT_TOKEN }}
|
||||
SLACK_BOT_TOKEN: ${{ secrets.SLACK_ISSUE_BOT_TOKEN }}
|
||||
|
||||
20
.github/workflows/add-remove-label-on-comment.yml
vendored
Normal file
20
.github/workflows/add-remove-label-on-comment.yml
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
# This workflow runs when a comment is made on the ticket
|
||||
# If the comment starts with "label: " it tries to apply
|
||||
# the label indicated in rest of comment.
|
||||
# If the comment starts with "remove label: ", it tries
|
||||
# to remove the indicated label.
|
||||
# Note: Labels are allowed to have spaces and this script does
|
||||
# not parse spaces (as often a space is legitimate), so the command
|
||||
# "label: really long lots of words label" will apply the
|
||||
# label "really long lots of words label"
|
||||
|
||||
name: Allows for the adding and removing of labels via comment
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
jobs:
|
||||
add_remove_labels:
|
||||
uses: openedx/.github/.github/workflows/add-remove-label-on-comment.yml@master
|
||||
|
||||
12
.github/workflows/self-assign-issue.yml
vendored
Normal file
12
.github/workflows/self-assign-issue.yml
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
# This workflow runs when a comment is made on the ticket
|
||||
# If the comment starts with "assign me" it assigns the author to the
|
||||
# ticket (case insensitive)
|
||||
|
||||
name: Assign comment author to ticket if they say "assign me"
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
jobs:
|
||||
self_assign_by_comment:
|
||||
uses: openedx/.github/.github/workflows/self-assign-issue.yml@master
|
||||
51
README.rst
51
README.rst
@@ -1,16 +1,15 @@
|
||||
|Build Status| |Codecov| |license|
|
||||
|
||||
frontend-app-discussions
|
||||
========================
|
||||
|
||||
Please tag **@edx/fedx-team** on any PRs or issues. Thanks.
|
||||
|Build Status| |Codecov| |license|
|
||||
|
||||
Introduction
|
||||
------------
|
||||
Purpose
|
||||
-------
|
||||
|
||||
This repository is a React-based micro frontend for the Open edX discussion forums.
|
||||
|
||||
**Installation and Startup**
|
||||
Getting Started
|
||||
---------------
|
||||
|
||||
1. Clone your new repo:
|
||||
|
||||
@@ -26,6 +25,44 @@ This repository is a React-based micro frontend for the Open edX discussion foru
|
||||
|
||||
The dev server is running at `http://localhost:2002 <http://localhost:2002>`_.
|
||||
|
||||
Getting Help
|
||||
------------
|
||||
Please tag **@openedx/edx-infinity ** on any PRs or issues. Thanks.
|
||||
|
||||
If you're having trouble, we have discussion forums at https://discuss.openedx.org where you can connect with others in the community.
|
||||
For anything non-trivial, the best path is to open an issue in this repository with as many details about the issue you are facing as you can provide.
|
||||
|
||||
https://github.com/openedx/frontend-app-discussions/issues
|
||||
|
||||
For more information about these options, see the `Getting Help`_ page.
|
||||
|
||||
.. _Getting Help: https://openedx.org/getting-help
|
||||
|
||||
How to Contribute
|
||||
-----------------
|
||||
Details about how to become a contributor to the Open edX project may be found in the wiki at `How to contribute`_
|
||||
|
||||
.. _How to contribute: https://edx.readthedocs.io/projects/edx-developer-guide/en/latest/process/index.html
|
||||
|
||||
PR description template should be automatically applied if you are sending PR from github interface; otherwise you
|
||||
can find it it at `PULL_REQUEST_TEMPLATE.md <https://github.com/openedx/frontend-app-discussions/blob/master/.github/pull_request_template.md>`_
|
||||
|
||||
This project is currently accepting all types of contributions, bug fixes and security fixes
|
||||
|
||||
The Open edX Code of Conduct
|
||||
----------------------------
|
||||
All community members should familiarize themselves with the `Open edX Code of Conduct`_.
|
||||
|
||||
.. _Open edX Code of Conduct: https://openedx.org/code-of-conduct/
|
||||
|
||||
People
|
||||
------
|
||||
The assigned maintainers for this component and other project details may be found in Backstage or from inspecting catalog-info.yaml.
|
||||
|
||||
Reporting Security Issues
|
||||
-------------------------
|
||||
Please do not report security issues in public. Please email security@edx.org.
|
||||
|
||||
Project Structure
|
||||
-----------------
|
||||
|
||||
@@ -48,4 +85,4 @@ Please see `edx/frontend-platform's i18n module <https://edx.github.io/frontend-
|
||||
.. |Codecov| image:: https://codecov.io/gh/edx/frontend-app-discussions/branch/master/graph/badge.svg
|
||||
:target: https://codecov.io/gh/edx/frontend-app-discussions
|
||||
.. |license| image:: https://img.shields.io/npm/l/@edx/frontend-app-discussions.svg
|
||||
:target: @edx/frontend-app-discussions
|
||||
:target: @edx/frontend-app-discussions
|
||||
18
catalog-info.yaml
Normal file
18
catalog-info.yaml
Normal file
@@ -0,0 +1,18 @@
|
||||
# This file records information about this repo. Its use is described in OEP-55:
|
||||
# https://open-edx-proposals.readthedocs.io/en/latest/processes/oep-0055-proc-project-maintainers.html
|
||||
|
||||
apiVersion: backstage.io/v1alpha1
|
||||
kind: Component
|
||||
metadata:
|
||||
name: 'frontend-app-discussions'
|
||||
description: "The discussion forum for openEdx discussions"
|
||||
links:
|
||||
- url: "https://github.com/openedx/frontend-app-discussions"
|
||||
title: "Frontend app discussions"
|
||||
icon: "Web"
|
||||
annotations:
|
||||
openedx.org/arch-interest-groups: ""
|
||||
spec:
|
||||
owner: group:edx-infinity
|
||||
type: 'website'
|
||||
lifecycle: 'production'
|
||||
@@ -13,41 +13,178 @@
|
||||
window.MathJax = {
|
||||
tex: {
|
||||
inlineMath: [
|
||||
['$', '$'],
|
||||
['\\\\(', '\\\\)'],
|
||||
['\\(', '\\)'],
|
||||
['[mathjaxinline]', '[/mathjaxinline]'],
|
||||
['\\begin{math}', '\\end{math}'],
|
||||
["$", "$"],
|
||||
["\\\\(", "\\\\)"],
|
||||
["\\(", "\\)"],
|
||||
["[mathjaxinline]", "[/mathjaxinline]"],
|
||||
["\\begin{math}", "\\end{math}"],
|
||||
],
|
||||
displayMath: [
|
||||
['[mathjax]', '[/mathjax]'],
|
||||
['$$', '$$'],
|
||||
['\\\\[', '\\\\]'],
|
||||
['\\[', '\\]'],
|
||||
['\\begin{displaymath}', '\\end{displaymath}'],
|
||||
['\\begin{equation}', '\\end{equation}'],
|
||||
["[mathjax]", "[/mathjax]"],
|
||||
["$$", "$$"],
|
||||
["\\\\[", "\\\\]"],
|
||||
["\\[", "\\]"],
|
||||
["\\begin{displaymath}", "\\end{displaymath}"],
|
||||
["\\begin{equation}", "\\end{equation}"],
|
||||
],
|
||||
processEscapes: true,
|
||||
processEnvironments: true,
|
||||
autoload: {
|
||||
color: [],
|
||||
colorv2: ['color']
|
||||
colorv2: ["color"],
|
||||
},
|
||||
packages: {'[+]': ['noerrors']}
|
||||
packages: { "[+]": ["noerrors"] },
|
||||
},
|
||||
options: {
|
||||
ignoreHtmlClass: 'tex2jax_ignore',
|
||||
processHtmlClass: 'tex2jax_process'
|
||||
ignoreHtmlClass: "tex2jax_ignore",
|
||||
processHtmlClass: "tex2jax_process",
|
||||
},
|
||||
loader: {
|
||||
load: ['input/asciimath', '[tex]/noerrors']
|
||||
}
|
||||
load: ["input/asciimath", "[tex]/noerrors"],
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<script async src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js" id="MathJax-script"></script>
|
||||
|
||||
<script
|
||||
defer
|
||||
src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js"
|
||||
id="MathJax-script"
|
||||
></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root" class="small"></div>
|
||||
|
||||
<!-- begin usabilla live embed code -->
|
||||
<script defer type="text/javascript">
|
||||
window.lightningjs ||
|
||||
(function (n) {
|
||||
var e = "lightningjs";
|
||||
function t(e, t) {
|
||||
var r, i, a, o, d, c;
|
||||
return (
|
||||
t && (t += (/\?/.test(t) ? "&" : "?") + "lv=1"),
|
||||
n[e] ||
|
||||
((r = window),
|
||||
(i = document),
|
||||
(a = e),
|
||||
(o = i.location.protocol),
|
||||
(d = "load"),
|
||||
(c = 0),
|
||||
(function () {
|
||||
n[a] = function () {
|
||||
var t = arguments,
|
||||
i = this,
|
||||
o = ++c,
|
||||
d = (i && i != r && i.id) || 0;
|
||||
function s() {
|
||||
return (s.id = o), n[a].apply(s, arguments);
|
||||
}
|
||||
return (
|
||||
(e.s = e.s || []).push([o, d, t]),
|
||||
(s.then = function (n, t, r) {
|
||||
var i = (e.fh[o] = e.fh[o] || []),
|
||||
a = (e.eh[o] = e.eh[o] || []),
|
||||
d = (e.ph[o] = e.ph[o] || []);
|
||||
return (
|
||||
n && i.push(n), t && a.push(t), r && d.push(r), s
|
||||
);
|
||||
}),
|
||||
s
|
||||
);
|
||||
};
|
||||
var e = (n[a]._ = {});
|
||||
function s() {
|
||||
e.P(d), (e.w = 1), n[a]("_load");
|
||||
}
|
||||
(e.fh = {}),
|
||||
(e.eh = {}),
|
||||
(e.ph = {}),
|
||||
(e.l = t
|
||||
? t.replace(/^\/\//, ("https:" == o ? o : "http:") + "//")
|
||||
: t),
|
||||
(e.p = { 0: +new Date() }),
|
||||
(e.P = function (n) {
|
||||
e.p[n] = new Date() - e.p[0];
|
||||
}),
|
||||
e.w && s(),
|
||||
r.addEventListener
|
||||
? r.addEventListener(d, s, !1)
|
||||
: r.attachEvent("onload", s);
|
||||
var l = function () {
|
||||
function n() {
|
||||
return [
|
||||
"<!DOCTYPE ",
|
||||
o,
|
||||
"><",
|
||||
o,
|
||||
"><head></head><",
|
||||
t,
|
||||
"><",
|
||||
r,
|
||||
' src="',
|
||||
e.l,
|
||||
'"></',
|
||||
r,
|
||||
"></",
|
||||
t,
|
||||
"></",
|
||||
o,
|
||||
">",
|
||||
].join("");
|
||||
}
|
||||
var t = "body",
|
||||
r = "script",
|
||||
o = "html",
|
||||
d = i[t];
|
||||
if (!d) return setTimeout(l, 100);
|
||||
e.P(1);
|
||||
var c,
|
||||
s = i.createElement("div"),
|
||||
h = s.appendChild(i.createElement("div")),
|
||||
u = i.createElement("iframe");
|
||||
(s.style.display = "none"),
|
||||
(d.insertBefore(s, d.firstChild).id = "lightningjs-" + a),
|
||||
(u.frameBorder = "0"),
|
||||
(u.id = "lightningjs-frame-" + a),
|
||||
/MSIE[ ]+6/.test(navigator.userAgent) &&
|
||||
(u.src = "javascript:false"),
|
||||
(u.allowTransparency = "true"),
|
||||
h.appendChild(u);
|
||||
try {
|
||||
u.contentWindow.document.open();
|
||||
} catch (n) {
|
||||
(e.domain = i.domain),
|
||||
(c =
|
||||
"javascript:var d=document.open();d.domain='" +
|
||||
i.domain +
|
||||
"';"),
|
||||
(u.src = c + "void(0);");
|
||||
}
|
||||
try {
|
||||
var p = u.contentWindow.document;
|
||||
p.write(n()), p.close();
|
||||
} catch (e) {
|
||||
u.src =
|
||||
c +
|
||||
'd.write("' +
|
||||
n().replace(/"/g, String.fromCharCode(92) + '"') +
|
||||
'");d.close();';
|
||||
}
|
||||
e.P(2);
|
||||
};
|
||||
e.l && l();
|
||||
})()),
|
||||
(n[e].lv = "1"),
|
||||
n[e]
|
||||
);
|
||||
}
|
||||
var r = (window.lightningjs = t(e));
|
||||
(r.require = t), (r.modules = n);
|
||||
})({});
|
||||
window.usabilla_live = lightningjs.require(
|
||||
"usabilla_live",
|
||||
"//w.usabilla.com/9e6036348fa1.js"
|
||||
);
|
||||
</script>
|
||||
<!-- end usabilla live embed code -->
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -5,30 +5,37 @@ import DOMPurify from 'dompurify';
|
||||
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
|
||||
import { useDebounce } from '../discussions/data/hooks';
|
||||
|
||||
const defaultSanitizeOptions = {
|
||||
USE_PROFILES: { html: true },
|
||||
ADD_ATTR: ['columnalign'],
|
||||
};
|
||||
|
||||
function HTMLLoader({ htmlNode, componentId, cssClassName }) {
|
||||
function HTMLLoader({
|
||||
htmlNode, componentId, cssClassName, testId, delay,
|
||||
}) {
|
||||
const sanitizedMath = DOMPurify.sanitize(htmlNode, { ...defaultSanitizeOptions });
|
||||
const previewRef = useRef();
|
||||
|
||||
const debouncedPostContent = useDebounce(htmlNode, delay);
|
||||
|
||||
useEffect(() => {
|
||||
let promise = Promise.resolve(); // Used to hold chain of typesetting calls
|
||||
function typeset(code) {
|
||||
promise = promise.then(() => window.MathJax.typesetPromise(code()))
|
||||
promise = promise.then(() => window.MathJax?.typesetPromise(code()))
|
||||
.catch((err) => logError(`Typeset failed: ${err.message}`));
|
||||
return promise;
|
||||
}
|
||||
|
||||
typeset(() => {
|
||||
previewRef.current.innerHTML = sanitizedMath;
|
||||
});
|
||||
}, [sanitizedMath]);
|
||||
if (debouncedPostContent) {
|
||||
typeset(() => {
|
||||
previewRef.current.innerHTML = sanitizedMath;
|
||||
});
|
||||
}
|
||||
}, [debouncedPostContent]);
|
||||
|
||||
return (
|
||||
<div ref={previewRef} className={cssClassName} id={componentId} />
|
||||
<div ref={previewRef} className={cssClassName} id={componentId} data-testid={testId} />
|
||||
|
||||
);
|
||||
}
|
||||
@@ -37,12 +44,16 @@ HTMLLoader.propTypes = {
|
||||
htmlNode: PropTypes.node,
|
||||
componentId: PropTypes.string,
|
||||
cssClassName: PropTypes.string,
|
||||
testId: PropTypes.string,
|
||||
delay: PropTypes.number,
|
||||
};
|
||||
|
||||
HTMLLoader.defaultProps = {
|
||||
htmlNode: '',
|
||||
componentId: null,
|
||||
cssClassName: '',
|
||||
testId: '',
|
||||
delay: 0,
|
||||
};
|
||||
|
||||
export default HTMLLoader;
|
||||
|
||||
@@ -29,17 +29,22 @@ function PostPreviewPane({
|
||||
className="float-right p-3"
|
||||
iconClassNames="icon-size"
|
||||
/>
|
||||
<HTMLLoader htmlNode={htmlNode} cssClassName="text-primary" componentId="post-preview" />
|
||||
<HTMLLoader
|
||||
htmlNode={htmlNode}
|
||||
cssClassName="text-primary"
|
||||
componentId="post-preview"
|
||||
testId="post-preview"
|
||||
delay={500}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="d-flex justify-content-end">
|
||||
{!showPreviewPane
|
||||
&& (
|
||||
{!showPreviewPane && (
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
onClick={() => setShowPreviewPane(true)}
|
||||
className={`text-primary-500 p-0 ${editExisting && 'mb-4.5'}`}
|
||||
className={`text-primary-500 font-style p-0 ${editExisting && 'mb-4.5'}`}
|
||||
style={{ lineHeight: '26px' }}
|
||||
>
|
||||
{intl.formatMessage(messages.showPreviewButton)}
|
||||
|
||||
@@ -119,6 +119,7 @@ export default function TinyMCEEditor(props) {
|
||||
content_css: false,
|
||||
content_style: contentStyle,
|
||||
body_class: 'm-2 text-editor',
|
||||
convert_urls: false,
|
||||
relative_urls: false,
|
||||
default_link_target: '_blank',
|
||||
target_list: false,
|
||||
|
||||
108
src/components/TopicStats.jsx
Normal file
108
src/components/TopicStats.jsx
Normal file
@@ -0,0 +1,108 @@
|
||||
/* eslint react/prop-types: 0 */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Icon, OverlayTrigger, Tooltip } from '@edx/paragon';
|
||||
import { HelpOutline, PostOutline, Report } from '@edx/paragon/icons';
|
||||
|
||||
import {
|
||||
selectUserHasModerationPrivileges,
|
||||
selectUserIsGroupTa,
|
||||
} from '../discussions/data/selectors';
|
||||
import messages from '../discussions/in-context-topics/messages';
|
||||
|
||||
function TopicStats({
|
||||
threadCounts,
|
||||
activeFlags,
|
||||
inactiveFlags,
|
||||
intl,
|
||||
}) {
|
||||
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
|
||||
const userIsGroupTa = useSelector(selectUserIsGroupTa);
|
||||
const canSeeReportedStats = (activeFlags || inactiveFlags) && (userHasModerationPrivileges || userIsGroupTa);
|
||||
return (
|
||||
<div className="d-flex align-items-center mt-2.5" style={{ marginBottom: '2px' }}>
|
||||
<OverlayTrigger
|
||||
overlay={(
|
||||
<Tooltip>
|
||||
<div className="d-flex flex-column align-items-start">
|
||||
{intl.formatMessage(messages.discussions, {
|
||||
count: threadCounts?.discussion || 0,
|
||||
})}
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
>
|
||||
<div className="d-flex align-items-center mr-3.5">
|
||||
<Icon src={PostOutline} className="icon-size mr-2" />
|
||||
{threadCounts?.discussion || 0}
|
||||
</div>
|
||||
</OverlayTrigger>
|
||||
<OverlayTrigger
|
||||
overlay={(
|
||||
<Tooltip>
|
||||
<div className="d-flex flex-column align-items-start">
|
||||
{intl.formatMessage(messages.questions, {
|
||||
count: threadCounts?.question || 0,
|
||||
})}
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
>
|
||||
<div className="d-flex align-items-center mr-3.5">
|
||||
<Icon src={HelpOutline} className="icon-size mr-2" />
|
||||
{threadCounts?.question || 0}
|
||||
</div>
|
||||
</OverlayTrigger>
|
||||
{Boolean(canSeeReportedStats) && (
|
||||
<OverlayTrigger
|
||||
overlay={(
|
||||
<Tooltip>
|
||||
<div className="d-flex flex-column align-items-start">
|
||||
{Boolean(activeFlags) && (
|
||||
<span>
|
||||
{intl.formatMessage(messages.reported, { reported: activeFlags })}
|
||||
</span>
|
||||
)}
|
||||
{Boolean(inactiveFlags) && (
|
||||
<span>
|
||||
{intl.formatMessage(messages.previouslyReported, { previouslyReported: inactiveFlags })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
>
|
||||
<div className="d-flex align-items-center">
|
||||
<Icon src={Report} className="icon-size mr-2 text-danger" />
|
||||
{activeFlags}{Boolean(inactiveFlags) && `/${inactiveFlags}`}
|
||||
</div>
|
||||
</OverlayTrigger>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
TopicStats.propTypes = {
|
||||
threadCounts: PropTypes.shape({
|
||||
discussions: PropTypes.number,
|
||||
questions: PropTypes.number,
|
||||
}),
|
||||
activeFlags: PropTypes.number,
|
||||
inactiveFlags: PropTypes.number,
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
TopicStats.defaultProps = {
|
||||
threadCounts: {
|
||||
discussions: 0,
|
||||
questions: 0,
|
||||
},
|
||||
activeFlags: null,
|
||||
inactiveFlags: null,
|
||||
};
|
||||
|
||||
export default injectIntl(TopicStats);
|
||||
@@ -1,3 +1,4 @@
|
||||
export { default as PostActionsBar } from '../discussions/posts/post-actions-bar/PostActionsBar';
|
||||
export { default as Search } from './Search';
|
||||
export { default as TinyMCEEditor } from './TinyMCEEditor';
|
||||
export { default as TopicStats } from './TopicStats';
|
||||
|
||||
@@ -63,6 +63,7 @@ export const ContentActions = {
|
||||
* @enum {string}
|
||||
*/
|
||||
export const RequestStatus = {
|
||||
IDLE: 'idle',
|
||||
IN_PROGRESS: 'in-progress',
|
||||
SUCCESSFUL: 'successful',
|
||||
FAILED: 'failed',
|
||||
|
||||
@@ -1,257 +0,0 @@
|
||||
import React, { useContext, useEffect, useMemo } 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 { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Button, Icon, IconButton, Spinner,
|
||||
} from '@edx/paragon';
|
||||
import { ArrowBack } from '@edx/paragon/icons';
|
||||
|
||||
import {
|
||||
EndorsementStatus, PostsPages, 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 { selectThreadComments, selectThreadCurrentPage, selectThreadHasMorePages } from './data/selectors';
|
||||
import { fetchThreadComments } from './data/thunks';
|
||||
import { Comment, ResponseEditor } from './comment';
|
||||
import messages from './messages';
|
||||
|
||||
function usePost(postId) {
|
||||
const dispatch = useDispatch();
|
||||
const thread = useSelector(selectThread(postId));
|
||||
|
||||
useEffect(() => {
|
||||
if (thread && !thread.read) {
|
||||
dispatch(markThreadAsRead(postId));
|
||||
}
|
||||
}, [postId]);
|
||||
|
||||
return thread;
|
||||
}
|
||||
|
||||
function usePostComments(postId, endorsed = null) {
|
||||
const [isLoading, dispatch] = useDispatchWithState();
|
||||
const comments = useSelector(selectThreadComments(postId, endorsed));
|
||||
const hasMorePages = useSelector(selectThreadHasMorePages(postId, endorsed));
|
||||
const currentPage = useSelector(selectThreadCurrentPage(postId, endorsed));
|
||||
const handleLoadMoreResponses = async () => dispatch(fetchThreadComments(postId, {
|
||||
endorsed,
|
||||
page: currentPage + 1,
|
||||
}));
|
||||
useEffect(() => {
|
||||
dispatch(fetchThreadComments(postId, {
|
||||
endorsed,
|
||||
page: 1,
|
||||
}));
|
||||
}, [postId]);
|
||||
return {
|
||||
comments,
|
||||
hasMorePages,
|
||||
isLoading,
|
||||
handleLoadMoreResponses,
|
||||
};
|
||||
}
|
||||
|
||||
function DiscussionCommentsView({
|
||||
postType,
|
||||
postId,
|
||||
intl,
|
||||
endorsed,
|
||||
isClosed,
|
||||
}) {
|
||||
const {
|
||||
comments,
|
||||
hasMorePages,
|
||||
isLoading,
|
||||
handleLoadMoreResponses,
|
||||
} = usePostComments(postId, endorsed);
|
||||
|
||||
const endorsedComments = useMemo(() => [...filterPosts(comments, 'endorsed')], [comments]);
|
||||
const unEndorsedComments = useMemo(() => [...filterPosts(comments, 'unendorsed')], [comments]);
|
||||
|
||||
const handleDefinition = (message, commentsLength) => (
|
||||
<div className="mx-4 text-primary-700" role="heading" aria-level="2" style={{ lineHeight: '28px' }}>
|
||||
{intl.formatMessage(message, { num: commentsLength })}
|
||||
</div>
|
||||
);
|
||||
|
||||
const handleComments = (postComments, showAddResponse = false, showLoadMoreResponses = false) => (
|
||||
<div className="mx-4" role="list">
|
||||
{postComments.map(comment => (
|
||||
<Comment comment={comment} key={comment.id} postType={postType} isClosedPost={isClosed} />
|
||||
))}
|
||||
{hasMorePages && !isLoading && !showLoadMoreResponses && (
|
||||
<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 && !showLoadMoreResponses && (
|
||||
<div className="card my-4 p-4 d-flex align-items-center">
|
||||
<Spinner animation="border" variant="primary" />
|
||||
</div>
|
||||
)}
|
||||
{!!postComments.length && !isClosed && showAddResponse
|
||||
&& <ResponseEditor postId={postId} addWrappingDiv />}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{((hasMorePages && isLoading) || !isLoading) && (
|
||||
<>
|
||||
{endorsedComments.length > 0 && (
|
||||
<>
|
||||
{handleDefinition(messages.endorsedResponseCount, endorsedComments.length)}
|
||||
{endorsed === EndorsementStatus.DISCUSSION
|
||||
? handleComments(endorsedComments, false, true)
|
||||
: handleComments(endorsedComments)}
|
||||
</>
|
||||
)}
|
||||
{endorsed !== EndorsementStatus.ENDORSED && (
|
||||
<>
|
||||
{handleDefinition(messages.responseCount, unEndorsedComments.length)}
|
||||
{unEndorsedComments.length === 0 && <br />}
|
||||
{handleComments(unEndorsedComments, true)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
]).isRequired,
|
||||
};
|
||||
|
||||
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, enableInContextSidebar,
|
||||
} = 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 && (
|
||||
enableInContextSidebar ? (
|
||||
<>
|
||||
<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': !enableInContextSidebar,
|
||||
'p-4 rounded-0 border-0 mb-4': enableInContextSidebar,
|
||||
})}
|
||||
>
|
||||
<Post post={thread} />
|
||||
{!thread.closed && <ResponseEditor postId={postId} />}
|
||||
</div>
|
||||
{thread.type === ThreadType.DISCUSSION && (
|
||||
<DiscussionCommentsView
|
||||
postId={postId}
|
||||
intl={intl}
|
||||
postType={thread.type}
|
||||
endorsed={EndorsementStatus.DISCUSSION}
|
||||
isClosed={thread.closed}
|
||||
/>
|
||||
)}
|
||||
{thread.type === ThreadType.QUESTION && (
|
||||
<>
|
||||
<DiscussionCommentsView
|
||||
postId={postId}
|
||||
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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
CommentsView.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(CommentsView);
|
||||
@@ -1,730 +0,0 @@
|
||||
import {
|
||||
act, fireEvent, render, screen, waitFor, /* within, */
|
||||
} from '@testing-library/react';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { IntlProvider } from 'react-intl';
|
||||
import { MemoryRouter, Route } from 'react-router';
|
||||
import { Factory } from 'rosie';
|
||||
|
||||
import { camelCaseObject, initializeMockApp } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
|
||||
import { initializeStore } from '../../store';
|
||||
import { executeThunk } from '../../test-utils';
|
||||
import { DiscussionContext } from '../common/context';
|
||||
import { getCourseConfigApiUrl } from '../data/api';
|
||||
import { fetchCourseConfig } from '../data/thunks';
|
||||
import DiscussionContent from '../discussions-home/DiscussionContent';
|
||||
import { getThreadsApiUrl } from '../posts/data/api';
|
||||
import { fetchThreads } from '../posts/data/thunks';
|
||||
import { getCommentsApiUrl } 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';
|
||||
const courseId = 'course-v1:edX+TestX+Test_Course';
|
||||
let store;
|
||||
let axiosMock;
|
||||
// let testLocation;
|
||||
|
||||
function mockAxiosReturnPagedComments() {
|
||||
[null, false, true].forEach(endorsed => {
|
||||
const postId = endorsed === null ? discussionPostId : questionPostId;
|
||||
[1, 2].forEach(page => {
|
||||
axiosMock
|
||||
.onGet(commentsApiUrl, {
|
||||
params: {
|
||||
thread_id: postId,
|
||||
page,
|
||||
page_size: undefined,
|
||||
requested_fields: 'profile_image',
|
||||
endorsed,
|
||||
},
|
||||
})
|
||||
.reply(200, Factory.build('commentsResult', { can_delete: true }, {
|
||||
threadId: postId,
|
||||
page,
|
||||
pageSize: 1,
|
||||
count: 2,
|
||||
endorsed,
|
||||
childCount: page === 1 ? 2 : 0,
|
||||
}));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function mockAxiosReturnPagedCommentsResponses() {
|
||||
const parentId = 'comment-1';
|
||||
const commentsResponsesApiUrl = `${commentsApiUrl}${parentId}/`;
|
||||
const paramsTemplate = {
|
||||
page: undefined,
|
||||
page_size: undefined,
|
||||
requested_fields: 'profile_image',
|
||||
};
|
||||
|
||||
for (let page = 1; page <= 2; page++) {
|
||||
axiosMock
|
||||
.onGet(commentsResponsesApiUrl, { params: { ...paramsTemplate, page } })
|
||||
.reply(200, Factory.build('commentsResult', null, {
|
||||
parentId,
|
||||
page,
|
||||
pageSize: 1,
|
||||
count: 2,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
function renderComponent(postId) {
|
||||
render(
|
||||
<IntlProvider locale="en">
|
||||
<AppProvider store={store}>
|
||||
<DiscussionContext.Provider
|
||||
value={{ courseId }}
|
||||
>
|
||||
<MemoryRouter initialEntries={[`/${courseId}/posts/${postId}`]}>
|
||||
<DiscussionContent />
|
||||
<Route
|
||||
path="*"
|
||||
render={() => null}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
</DiscussionContext.Provider>
|
||||
</AppProvider>
|
||||
</IntlProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('CommentsView', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
store = initializeStore();
|
||||
Factory.resetAll();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
axiosMock.onGet(threadsApiUrl)
|
||||
.reply(200, Factory.build('threadsResult'));
|
||||
axiosMock.onPatch(new RegExp(`${commentsApiUrl}*`)).reply(({
|
||||
url,
|
||||
data,
|
||||
}) => {
|
||||
const commentId = url.match(/comments\/(?<id>[a-z1-9-]+)\//).groups.id;
|
||||
const {
|
||||
rawBody,
|
||||
} = camelCaseObject(JSON.parse(data));
|
||||
return [200, Factory.build('comment', {
|
||||
id: commentId,
|
||||
rendered_body: rawBody,
|
||||
raw_body: rawBody,
|
||||
})];
|
||||
});
|
||||
axiosMock.onPost(commentsApiUrl)
|
||||
.reply(({ data }) => {
|
||||
const {
|
||||
rawBody,
|
||||
threadId,
|
||||
} = camelCaseObject(JSON.parse(data));
|
||||
return [200, Factory.build(
|
||||
'comment',
|
||||
{
|
||||
rendered_body: rawBody,
|
||||
raw_body: rawBody,
|
||||
thread_id: threadId,
|
||||
},
|
||||
)];
|
||||
});
|
||||
|
||||
executeThunk(fetchThreads(courseId), store.dispatch, store.getState);
|
||||
mockAxiosReturnPagedComments();
|
||||
mockAxiosReturnPagedCommentsResponses();
|
||||
});
|
||||
|
||||
describe('for all post types', () => {
|
||||
function assertLastUpdateData(data) {
|
||||
expect(JSON.parse(axiosMock.history.patch[axiosMock.history.patch.length - 1].data)).toMatchObject(data);
|
||||
}
|
||||
|
||||
// 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],
|
||||
// );
|
||||
// });
|
||||
// expect(screen.queryByTestId('tinymce-editor')).toBeInTheDocument();
|
||||
// await act(async () => {
|
||||
// fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
|
||||
// });
|
||||
// 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],
|
||||
// );
|
||||
// });
|
||||
// await act(() => {
|
||||
// fireEvent.change(screen.getByTestId('tinymce-editor'), { target: { value: 'testing123' } });
|
||||
// });
|
||||
//
|
||||
// await act(async () => {
|
||||
// fireEvent.click(
|
||||
// screen.getByText(/submit/i),
|
||||
// );
|
||||
// });
|
||||
// expect(screen.queryByTestId('tinymce-editor')).not.toBeInTheDocument();
|
||||
// await waitFor(async () => expect(await screen.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 }));
|
||||
// await act(async () => {
|
||||
// fireEvent.click(
|
||||
// screen.getAllByRole('button', { name: /add a comment/i })[0],
|
||||
// );
|
||||
// });
|
||||
// act(() => {
|
||||
// fireEvent.change(screen.getByTestId('tinymce-editor'), { target: { value: 'testing123' } });
|
||||
// });
|
||||
//
|
||||
// await act(async () => {
|
||||
// fireEvent.click(
|
||||
// screen.getByText(/submit/i),
|
||||
// );
|
||||
// });
|
||||
// expect(screen.queryByTestId('tinymce-editor')).not.toBeInTheDocument();
|
||||
// await waitFor(async () => expect(await screen.findByText('testing123', { exact: false })).toBeInTheDocument());
|
||||
// });
|
||||
|
||||
it('should 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 }));
|
||||
// await act(async () => {
|
||||
// fireEvent.click(
|
||||
// // The first edit menu is for the post, the second will be for the first comment.
|
||||
// screen.getAllByRole('button', { name: /actions menu/i })[1],
|
||||
// );
|
||||
// });
|
||||
// await act(async () => {
|
||||
// fireEvent.click(screen.getByRole('button', { name: /edit/i }));
|
||||
// });
|
||||
// act(() => {
|
||||
// fireEvent.change(screen.getByTestId('tinymce-editor'), { target: { value: 'testing123' } });
|
||||
// });
|
||||
// await act(async () => {
|
||||
// fireEvent.click(screen.getByRole('button', { name: /submit/i }));
|
||||
// });
|
||||
// await waitFor(async () => {
|
||||
// expect(await screen.findByText('testing123', { exact: false })).toBeInTheDocument();
|
||||
// });
|
||||
// });
|
||||
//
|
||||
async function setupCourseConfig(reasonCodesEnabled = true) {
|
||||
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`).reply(200, {
|
||||
has_moderation_privileges: true,
|
||||
reason_codes_enabled: reasonCodesEnabled,
|
||||
editReasons: [
|
||||
{ code: 'reason-1', label: 'reason 1' },
|
||||
{ code: 'reason-2', label: 'reason 2' },
|
||||
],
|
||||
postCloseReasons: [
|
||||
{ code: 'reason-1', label: 'reason 1' },
|
||||
{ code: 'reason-2', label: 'reason 2' },
|
||||
],
|
||||
});
|
||||
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/settings`).reply(200, {});
|
||||
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
|
||||
}
|
||||
//
|
||||
// it('should show reason codes when editing an existing comment', async () => {
|
||||
// setupCourseConfig();
|
||||
// renderComponent(discussionPostId);
|
||||
// await waitFor(() => screen.findByText('comment number 1', { exact: false }));
|
||||
// await act(async () => {
|
||||
// fireEvent.click(
|
||||
// // The first edit menu is for the post, the second will be for the first comment.
|
||||
// screen.getAllByRole('button', { name: /actions menu/i })[1],
|
||||
// );
|
||||
// });
|
||||
// await act(async () => {
|
||||
// fireEvent.click(screen.getByRole('button', { name: /edit/i }));
|
||||
// });
|
||||
// expect(screen.queryByRole('combobox', { name: /reason for editing/i })).toBeInTheDocument();
|
||||
// expect(screen.getAllByRole('option', { name: /reason \d/i })).toHaveLength(2);
|
||||
// await act(async () => {
|
||||
// fireEvent.change(screen.queryByRole('combobox', { name: /reason for editing/i }),
|
||||
// { target: { value: null } });
|
||||
// });
|
||||
// await act(async () => {
|
||||
// fireEvent.change(screen.queryByRole('combobox',
|
||||
// { name: /reason for editing/i }), { target: { value: 'reason-1' } });
|
||||
// });
|
||||
// await act(async () => {
|
||||
// fireEvent.change(screen.getByTestId('tinymce-editor'), { target: { value: 'testing123' } });
|
||||
// });
|
||||
// await act(async () => {
|
||||
// fireEvent.click(screen.getByRole('button', { name: /submit/i }));
|
||||
// });
|
||||
// assertLastUpdateData({ edit_reason_code: 'reason-1' });
|
||||
// });
|
||||
//
|
||||
// it('should show reason codes when closing a post', async () => {
|
||||
// setupCourseConfig();
|
||||
// renderComponent(discussionPostId);
|
||||
// await act(async () => {
|
||||
// fireEvent.click(
|
||||
// // The first edit menu is for the post
|
||||
// screen.getAllByRole('button', {
|
||||
// name: /actions menu/i,
|
||||
// })[0],
|
||||
// );
|
||||
// });
|
||||
// expect(screen.queryByRole('dialog', { name: /close post/i })).not.toBeInTheDocument();
|
||||
// await act(async () => {
|
||||
// fireEvent.click(screen.getByRole('button', { name: /close/i }));
|
||||
// });
|
||||
// expect(screen.queryByRole('dialog', { name: /close post/i })).toBeInTheDocument();
|
||||
// expect(screen.queryByRole('combobox', { name: /reason/i })).toBeInTheDocument();
|
||||
// expect(screen.getAllByRole('option', { name: /reason \d/i })).toHaveLength(2);
|
||||
// await act(async () => {
|
||||
// fireEvent.change(screen.queryByRole('combobox', { name: /reason/i }), { target: { value: 'reason-1' } });
|
||||
// });
|
||||
// await act(async () => {
|
||||
// fireEvent.click(screen.getByRole('button', { name: /close post/i }));
|
||||
// });
|
||||
// expect(screen.queryByRole('dialog', { name: /close post/i })).not.toBeInTheDocument();
|
||||
// assertLastUpdateData({ closed: true, close_reason_code: 'reason-1' });
|
||||
// });
|
||||
|
||||
// it('should close the post directly if reason codes are not enabled', async () => {
|
||||
// setupCourseConfig(false);
|
||||
// renderComponent(discussionPostId);
|
||||
// await act(async () => {
|
||||
// fireEvent.click(
|
||||
// // The first edit menu is for the post
|
||||
// screen.getAllByRole('button', { name: /actions menu/i })[0],
|
||||
// );
|
||||
// });
|
||||
// expect(screen.queryByRole('dialog', { name: /close post/i })).not.toBeInTheDocument();
|
||||
// await act(async () => {
|
||||
// fireEvent.click(screen.getByRole('button', { name: /close/i }));
|
||||
// });
|
||||
// expect(screen.queryByRole('dialog', { name: /close post/i })).not.toBeInTheDocument();
|
||||
// assertLastUpdateData({ closed: true });
|
||||
// });
|
||||
|
||||
it.each([true, false])(
|
||||
'should reopen the post directly when reason codes enabled=%s',
|
||||
async (reasonCodesEnabled) => {
|
||||
setupCourseConfig(reasonCodesEnabled);
|
||||
renderComponent(closedPostId);
|
||||
await act(async () => {
|
||||
fireEvent.click(
|
||||
// The first edit menu is for the post
|
||||
screen.getAllByRole('button', { name: /actions menu/i })[0],
|
||||
);
|
||||
});
|
||||
expect(screen.queryByRole('dialog', { name: /close post/i })).not.toBeInTheDocument();
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /reopen/i }));
|
||||
});
|
||||
expect(screen.queryByRole('dialog', { name: /close post/i })).not.toBeInTheDocument();
|
||||
assertLastUpdateData({ closed: false });
|
||||
},
|
||||
);
|
||||
|
||||
// it('should show the editor if the post is edited', async () => {
|
||||
// setupCourseConfig(false);
|
||||
// renderComponent(discussionPostId);
|
||||
// await act(async () => {
|
||||
// fireEvent.click(
|
||||
// // The first edit menu is for the post
|
||||
// screen.getAllByRole('button', { name: /actions menu/i })[0],
|
||||
// );
|
||||
// });
|
||||
// await act(async () => {
|
||||
// fireEvent.click(screen.getByRole('button', { name: /edit/i }));
|
||||
// });
|
||||
// expect(testLocation.pathname).toBe(`/${courseId}/posts/${discussionPostId}/edit`);
|
||||
// });
|
||||
|
||||
it('should allow pinning the post', async () => {
|
||||
renderComponent(discussionPostId);
|
||||
await act(async () => {
|
||||
fireEvent.click(
|
||||
// The first edit menu is for the post
|
||||
screen.getAllByRole('button', { name: /actions menu/i })[0],
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /pin/i }));
|
||||
});
|
||||
assertLastUpdateData({ pinned: false });
|
||||
});
|
||||
|
||||
it('should allow reporting the post', async () => {
|
||||
renderComponent(discussionPostId);
|
||||
await act(async () => {
|
||||
fireEvent.click(
|
||||
// The first edit menu is for the post
|
||||
screen.getAllByRole('button', { name: /actions menu/i })[0],
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /report/i }));
|
||||
});
|
||||
expect(screen.queryByRole('dialog', { name: /Report \w+/i, exact: false })).toBeInTheDocument();
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.queryByRole('button', { name: /Confirm/i }));
|
||||
});
|
||||
expect(screen.queryByRole('dialog', { name: /Report \w+/i, exact: false })).not.toBeInTheDocument();
|
||||
assertLastUpdateData({ abuse_flagged: true });
|
||||
});
|
||||
|
||||
// it('handles liking a comment', async () => {
|
||||
// renderComponent(discussionPostId);
|
||||
//
|
||||
// // Wait for the content to load
|
||||
// await screen.findByText('comment number 7', { exact: false });
|
||||
// const view = screen.getByTestId('comment-comment-1');
|
||||
//
|
||||
// const likeButton = within(view).getByRole('button', { name: /like/i });
|
||||
// await act(async () => {
|
||||
// fireEvent.click(likeButton);
|
||||
// });
|
||||
// expect(axiosMock.history.patch).toHaveLength(2);
|
||||
// expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject({ voted: true });
|
||||
// });
|
||||
//
|
||||
// it('handles endorsing comments', async () => {
|
||||
// renderComponent(discussionPostId);
|
||||
// // Wait for the content to load
|
||||
// await screen.findByText('comment number 7', { exact: false });
|
||||
//
|
||||
// // There should be three buttons, one for the post, the second for the
|
||||
// // comment and the third for a response to that comment
|
||||
// const actionButtons = screen.queryAllByRole('button', { name: /actions menu/i });
|
||||
// await act(async () => {
|
||||
// fireEvent.click(actionButtons[1]);
|
||||
// });
|
||||
//
|
||||
// await act(async () => {
|
||||
// fireEvent.click(screen.getByRole('button', { name: /Endorse/i }));
|
||||
// });
|
||||
// expect(axiosMock.history.patch).toHaveLength(2);
|
||||
// expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject({ endorsed: true });
|
||||
// });
|
||||
//
|
||||
// it('handles reporting comments', async () => {
|
||||
// renderComponent(discussionPostId);
|
||||
// // Wait for the content to load
|
||||
// await screen.findByText('comment number 7', { exact: false });
|
||||
//
|
||||
// // There should be three buttons, one for the post, the second for the
|
||||
// // comment and the third for a response to that comment
|
||||
// const actionButtons = screen.queryAllByRole('button', { name: /actions menu/i });
|
||||
// await act(async () => {
|
||||
// fireEvent.click(actionButtons[1]);
|
||||
// });
|
||||
//
|
||||
// await act(async () => {
|
||||
// fireEvent.click(screen.getByRole('button', { name: /Report/i }));
|
||||
// });
|
||||
// expect(screen.queryByRole('dialog', { name: /Report \w+/i, exact: false })).toBeInTheDocument();
|
||||
// await act(async () => {
|
||||
// fireEvent.click(screen.queryByRole('button', { name: /Confirm/i }));
|
||||
// });
|
||||
// expect(screen.queryByRole('dialog', { name: /Report \w+/i, exact: false })).not.toBeInTheDocument();
|
||||
// expect(axiosMock.history.patch).toHaveLength(2);
|
||||
// expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject({ abuse_flagged: true });
|
||||
// });
|
||||
// });
|
||||
//
|
||||
// describe('for discussion thread', () => {
|
||||
// const findLoadMoreCommentsButton = () => screen.findByTestId('load-more-comments');
|
||||
//
|
||||
// it('shown post not found when post id does not belong to course', async () => {
|
||||
// renderComponent('unloaded-id');
|
||||
// expect(await screen.findByText('Thread not found', { exact: true }))
|
||||
// .toBeInTheDocument();
|
||||
// });
|
||||
//
|
||||
// it('initially loads only the first page', async () => {
|
||||
// renderComponent(discussionPostId);
|
||||
// expect(await screen.findByText('comment number 1', { exact: false }))
|
||||
// .toBeInTheDocument();
|
||||
// expect(screen.queryByText('comment number 2', { exact: false }))
|
||||
// .not
|
||||
// .toBeInTheDocument();
|
||||
// });
|
||||
//
|
||||
// it('pressing load more button will load next page of comments', async () => {
|
||||
// renderComponent(discussionPostId);
|
||||
//
|
||||
// const loadMoreButton = await findLoadMoreCommentsButton();
|
||||
// fireEvent.click(loadMoreButton);
|
||||
//
|
||||
// await screen.findByText('comment number 1', { exact: false });
|
||||
// await screen.findByText('comment number 2', { exact: false });
|
||||
// });
|
||||
//
|
||||
// it('newly loaded comments are appended to the old ones', async () => {
|
||||
// renderComponent(discussionPostId);
|
||||
//
|
||||
// const loadMoreButton = await findLoadMoreCommentsButton();
|
||||
// fireEvent.click(loadMoreButton);
|
||||
//
|
||||
// await screen.findByText('comment number 1', { exact: false });
|
||||
// // check that comments from the first page are also displayed
|
||||
// expect(screen.queryByText('comment number 2', { exact: false }))
|
||||
// .toBeInTheDocument();
|
||||
// });
|
||||
//
|
||||
// it('load more button is hidden when no more comments pages to load', async () => {
|
||||
// const totalPages = 2;
|
||||
// renderComponent(discussionPostId);
|
||||
//
|
||||
// const loadMoreButton = await findLoadMoreCommentsButton();
|
||||
// for (let page = 1; page < totalPages; page++) {
|
||||
// fireEvent.click(loadMoreButton);
|
||||
// }
|
||||
//
|
||||
// await screen.findByText('comment number 2', { exact: false });
|
||||
// await expect(findLoadMoreCommentsButton())
|
||||
// .rejects
|
||||
// .toThrow();
|
||||
// });
|
||||
// });
|
||||
//
|
||||
// describe('for question thread', () => {
|
||||
// const findLoadMoreCommentsButtons = () => screen.findAllByTestId('load-more-comments');
|
||||
//
|
||||
// it('initially loads only the first page', async () => {
|
||||
// act(() => renderComponent(questionPostId));
|
||||
// expect(await screen.findByText('comment number 3', { exact: false }))
|
||||
// .toBeInTheDocument();
|
||||
// expect(await screen.findByText('endorsed comment number 5', { exact: false }))
|
||||
// .toBeInTheDocument();
|
||||
// expect(screen.queryByText('comment number 4', { exact: false }))
|
||||
// .not
|
||||
// .toBeInTheDocument();
|
||||
// });
|
||||
//
|
||||
// it('pressing load more button will load next page of comments', async () => {
|
||||
// act(() => {
|
||||
// renderComponent(questionPostId);
|
||||
// });
|
||||
//
|
||||
// const [loadMoreButtonEndorsed, loadMoreButtonUnendorsed] = await findLoadMoreCommentsButtons();
|
||||
// // Both load more buttons should show
|
||||
// expect(await findLoadMoreCommentsButtons()).toHaveLength(2);
|
||||
// expect(await screen.findByText('unendorsed comment number 3', { exact: false }))
|
||||
// .toBeInTheDocument();
|
||||
// expect(await screen.findByText('endorsed comment number 5', { exact: false }))
|
||||
// .toBeInTheDocument();
|
||||
// // Comments from next page should not be loaded yet.
|
||||
// expect(await screen.queryByText('endorsed comment number 6', { exact: false }))
|
||||
// .not
|
||||
// .toBeInTheDocument();
|
||||
// expect(await screen.queryByText('unendorsed comment number 4', { exact: false }))
|
||||
// .not
|
||||
// .toBeInTheDocument();
|
||||
//
|
||||
// await act(async () => {
|
||||
// fireEvent.click(loadMoreButtonEndorsed);
|
||||
// });
|
||||
// // Endorsed comment from next page should be loaded now.
|
||||
// await waitFor(() => expect(screen.queryByText('endorsed comment number 6', { exact: false }))
|
||||
// .toBeInTheDocument());
|
||||
// // Unendorsed comment from next page should not be loaded yet.
|
||||
// expect(await screen.queryByText('unendorsed comment number 4', { exact: false }))
|
||||
// .not
|
||||
// .toBeInTheDocument();
|
||||
// // Now only one load more buttons should show, for unendorsed comments
|
||||
// expect(await findLoadMoreCommentsButtons()).toHaveLength(1);
|
||||
// await act(async () => {
|
||||
// fireEvent.click(loadMoreButtonUnendorsed);
|
||||
// });
|
||||
// // Unendorsed comment from next page should be loaded now.
|
||||
// await waitFor(() => expect(screen.queryByText('unendorsed comment number 4', { exact: false }))
|
||||
// .toBeInTheDocument());
|
||||
// await expect(findLoadMoreCommentsButtons()).rejects.toThrow();
|
||||
// });
|
||||
// });
|
||||
//
|
||||
// describe('comments responses', () => {
|
||||
// const findLoadMoreCommentsResponsesButton = () => screen.findByTestId('load-more-comments-responses');
|
||||
//
|
||||
// it('initially loads only the first page', async () => {
|
||||
// renderComponent(discussionPostId);
|
||||
//
|
||||
// await waitFor(() => screen.findByText('comment number 7', { exact: false }));
|
||||
// expect(screen.queryByText('comment number 8', { exact: false })).not.toBeInTheDocument();
|
||||
// });
|
||||
//
|
||||
// it('pressing load more button will load next page of responses', async () => {
|
||||
// renderComponent(discussionPostId);
|
||||
//
|
||||
// const loadMoreButton = await findLoadMoreCommentsResponsesButton();
|
||||
// await act(async () => {
|
||||
// fireEvent.click(loadMoreButton);
|
||||
// });
|
||||
//
|
||||
// await screen.findByText('comment number 8', { exact: false });
|
||||
// });
|
||||
//
|
||||
// it('newly loaded responses are appended to the old ones', async () => {
|
||||
// renderComponent(discussionPostId);
|
||||
//
|
||||
// const loadMoreButton = await findLoadMoreCommentsResponsesButton();
|
||||
// await act(async () => {
|
||||
// fireEvent.click(loadMoreButton);
|
||||
// });
|
||||
//
|
||||
// await screen.findByText('comment number 8', { exact: false });
|
||||
// // check that comments from the first page are also displayed
|
||||
// expect(screen.queryByText('comment number 7', { exact: false })).toBeInTheDocument();
|
||||
// });
|
||||
//
|
||||
// it('load more button is hidden when no more responses pages to load', async () => {
|
||||
// const totalPages = 2;
|
||||
// renderComponent(discussionPostId);
|
||||
//
|
||||
// const loadMoreButton = await findLoadMoreCommentsResponsesButton();
|
||||
// for (let page = 1; page < totalPages; page++) {
|
||||
// act(() => {
|
||||
// fireEvent.click(loadMoreButton);
|
||||
// });
|
||||
// }
|
||||
//
|
||||
// await screen.findByText('comment number 8', { exact: false });
|
||||
// await expect(findLoadMoreCommentsResponsesButton())
|
||||
// .rejects
|
||||
// .toThrow();
|
||||
// });
|
||||
//
|
||||
// it('handles liking a comment', async () => {
|
||||
// renderComponent(discussionPostId);
|
||||
//
|
||||
// // Wait for the content to load
|
||||
// await screen.findByText('comment number 7', { exact: false });
|
||||
// const view = screen.getByTestId('comment-comment-1');
|
||||
//
|
||||
// const likeButton = within(view).getByRole('button', { name: /like/i });
|
||||
// await act(async () => {
|
||||
// fireEvent.click(likeButton);
|
||||
// });
|
||||
// expect(axiosMock.history.patch).toHaveLength(2);
|
||||
// expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject({ voted: true });
|
||||
// });
|
||||
//
|
||||
// it('handles endorsing comments', async () => {
|
||||
// renderComponent(discussionPostId);
|
||||
// // Wait for the content to load
|
||||
// await screen.findByText('comment number 7', { exact: false });
|
||||
//
|
||||
// // There should be three buttons, one for the post, the second for the
|
||||
// // comment and the third for a response to that comment
|
||||
// const actionButtons = screen.queryAllByRole('button', { name: /actions menu/i });
|
||||
// await act(async () => {
|
||||
// fireEvent.click(actionButtons[1]);
|
||||
// });
|
||||
//
|
||||
// await act(async () => {
|
||||
// fireEvent.click(screen.getByRole('button', { name: /Endorse/i }));
|
||||
// });
|
||||
// expect(axiosMock.history.patch).toHaveLength(2);
|
||||
// expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject({ endorsed: true });
|
||||
// });
|
||||
//
|
||||
// it('handles reporting comments', async () => {
|
||||
// renderComponent(discussionPostId);
|
||||
// // Wait for the content to load
|
||||
// await screen.findByText('comment number 7', { exact: false });
|
||||
//
|
||||
// // There should be three buttons, one for the post, the second for the
|
||||
// // comment and the third for a response to that comment
|
||||
// const actionButtons = screen.queryAllByRole('button', { name: /actions menu/i });
|
||||
// await act(async () => {
|
||||
// fireEvent.click(actionButtons[1]);
|
||||
// });
|
||||
//
|
||||
// await act(async () => {
|
||||
// fireEvent.click(screen.getByRole('button', { name: /Report/i }));
|
||||
// });
|
||||
// expect(screen.queryByRole('dialog', { name: /Report \w+/i, exact: false })).toBeInTheDocument();
|
||||
// await act(async () => {
|
||||
// fireEvent.click(screen.queryByRole('button', { name: /Confirm/i }));
|
||||
// });
|
||||
// expect(screen.queryByRole('dialog', { name: /Report \w+/i, exact: false })).not.toBeInTheDocument();
|
||||
// expect(axiosMock.history.patch).toHaveLength(2);
|
||||
// expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject({ abuse_flagged: true });
|
||||
// });
|
||||
// });
|
||||
//
|
||||
// describe.each([
|
||||
// { component: 'post', testId: 'post-thread-1' },
|
||||
// { component: 'comment', testId: 'comment-comment-1' },
|
||||
// { component: 'reply', testId: 'reply-comment-7' },
|
||||
// ])('delete confirmation modal', ({
|
||||
// component,
|
||||
// testId,
|
||||
// }) => {
|
||||
// test(`for ${component}`, async () => {
|
||||
// renderComponent(discussionPostId);
|
||||
// // Wait for the content to load
|
||||
// await waitFor(() => expect(screen.queryByText('comment number 7', { exact: false })).toBeInTheDocument());
|
||||
// const content = screen.getByTestId(testId);
|
||||
// const actionsButton = within(content).getAllByRole('button', { name: /actions menu/i })[0];
|
||||
// await act(async () => {
|
||||
// fireEvent.click(actionsButton);
|
||||
// });
|
||||
// expect(screen.queryByRole('dialog', { name: /delete \w+/i, exact: false })).not.toBeInTheDocument();
|
||||
// const deleteButton = within(content).queryByRole('button', { name: /delete/i });
|
||||
// await act(async () => {
|
||||
// fireEvent.click(deleteButton);
|
||||
// });
|
||||
// expect(screen.queryByRole('dialog', { name: /delete \w+/i, exact: false })).toBeInTheDocument();
|
||||
// await act(async () => {
|
||||
// fireEvent.click(screen.queryByRole('button', { name: /delete/i }));
|
||||
// });
|
||||
// expect(screen.queryByRole('dialog', { name: /delete \w+/i, exact: false })).not.toBeInTheDocument();
|
||||
// });
|
||||
});
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { useDispatch } from 'react-redux';
|
||||
import * as timeago from 'timeago.js';
|
||||
|
||||
import { injectIntl } 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,
|
||||
}) {
|
||||
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">
|
||||
<LikeButton
|
||||
count={comment.voteCount}
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
CommentIcons.propTypes = {
|
||||
comment: PropTypes.shape({
|
||||
id: PropTypes.string,
|
||||
voteCount: PropTypes.number,
|
||||
following: PropTypes.bool,
|
||||
voted: PropTypes.bool,
|
||||
createdAt: PropTypes.string,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(CommentIcons);
|
||||
@@ -1,98 +0,0 @@
|
||||
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 { AvatarOutlineAndLabelColors, EndorsementStatus, 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({
|
||||
comment,
|
||||
postType,
|
||||
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}`);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className={classNames('d-flex flex-row justify-content-between', {
|
||||
'mt-2': hasAnyAlert,
|
||||
})}
|
||||
>
|
||||
<div className="align-items-center d-flex flex-row">
|
||||
<Avatar
|
||||
className={`border-0 ml-0.5 mr-2.5 ${colorClass ? `outline-${colorClass}` : 'outline-anonymous'}`}
|
||||
alt={comment.author}
|
||||
src={authorAvatars?.imageUrlSmall}
|
||||
style={{
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
}}
|
||||
/>
|
||||
<AuthorLabel
|
||||
author={comment.author}
|
||||
authorLabel={comment.authorLabel}
|
||||
labelColor={colorClass && `text-${colorClass}`}
|
||||
linkToProfile
|
||||
/>
|
||||
</div>
|
||||
<div className="d-flex align-items-center">
|
||||
|
||||
{actionIcons && (
|
||||
<span className="btn-icon btn-icon-sm mr-1 align-items-center pointer-cursor-hover">
|
||||
<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>
|
||||
)}
|
||||
|
||||
<ActionsDropdown
|
||||
commentOrPost={{
|
||||
...comment,
|
||||
postType,
|
||||
}}
|
||||
actionHandlers={actionHandlers}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
CommentHeader.propTypes = {
|
||||
comment: commentShape.isRequired,
|
||||
actionHandlers: PropTypes.objectOf(PropTypes.func).isRequired,
|
||||
postType: PropTypes.oneOf([ThreadType.QUESTION, ThreadType.DISCUSSION]).isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(CommentHeader);
|
||||
@@ -1,57 +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 { AppProvider } from '@edx/frontend-platform/react';
|
||||
|
||||
import { initializeStore } from '../../../store';
|
||||
import { DiscussionContext } from '../../common/context';
|
||||
import CommentHeader from './CommentHeader';
|
||||
|
||||
let store;
|
||||
|
||||
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>
|
||||
</AppProvider>
|
||||
</IntlProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const mockComment = {
|
||||
author: 'abc123',
|
||||
authorLabel: 'ABC 123',
|
||||
endorsed: true,
|
||||
editableFields: ['endorsed'],
|
||||
};
|
||||
|
||||
describe('Comment Header', () => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore();
|
||||
});
|
||||
|
||||
it('should render verified icon for endorsed discussion posts', () => {
|
||||
renderComponent(mockComment, 'discussion', {});
|
||||
expect(screen.queryAllByTestId('check-icon')).toHaveLength(1);
|
||||
});
|
||||
it('should render check icon for endorsed question posts', () => {
|
||||
renderComponent(mockComment, 'question', {});
|
||||
expect(screen.queryAllByTestId('check-icon')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -1,63 +0,0 @@
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Button } from '@edx/paragon';
|
||||
|
||||
import { DiscussionContext } from '../../common/context';
|
||||
import { useUserCanAddThreadInBlackoutDate } from '../../data/hooks';
|
||||
import messages from '../messages';
|
||||
import CommentEditor from './CommentEditor';
|
||||
|
||||
function ResponseEditor({
|
||||
postId,
|
||||
intl,
|
||||
addWrappingDiv,
|
||||
}) {
|
||||
const { enableInContextSidebar } = useContext(DiscussionContext);
|
||||
const [addingResponse, setAddingResponse] = useState(false);
|
||||
const userCanAddThreadInBlackoutDate = useUserCanAddThreadInBlackoutDate();
|
||||
|
||||
useEffect(() => {
|
||||
setAddingResponse(false);
|
||||
}, [postId]);
|
||||
|
||||
return addingResponse
|
||||
? (
|
||||
<div className={classNames({ 'bg-white p-4 mb-4 rounded': addWrappingDiv })}>
|
||||
<CommentEditor
|
||||
comment={{ threadId: postId }}
|
||||
edit={false}
|
||||
onCloseEditor={() => setAddingResponse(false)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
: userCanAddThreadInBlackoutDate && (
|
||||
<div className={classNames({ 'mb-4': addWrappingDiv }, 'actions d-flex')}>
|
||||
<Button
|
||||
variant="primary"
|
||||
className={classNames('px-2.5 py-2 font-size-14', { 'w-100': enableInContextSidebar })}
|
||||
onClick={() => setAddingResponse(true)}
|
||||
style={{
|
||||
lineHeight: '20px',
|
||||
}}
|
||||
>
|
||||
{intl.formatMessage(messages.addResponse)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ResponseEditor.propTypes = {
|
||||
postId: PropTypes.string.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
addWrappingDiv: PropTypes.bool,
|
||||
};
|
||||
|
||||
ResponseEditor.defaultProps = {
|
||||
addWrappingDiv: false,
|
||||
};
|
||||
|
||||
export default injectIntl(ResponseEditor);
|
||||
@@ -1,2 +0,0 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export { default as CommentsView } from './CommentsView';
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useContext, useState } from 'react';
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { useSelector } from 'react-redux';
|
||||
@@ -11,53 +11,68 @@ import {
|
||||
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 { commentShape } from '../post-comments/comments/comment/proptypes';
|
||||
import { postShape } from '../posts/post/proptypes';
|
||||
import { inBlackoutDateRange, useActions } from '../utils';
|
||||
import { DiscussionContext } from './context';
|
||||
|
||||
function ActionsDropdown({
|
||||
intl,
|
||||
commentOrPost,
|
||||
disabled,
|
||||
actionHandlers,
|
||||
iconSize,
|
||||
dropDownIconSize,
|
||||
}) {
|
||||
const buttonRef = useRef();
|
||||
const [isOpen, open, close] = useToggle(false);
|
||||
const [target, setTarget] = useState(null);
|
||||
const actions = useActions(commentOrPost);
|
||||
const { enableInContextSidebar } = useContext(DiscussionContext);
|
||||
const handleActions = (action) => {
|
||||
|
||||
const handleActions = useCallback((action) => {
|
||||
const actionFunction = actionHandlers[action];
|
||||
if (actionFunction) {
|
||||
actionFunction();
|
||||
} else {
|
||||
logError(`Unknown or unimplemented action ${action}`);
|
||||
}
|
||||
};
|
||||
}, [actionHandlers]);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
const onClickButton = useCallback(() => {
|
||||
setTarget(buttonRef.current);
|
||||
open();
|
||||
}, [open]);
|
||||
|
||||
const onCloseModal = useCallback(() => {
|
||||
close();
|
||||
setTarget(null);
|
||||
}, [close]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
onClick={open}
|
||||
onClick={onClickButton}
|
||||
alt={intl.formatMessage(messages.actionsAlt)}
|
||||
src={MoreHoriz}
|
||||
iconAs={Icon}
|
||||
disabled={disabled}
|
||||
size="sm"
|
||||
ref={setTarget}
|
||||
size={iconSize}
|
||||
ref={buttonRef}
|
||||
iconClassNames={dropDownIconSize ? 'dropdown-icon-dimentions' : ''}
|
||||
/>
|
||||
<div className="actions-dropdown">
|
||||
<ModalPopup
|
||||
onClose={close}
|
||||
onClose={onCloseModal}
|
||||
positionRef={target}
|
||||
isOpen={isOpen}
|
||||
placement={enableInContextSidebar ? 'left' : 'auto-start'}
|
||||
placement="bottom-end"
|
||||
>
|
||||
<div
|
||||
className="bg-white p-1 shadow d-flex flex-column"
|
||||
@@ -66,7 +81,7 @@ function ActionsDropdown({
|
||||
{actions.map(action => (
|
||||
<React.Fragment key={action.id}>
|
||||
{(action.action === ContentActions.DELETE)
|
||||
&& <Dropdown.Divider />}
|
||||
&& <Dropdown.Divider />}
|
||||
|
||||
<Dropdown.Item
|
||||
as={Button}
|
||||
@@ -94,10 +109,14 @@ ActionsDropdown.propTypes = {
|
||||
commentOrPost: PropTypes.oneOfType([commentShape, postShape]).isRequired,
|
||||
disabled: PropTypes.bool,
|
||||
actionHandlers: PropTypes.objectOf(PropTypes.func).isRequired,
|
||||
iconSize: PropTypes.string,
|
||||
dropDownIconSize: PropTypes.bool,
|
||||
};
|
||||
|
||||
ActionsDropdown.defaultProps = {
|
||||
disabled: false,
|
||||
iconSize: 'sm',
|
||||
dropDownIconSize: false,
|
||||
};
|
||||
|
||||
export default injectIntl(ActionsDropdown);
|
||||
|
||||
@@ -14,7 +14,7 @@ import messages from '../messages';
|
||||
import { ACTIONS_LIST } from '../utils';
|
||||
import ActionsDropdown from './ActionsDropdown';
|
||||
|
||||
import '../comments/data/__factories__';
|
||||
import '../post-comments/data/__factories__';
|
||||
import '../posts/data/__factories__';
|
||||
|
||||
let store;
|
||||
|
||||
@@ -8,11 +8,11 @@ import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Alert } from '@edx/paragon';
|
||||
import { Report } from '@edx/paragon/icons';
|
||||
|
||||
import { commentShape } from '../comments/comment/proptypes';
|
||||
import messages from '../comments/messages';
|
||||
import {
|
||||
selectModerationSettings, selectUserHasModerationPrivileges, selectUserIsGroupTa, selectUserIsStaff,
|
||||
} from '../data/selectors';
|
||||
import { commentShape } from '../post-comments/comments/comment/proptypes';
|
||||
import messages from '../post-comments/messages';
|
||||
import { postShape } from '../posts/post/proptypes';
|
||||
import AuthorLabel from './AuthorLabel';
|
||||
|
||||
@@ -33,32 +33,45 @@ function AlertBanner({
|
||||
return (
|
||||
<>
|
||||
{canSeeReportedBanner && (
|
||||
<Alert icon={Report} variant="danger" className="px-3 mb-2 py-10px shadow-none flex-fill">
|
||||
<Alert icon={Report} variant="danger" className="px-3 mb-1 py-10px shadow-none 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">
|
||||
<Alert variant="info" className="px-3 shadow-none mb-1 py-10px bg-light-200">
|
||||
<div className="d-flex align-items-center flex-wrap text-gray-700 font-style">
|
||||
{intl.formatMessage(messages.editedBy)}
|
||||
<span className="ml-1 mr-3">
|
||||
<AuthorLabel author={content.lastEdit.editorUsername} linkToProfile />
|
||||
<AuthorLabel author={content.lastEdit.editorUsername} linkToProfile postOrComment />
|
||||
</span>
|
||||
<span
|
||||
className="mx-1.5 font-size-8 font-style text-light-700"
|
||||
style={{ lineHeight: '15px' }}
|
||||
>
|
||||
{intl.formatMessage(messages.fullStop)}
|
||||
</span>
|
||||
{intl.formatMessage(messages.reason)}: {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">
|
||||
<Alert variant="info" className="px-3 shadow-none mb-1 py-10px bg-light-200">
|
||||
<div className="d-flex align-items-center flex-wrap text-gray-700 font-style">
|
||||
{intl.formatMessage(messages.closedBy)}
|
||||
<span className="ml-1 ">
|
||||
<AuthorLabel author={content.closedBy} linkToProfile />
|
||||
<AuthorLabel author={content.closedBy} linkToProfile postOrComment />
|
||||
</span>
|
||||
<span className="mx-1" />
|
||||
<span
|
||||
className="mx-1.5 font-size-8 font-style text-light-700"
|
||||
style={{ lineHeight: '15px' }}
|
||||
>
|
||||
{intl.formatMessage(messages.fullStop)}
|
||||
</span>
|
||||
|
||||
{content.closeReason && (`${intl.formatMessage(messages.reason)}: ${content.closeReason}`)}
|
||||
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
@@ -7,11 +7,11 @@ import { AppProvider } from '@edx/frontend-platform/react';
|
||||
|
||||
import { ThreadType } from '../../data/constants';
|
||||
import { initializeStore } from '../../store';
|
||||
import messages from '../comments/messages';
|
||||
import messages from '../post-comments/messages';
|
||||
import AlertBanner from './AlertBanner';
|
||||
import { DiscussionContext } from './context';
|
||||
|
||||
import '../comments/data/__factories__';
|
||||
import '../post-comments/data/__factories__';
|
||||
import '../posts/data/__factories__';
|
||||
|
||||
let store;
|
||||
|
||||
@@ -3,9 +3,10 @@ import PropTypes from 'prop-types';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import * as timeago from 'timeago.js';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Icon } from '@edx/paragon';
|
||||
import { Icon, OverlayTrigger, Tooltip } from '@edx/paragon';
|
||||
import { Institution, School } from '@edx/paragon/icons';
|
||||
|
||||
import { Routes } from '../../data/constants';
|
||||
@@ -13,6 +14,7 @@ import { useShowLearnersTab } from '../data/hooks';
|
||||
import messages from '../messages';
|
||||
import { discussionsPath } from '../utils';
|
||||
import { DiscussionContext } from './context';
|
||||
import timeLocale from './time-locale';
|
||||
|
||||
function AuthorLabel({
|
||||
intl,
|
||||
@@ -21,11 +23,15 @@ function AuthorLabel({
|
||||
linkToProfile,
|
||||
labelColor,
|
||||
alert,
|
||||
postCreatedAt,
|
||||
authorToolTip,
|
||||
postOrComment,
|
||||
}) {
|
||||
const location = useLocation();
|
||||
const { courseId } = useContext(DiscussionContext);
|
||||
let icon = null;
|
||||
let authorLabelMessage = null;
|
||||
timeago.register('time-locale', timeLocale);
|
||||
|
||||
if (authorLabel === 'Staff') {
|
||||
icon = Institution;
|
||||
@@ -37,37 +43,56 @@ function AuthorLabel({
|
||||
}
|
||||
|
||||
const isRetiredUser = author ? author.startsWith('retired__user') : false;
|
||||
const showTextPrimary = !authorLabelMessage && !isRetiredUser && !alert;
|
||||
|
||||
const className = classNames('d-flex align-items-center mb-0.5', labelColor);
|
||||
const className = classNames('d-flex align-items-center', { 'mb-0.5': !postOrComment }, labelColor);
|
||||
|
||||
const showUserNameAsLink = useShowLearnersTab()
|
||||
&& linkToProfile && author && author !== intl.formatMessage(messages.anonymous);
|
||||
&& 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>
|
||||
{icon && (
|
||||
<Icon
|
||||
style={{
|
||||
width: '1rem',
|
||||
height: '1rem',
|
||||
}}
|
||||
src={icon}
|
||||
/>
|
||||
{!alert && (
|
||||
<span
|
||||
className={classNames('mr-1.5 font-size-14 font-style font-weight-500', {
|
||||
'text-gray-700': isRetiredUser,
|
||||
'text-primary-500': !authorLabelMessage && !isRetiredUser,
|
||||
})}
|
||||
role="heading"
|
||||
aria-level="2"
|
||||
>
|
||||
{isRetiredUser ? '[Deactivated]' : author}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<OverlayTrigger
|
||||
overlay={(
|
||||
<Tooltip id={`endorsed-by-${author}-tooltip`}>
|
||||
{author}
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
trigger={['hover', 'focus']}
|
||||
>
|
||||
<div className={classNames('d-flex flex-row align-items-center', {
|
||||
'disable-div': !authorToolTip,
|
||||
})}
|
||||
>
|
||||
<Icon
|
||||
style={{
|
||||
width: '1rem',
|
||||
height: '1rem',
|
||||
}}
|
||||
src={icon}
|
||||
data-testid="author-icon"
|
||||
/>
|
||||
|
||||
</div>
|
||||
</OverlayTrigger>
|
||||
{authorLabelMessage && (
|
||||
<span
|
||||
className={classNames('mr-1 font-size-14 font-style-normal font-family-inter font-weight-500', {
|
||||
'text-primary-500': !authorLabelMessage && !isRetiredUser && !alert,
|
||||
className={classNames('mr-1.5 font-size-14 font-style font-weight-500', {
|
||||
'text-primary-500': showTextPrimary,
|
||||
'text-gray-700': isRetiredUser,
|
||||
})}
|
||||
style={{ marginLeft: '2px' }}
|
||||
@@ -75,6 +100,19 @@ function AuthorLabel({
|
||||
{authorLabelMessage}
|
||||
</span>
|
||||
)}
|
||||
{postCreatedAt && (
|
||||
<span
|
||||
title={postCreatedAt}
|
||||
className={classNames('font-family-inter align-content-center', {
|
||||
'text-white': alert,
|
||||
'text-gray-500': !alert,
|
||||
})}
|
||||
style={{ lineHeight: '20px', fontSize: '12px', marginBottom: '-2.3px' }}
|
||||
>
|
||||
{timeago.format(postCreatedAt, 'time-locale')}
|
||||
</span>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -100,6 +138,9 @@ AuthorLabel.propTypes = {
|
||||
linkToProfile: PropTypes.bool,
|
||||
labelColor: PropTypes.string,
|
||||
alert: PropTypes.bool,
|
||||
postCreatedAt: PropTypes.string,
|
||||
authorToolTip: PropTypes.bool,
|
||||
postOrComment: PropTypes.bool,
|
||||
};
|
||||
|
||||
AuthorLabel.defaultProps = {
|
||||
@@ -107,6 +148,9 @@ AuthorLabel.defaultProps = {
|
||||
authorLabel: null,
|
||||
labelColor: '',
|
||||
alert: false,
|
||||
postCreatedAt: null,
|
||||
authorToolTip: false,
|
||||
postOrComment: false,
|
||||
};
|
||||
|
||||
export default injectIntl(AuthorLabel);
|
||||
|
||||
@@ -4,12 +4,12 @@ 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 { Alert, Icon } 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 { commentShape } from '../post-comments/comments/comment/proptypes';
|
||||
import messages from '../post-comments/messages';
|
||||
import AuthorLabel from './AuthorLabel';
|
||||
import timeLocale from './time-locale';
|
||||
|
||||
@@ -27,32 +27,32 @@ function EndorsedAlertBanner({
|
||||
content.endorsed && (
|
||||
<Alert
|
||||
variant="plain"
|
||||
className={`px-3 mb-0 py-10px align-items-center shadow-none ${classes}`}
|
||||
className={`px-2.5 mb-0 py-8px 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>
|
||||
<div className="d-flex align-items-center">
|
||||
<Icon
|
||||
src={iconClass}
|
||||
style={{
|
||||
width: '21px',
|
||||
height: '20px',
|
||||
}}
|
||||
/>
|
||||
<strong className="ml-2 font-family-inter">
|
||||
{intl.formatMessage(isQuestion ? messages.answer : messages.endorsed)}
|
||||
</strong>
|
||||
</div>
|
||||
<span className="d-flex align-items-center align-items-center flex-wrap" style={{ marginRight: '-1px' }}>
|
||||
<AuthorLabel
|
||||
author={content.endorsedBy}
|
||||
authorLabel={content.endorsedByLabel}
|
||||
linkToProfile
|
||||
alert={content.endorsed}
|
||||
postCreatedAt={content.endorsedAt}
|
||||
authorToolTip
|
||||
postOrComment
|
||||
/>
|
||||
{intl.formatMessage(messages.time, { time: timeago.format(content.endorsedAt, 'time-locale') })}
|
||||
</span>
|
||||
</div>
|
||||
</Alert>
|
||||
|
||||
@@ -7,11 +7,11 @@ import { AppProvider } from '@edx/frontend-platform/react';
|
||||
|
||||
import { ThreadType } from '../../data/constants';
|
||||
import { initializeStore } from '../../store';
|
||||
import messages from '../comments/messages';
|
||||
import messages from '../post-comments/messages';
|
||||
import { DiscussionContext } from './context';
|
||||
import EndorsedAlertBanner from './EndorsedAlertBanner';
|
||||
|
||||
import '../comments/data/__factories__';
|
||||
import '../post-comments/data/__factories__';
|
||||
import '../posts/data/__factories__';
|
||||
|
||||
let store;
|
||||
@@ -46,21 +46,21 @@ describe.each([
|
||||
type: 'comment',
|
||||
postType: ThreadType.QUESTION,
|
||||
props: { endorsed: true, endorsedBy: 'test-user', endorsedByLabel: 'Staff' },
|
||||
expectText: [messages.answer.defaultMessage, messages.answeredLabel.defaultMessage, 'test-user', 'Staff'],
|
||||
expectText: [messages.answer.defaultMessage, 'Staff'],
|
||||
},
|
||||
{
|
||||
label: 'TA endorsed comment in a question thread',
|
||||
type: 'comment',
|
||||
postType: ThreadType.QUESTION,
|
||||
props: { endorsed: true, endorsedBy: 'test-user', endorsedByLabel: 'Community TA' },
|
||||
expectText: [messages.answer.defaultMessage, messages.answeredLabel.defaultMessage, 'test-user', 'TA'],
|
||||
expectText: [messages.answer.defaultMessage, 'TA'],
|
||||
},
|
||||
{
|
||||
label: 'endorsed comment in a discussion thread',
|
||||
type: 'comment',
|
||||
postType: ThreadType.DISCUSSION,
|
||||
props: { endorsed: true, endorsedBy: 'test-user' },
|
||||
expectText: [messages.endorsed.defaultMessage, messages.endorsedLabel.defaultMessage, 'test-user'],
|
||||
expectText: [messages.endorsed.defaultMessage],
|
||||
},
|
||||
])('EndorsedAlertBanner', ({
|
||||
label, type, postType, props, expectText,
|
||||
|
||||
116
src/discussions/common/HoverCard.jsx
Normal file
116
src/discussions/common/HoverCard.jsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import React, { useContext } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { injectIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Button, Icon, IconButton } from '@edx/paragon';
|
||||
|
||||
import {
|
||||
StarFilled, StarOutline, ThumbUpFilled, ThumbUpOutline,
|
||||
} from '../../components/icons';
|
||||
import { useUserCanAddThreadInBlackoutDate } from '../data/hooks';
|
||||
import { commentShape } from '../post-comments/comments/comment/proptypes';
|
||||
import { postShape } from '../posts/post/proptypes';
|
||||
import ActionsDropdown from './ActionsDropdown';
|
||||
import { DiscussionContext } from './context';
|
||||
|
||||
function HoverCard({
|
||||
commentOrPost,
|
||||
actionHandlers,
|
||||
handleResponseCommentButton,
|
||||
addResponseCommentButtonMessage,
|
||||
onLike,
|
||||
onFollow,
|
||||
isClosedPost,
|
||||
endorseIcons,
|
||||
}) {
|
||||
const { enableInContextSidebar } = useContext(DiscussionContext);
|
||||
const userCanAddThreadInBlackoutDate = useUserCanAddThreadInBlackoutDate();
|
||||
return (
|
||||
<div
|
||||
className="flex-fill justify-content-end align-items-center hover-card mr-n4 position-absolute"
|
||||
data-testid={`hover-card-${commentOrPost.id}`}
|
||||
id={`hover-card-${commentOrPost.id}`}
|
||||
>
|
||||
{userCanAddThreadInBlackoutDate && (
|
||||
<div className="d-flex">
|
||||
<Button
|
||||
variant="tertiary"
|
||||
className={classNames('px-2.5 py-2 border-0 font-style text-gray-700 font-size-12',
|
||||
{ 'w-100': enableInContextSidebar })}
|
||||
onClick={() => handleResponseCommentButton()}
|
||||
disabled={isClosedPost}
|
||||
style={{ lineHeight: '20px' }}
|
||||
>
|
||||
{addResponseCommentButtonMessage}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{endorseIcons && (
|
||||
<div className="hover-button">
|
||||
<IconButton
|
||||
src={endorseIcons.icon}
|
||||
iconAs={Icon}
|
||||
onClick={() => {
|
||||
const actionFunction = actionHandlers[endorseIcons.action];
|
||||
actionFunction();
|
||||
}}
|
||||
className={['endorse', 'unendorse'].includes(endorseIcons.id) ? 'text-dark-500' : 'text-success-500'}
|
||||
size="sm"
|
||||
alt="Endorse"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="hover-button">
|
||||
<IconButton
|
||||
src={commentOrPost.voted ? ThumbUpFilled : ThumbUpOutline}
|
||||
iconAs={Icon}
|
||||
size="sm"
|
||||
alt="Like"
|
||||
iconClassNames="like-icon-dimentions"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onLike();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{commentOrPost.following !== undefined && (
|
||||
<div className="hover-button">
|
||||
<IconButton
|
||||
src={commentOrPost.following ? StarFilled : StarOutline}
|
||||
iconAs={Icon}
|
||||
size="sm"
|
||||
alt="Follow"
|
||||
iconClassNames="follow-icon-dimentions"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onFollow();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="hover-button ml-auto">
|
||||
<ActionsDropdown commentOrPost={commentOrPost} actionHandlers={actionHandlers} dropDownIconSize />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
HoverCard.propTypes = {
|
||||
commentOrPost: PropTypes.oneOfType([commentShape, postShape]).isRequired,
|
||||
actionHandlers: PropTypes.objectOf(PropTypes.func).isRequired,
|
||||
handleResponseCommentButton: PropTypes.func.isRequired,
|
||||
onLike: PropTypes.func.isRequired,
|
||||
onFollow: PropTypes.func,
|
||||
addResponseCommentButtonMessage: PropTypes.string.isRequired,
|
||||
isClosedPost: PropTypes.bool.isRequired,
|
||||
endorseIcons: PropTypes.objectOf(PropTypes.any),
|
||||
};
|
||||
|
||||
HoverCard.defaultProps = {
|
||||
onFollow: () => null,
|
||||
endorseIcons: null,
|
||||
};
|
||||
|
||||
export default injectIntl(HoverCard);
|
||||
188
src/discussions/common/HoverCard.test.jsx
Normal file
188
src/discussions/common/HoverCard.test.jsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import {
|
||||
render, screen, waitFor,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { IntlProvider } from 'react-intl';
|
||||
import { MemoryRouter, Route } from 'react-router';
|
||||
import { Factory } from 'rosie';
|
||||
|
||||
import { camelCaseObject, initializeMockApp } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
|
||||
import { initializeStore } from '../../store';
|
||||
import { executeThunk } from '../../test-utils';
|
||||
import DiscussionContent from '../discussions-home/DiscussionContent';
|
||||
import { getCommentsApiUrl } from '../post-comments/data/api';
|
||||
import { getThreadsApiUrl } from '../posts/data/api';
|
||||
import { fetchThreads } from '../posts/data/thunks';
|
||||
import { DiscussionContext } from './context';
|
||||
|
||||
import '../posts/data/__factories__';
|
||||
import '../post-comments/data/__factories__';
|
||||
|
||||
const commentsApiUrl = getCommentsApiUrl();
|
||||
const threadsApiUrl = getThreadsApiUrl();
|
||||
const discussionPostId = 'thread-1';
|
||||
const questionPostId = 'thread-2';
|
||||
const courseId = 'course-v1:edX+TestX+Test_Course';
|
||||
const reverseOrder = false;
|
||||
const enableInContextSidebar = false;
|
||||
let store;
|
||||
let axiosMock;
|
||||
let container;
|
||||
|
||||
function mockAxiosReturnPagedComments() {
|
||||
[null, false, true].forEach(endorsed => {
|
||||
const postId = endorsed === null ? discussionPostId : questionPostId;
|
||||
[1, 2].forEach(page => {
|
||||
axiosMock
|
||||
.onGet(commentsApiUrl, {
|
||||
params: {
|
||||
thread_id: postId,
|
||||
page,
|
||||
page_size: undefined,
|
||||
requested_fields: 'profile_image',
|
||||
endorsed,
|
||||
reverse_order: reverseOrder,
|
||||
enable_in_context_sidebar: enableInContextSidebar,
|
||||
},
|
||||
})
|
||||
.reply(200, Factory.build('commentsResult', { can_delete: true }, {
|
||||
threadId: postId,
|
||||
page,
|
||||
pageSize: 1,
|
||||
count: 2,
|
||||
endorsed,
|
||||
childCount: page === 1 ? 2 : 0,
|
||||
}));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function mockAxiosReturnPagedCommentsResponses() {
|
||||
const parentId = 'comment-1';
|
||||
const commentsResponsesApiUrl = `${commentsApiUrl}${parentId}/`;
|
||||
const paramsTemplate = {
|
||||
page: undefined,
|
||||
page_size: undefined,
|
||||
requested_fields: 'profile_image',
|
||||
};
|
||||
|
||||
for (let page = 1; page <= 2; page++) {
|
||||
axiosMock
|
||||
.onGet(commentsResponsesApiUrl, { params: { ...paramsTemplate, page } })
|
||||
.reply(200, Factory.build('commentsResult', null, {
|
||||
parentId,
|
||||
page,
|
||||
pageSize: 1,
|
||||
count: 2,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
function renderComponent(postId) {
|
||||
const wrapper = render(
|
||||
<IntlProvider locale="en">
|
||||
<AppProvider store={store}>
|
||||
<DiscussionContext.Provider
|
||||
value={{ courseId }}
|
||||
>
|
||||
<MemoryRouter initialEntries={[`/${courseId}/posts/${postId}`]}>
|
||||
<DiscussionContent />
|
||||
<Route
|
||||
path="*"
|
||||
/>
|
||||
</MemoryRouter>
|
||||
</DiscussionContext.Provider>
|
||||
</AppProvider>
|
||||
</IntlProvider>,
|
||||
);
|
||||
container = wrapper.container;
|
||||
return container;
|
||||
}
|
||||
|
||||
describe('HoverCard', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
store = initializeStore();
|
||||
Factory.resetAll();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
axiosMock.onGet(threadsApiUrl)
|
||||
.reply(200, Factory.build('threadsResult'));
|
||||
axiosMock.onPatch(new RegExp(`${commentsApiUrl}*`)).reply(({
|
||||
url,
|
||||
data,
|
||||
}) => {
|
||||
const commentId = url.match(/comments\/(?<id>[a-z1-9-]+)\//).groups.id;
|
||||
const {
|
||||
rawBody,
|
||||
} = camelCaseObject(JSON.parse(data));
|
||||
return [200, Factory.build('comment', {
|
||||
id: commentId,
|
||||
rendered_body: rawBody,
|
||||
raw_body: rawBody,
|
||||
})];
|
||||
});
|
||||
axiosMock.onPost(commentsApiUrl)
|
||||
.reply(({ data }) => {
|
||||
const {
|
||||
rawBody,
|
||||
threadId,
|
||||
} = camelCaseObject(JSON.parse(data));
|
||||
return [200, Factory.build(
|
||||
'comment',
|
||||
{
|
||||
rendered_body: rawBody,
|
||||
raw_body: rawBody,
|
||||
thread_id: threadId,
|
||||
},
|
||||
)];
|
||||
});
|
||||
|
||||
executeThunk(fetchThreads(courseId), store.dispatch, store.getState);
|
||||
mockAxiosReturnPagedComments();
|
||||
mockAxiosReturnPagedCommentsResponses();
|
||||
});
|
||||
|
||||
test('it should have hover card on post', async () => {
|
||||
renderComponent(discussionPostId);
|
||||
const post = screen.getByTestId('post-thread-1');
|
||||
expect(within(post).getByTestId('hover-card-thread-1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('it should have hover card on comment', async () => {
|
||||
renderComponent(discussionPostId);
|
||||
const comment = await waitFor(() => screen.findByTestId('comment-comment-1'));
|
||||
expect(within(comment).getByTestId('hover-card-comment-1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('it should show add response, like, follow and actions menu for hovered post', async () => {
|
||||
renderComponent(discussionPostId);
|
||||
const post = screen.getByTestId('post-thread-1');
|
||||
const view = within(post).getByTestId('hover-card-thread-1');
|
||||
expect(within(view).queryByRole('button', { name: /Add response/i })).toBeInTheDocument();
|
||||
expect(within(view).getByRole('button', { name: /like/i })).toBeInTheDocument();
|
||||
expect(within(view).queryByRole('button', { name: /follow/i })).toBeInTheDocument();
|
||||
expect(within(view).queryByRole('button', { name: /actions menu/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('it should show add comment, Endorse, like and actions menu Buttons for hovered comment', async () => {
|
||||
renderComponent(questionPostId);
|
||||
const comment = await waitFor(() => screen.findByTestId('comment-comment-3'));
|
||||
const view = within(comment).getByTestId('hover-card-comment-3');
|
||||
expect(within(view).queryByRole('button', { name: /Add comment/i })).toBeInTheDocument();
|
||||
expect(within(view).getByRole('button', { name: /Endorse/i })).toBeInTheDocument();
|
||||
expect(within(view).queryByRole('button', { name: /like/i })).toBeInTheDocument();
|
||||
expect(within(view).queryByRole('button', { name: /actions menu/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -224,3 +224,24 @@ export const useTourConfiguration = (intl) => {
|
||||
}
|
||||
));
|
||||
};
|
||||
|
||||
export const useDebounce = (value, delay) => {
|
||||
// State and setters for debounced value
|
||||
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||
useEffect(
|
||||
() => {
|
||||
// Update debounced value after delay
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
// Cancel the timeout if value changes (also on delay change or unmount)
|
||||
// This is how we prevent debounced value from updating if value is changed ...
|
||||
// .. within the delay period. Timeout gets cleared and restarted.
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
},
|
||||
[value, delay], // Only re-call effect if value or delay changes
|
||||
);
|
||||
return debouncedValue;
|
||||
};
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Route, Switch } from 'react-router';
|
||||
import { injectIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { Routes } from '../../data/constants';
|
||||
import { CommentsView } from '../comments';
|
||||
import { PostCommentsView } from '../post-comments';
|
||||
import { PostEditor } from '../posts';
|
||||
|
||||
function DiscussionContent() {
|
||||
@@ -25,7 +25,7 @@ function DiscussionContent() {
|
||||
<PostEditor editExisting />
|
||||
</Route>
|
||||
<Route path={Routes.COMMENTS.PATH}>
|
||||
<CommentsView />
|
||||
<PostCommentsView />
|
||||
</Route>
|
||||
</Switch>
|
||||
)}
|
||||
|
||||
@@ -87,7 +87,7 @@ export default function DiscussionsHome() {
|
||||
>
|
||||
<div
|
||||
className={classNames('d-flex flex-row justify-content-between navbar fixed-top', {
|
||||
'pl-4 pr-2.5 py-1.5': enableInContextSidebar,
|
||||
'pl-4 pr-3 py-0': enableInContextSidebar,
|
||||
})}
|
||||
>
|
||||
{!enableInContextSidebar && <Route path={Routes.DISCUSSIONS.PATH} component={NavigationBar} />}
|
||||
@@ -120,11 +120,11 @@ export default function DiscussionsHome() {
|
||||
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} /> }
|
||||
{isRedirectToLearners && <Route path={Routes.LEARNERS.PATH} component={EmptyLearners} />}
|
||||
</Switch>
|
||||
)}
|
||||
</div>
|
||||
<DiscussionsProductTour />
|
||||
{!enableInContextSidebar && <DiscussionsProductTour />}
|
||||
</main>
|
||||
{!enableInContextSidebar && <Footer />}
|
||||
</DiscussionContext.Provider>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useContext } from 'react';
|
||||
import React, { useContext, useEffect } from 'react';
|
||||
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
@@ -8,6 +8,7 @@ import { Spinner } from '@edx/paragon';
|
||||
|
||||
import { RequestStatus, Routes } from '../../data/constants';
|
||||
import { DiscussionContext } from '../common/context';
|
||||
import { selectDiscussionProvider } from '../data/selectors';
|
||||
import { selectTopicThreads } from '../posts/data/selectors';
|
||||
import PostsList from '../posts/PostsList';
|
||||
import { discussionsPath, handleKeyDown } from '../utils';
|
||||
@@ -15,14 +16,18 @@ import {
|
||||
selectArchivedTopic, selectLoadingStatus, selectNonCoursewareTopics,
|
||||
selectSubsection, selectSubsectionUnits, selectUnits,
|
||||
} from './data/selectors';
|
||||
import { fetchCourseTopicsV3 } from './data/thunks';
|
||||
import { BackButton, NoResults } from './components';
|
||||
import messages from './messages';
|
||||
import { Topic } from './topic';
|
||||
|
||||
function TopicPostsView({ intl }) {
|
||||
const location = useLocation();
|
||||
const dispatch = useDispatch();
|
||||
const { courseId, topicId, category } = useContext(DiscussionContext);
|
||||
const topicsLoadingStatus = useSelector(selectLoadingStatus);
|
||||
const provider = useSelector(selectDiscussionProvider);
|
||||
const topicsStatus = useSelector(selectLoadingStatus);
|
||||
const topicsInProgress = topicsStatus === RequestStatus.IN_PROGRESS;
|
||||
const posts = useSelector(selectTopicThreads([topicId]));
|
||||
const selectedSubsectionUnits = useSelector(selectSubsectionUnits(category));
|
||||
const selectedSubsection = useSelector(selectSubsection(category));
|
||||
@@ -30,6 +35,12 @@ function TopicPostsView({ intl }) {
|
||||
const selectedNonCoursewareTopic = useSelector(selectNonCoursewareTopics)?.find(topic => topic.id === topicId);
|
||||
const selectedArchivedTopic = useSelector(selectArchivedTopic(topicId));
|
||||
|
||||
useEffect(() => {
|
||||
if (provider && topicsStatus === RequestStatus.IDLE) {
|
||||
dispatch(fetchCourseTopicsV3(courseId));
|
||||
}
|
||||
}, [provider]);
|
||||
|
||||
const backButtonPath = () => {
|
||||
const path = selectedUnit ? Routes.TOPICS.CATEGORY : Routes.TOPICS.ALL;
|
||||
const params = selectedUnit ? { courseId, category: selectedUnit?.parentId } : { courseId };
|
||||
@@ -40,12 +51,14 @@ function TopicPostsView({ intl }) {
|
||||
<div className="discussion-posts d-flex flex-column h-100">
|
||||
{topicId ? (
|
||||
<BackButton
|
||||
loading={topicsInProgress}
|
||||
path={backButtonPath()}
|
||||
title={selectedUnit?.name || selectedNonCoursewareTopic?.name || selectedArchivedTopic?.name
|
||||
|| intl.formatMessage(messages.unnamedTopic)}
|
||||
/>
|
||||
) : (
|
||||
<BackButton
|
||||
loading={topicsInProgress}
|
||||
path={discussionsPath(Routes.TOPICS.ALL, { courseId })(location)}
|
||||
title={selectedSubsection?.displayName || intl.formatMessage(messages.unnamedSubsection)}
|
||||
/>
|
||||
@@ -56,6 +69,7 @@ function TopicPostsView({ intl }) {
|
||||
<PostsList
|
||||
posts={posts}
|
||||
topics={[topicId]}
|
||||
parentIsLoading={topicsInProgress}
|
||||
/>
|
||||
) : (
|
||||
selectedSubsectionUnits?.map((unit) => (
|
||||
@@ -65,10 +79,10 @@ function TopicPostsView({ intl }) {
|
||||
/>
|
||||
))
|
||||
)}
|
||||
{(category && selectedSubsectionUnits.length === 0 && topicsLoadingStatus === RequestStatus.SUCCESSFUL) && (
|
||||
{(category && selectedSubsectionUnits.length === 0 && topicsStatus === RequestStatus.SUCCESSFUL) && (
|
||||
<NoResults />
|
||||
)}
|
||||
{(category && topicsLoadingStatus === RequestStatus.IN_PROGRESS) && (
|
||||
{(category && topicsInProgress) && (
|
||||
<div className="d-flex justify-content-center p-4">
|
||||
<Spinner animation="border" variant="primary" size="lg" />
|
||||
</div>
|
||||
|
||||
255
src/discussions/in-context-topics/TopicPostsView.test.jsx
Normal file
255
src/discussions/in-context-topics/TopicPostsView.test.jsx
Normal file
@@ -0,0 +1,255 @@
|
||||
import {
|
||||
fireEvent, render, screen, waitFor, within,
|
||||
} from '@testing-library/react';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { IntlProvider } from 'react-intl';
|
||||
import { generatePath, MemoryRouter, Route } from 'react-router';
|
||||
import { Factory } from 'rosie';
|
||||
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
|
||||
import { PostActionsBar } from '../../components';
|
||||
import { Routes } from '../../data/constants';
|
||||
import { initializeStore } from '../../store';
|
||||
import { executeThunk } from '../../test-utils';
|
||||
import { DiscussionContext } from '../common/context';
|
||||
import { getThreadsApiUrl } from '../posts/data/api';
|
||||
import { fetchThreads } from '../posts/data/thunks';
|
||||
import { getCourseTopicsApiUrl } from './data/api';
|
||||
import { selectCoursewareTopics } from './data/selectors';
|
||||
import { fetchCourseTopicsV3 } from './data/thunks';
|
||||
import TopicPostsView from './TopicPostsView';
|
||||
import TopicsView from './TopicsView';
|
||||
|
||||
import './data/__factories__';
|
||||
import '../posts/data/__factories__/threads.factory';
|
||||
|
||||
const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||
const threadsApiUrl = getThreadsApiUrl();
|
||||
const topicsApiUrl = getCourseTopicsApiUrl();
|
||||
let store;
|
||||
let axiosMock;
|
||||
let lastLocation;
|
||||
let container;
|
||||
|
||||
async function renderComponent({ topicId, category } = { }) {
|
||||
let path = `/${courseId}/topics`;
|
||||
if (topicId) {
|
||||
path = generatePath(Routes.POSTS.PATH, { courseId, topicId });
|
||||
} else if (category) {
|
||||
path = generatePath(Routes.TOPICS.CATEGORY, { courseId, category });
|
||||
}
|
||||
const wrapper = await render(
|
||||
<IntlProvider locale="en">
|
||||
<AppProvider store={store}>
|
||||
<DiscussionContext.Provider value={{
|
||||
courseId,
|
||||
topicId,
|
||||
category,
|
||||
page: 'topics',
|
||||
}}
|
||||
>
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<Route exact path={[Routes.POSTS.PATH, Routes.TOPICS.CATEGORY]}>
|
||||
<TopicPostsView />
|
||||
</Route>
|
||||
<Route exact path={[Routes.TOPICS.ALL]}>
|
||||
<PostActionsBar />
|
||||
<TopicsView />
|
||||
</Route>
|
||||
<Route
|
||||
render={({ location }) => {
|
||||
lastLocation = location;
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
</DiscussionContext.Provider>
|
||||
</AppProvider>
|
||||
</IntlProvider>,
|
||||
);
|
||||
container = wrapper.container;
|
||||
}
|
||||
|
||||
describe('InContext Topic Posts View', () => {
|
||||
let coursewareTopics;
|
||||
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
store = initializeStore({
|
||||
config: {
|
||||
enableInContext: true,
|
||||
provider: 'openedx',
|
||||
hasModerationPrivileges: true,
|
||||
blackouts: [],
|
||||
},
|
||||
});
|
||||
Factory.resetAll();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
lastLocation = undefined;
|
||||
});
|
||||
|
||||
async function setupTopicsMockResponse() {
|
||||
axiosMock.onGet(`${topicsApiUrl}${courseId}`)
|
||||
.reply(200, (Factory.buildList('topic', 1, null, {
|
||||
topicPrefix: 'noncourseware-topic',
|
||||
enabledInContext: true,
|
||||
topicNamePrefix: 'general-topic',
|
||||
usageKey: '',
|
||||
courseware: false,
|
||||
discussionCount: 1,
|
||||
questionCount: 1,
|
||||
})
|
||||
.concat(Factory.buildList('section', 2, null, { topicPrefix: 'courseware' })))
|
||||
.concat(Factory.buildList('archived-topics', 2, null)));
|
||||
await executeThunk(fetchCourseTopicsV3(courseId), store.dispatch, store.getState);
|
||||
|
||||
const state = store.getState();
|
||||
coursewareTopics = selectCoursewareTopics(state);
|
||||
}
|
||||
|
||||
async function setupPostsMockResponse(topicId, numOfResponses = 3) {
|
||||
axiosMock.onGet(threadsApiUrl)
|
||||
.reply(() => {
|
||||
const threadAttrs = { previewBody: 'thread preview body' };
|
||||
return [200, Factory.build('threadsResult', {}, {
|
||||
topicId,
|
||||
threadAttrs,
|
||||
count: numOfResponses,
|
||||
})];
|
||||
});
|
||||
await executeThunk(fetchThreads(courseId), store.dispatch, store.getState);
|
||||
}
|
||||
|
||||
test.each([
|
||||
{ parentId: 'noncourseware-topic-1', parentTitle: 'general-topic-1', topicType: 'NonCourseware' },
|
||||
{ parentId: 'courseware-topic-1-v3-1', parentTitle: 'Introduction Introduction 1-1-1', topicType: 'Courseware' },
|
||||
])('\'$topicType\' topic should have a required number of post lengths.', async ({ parentId, parentTitle }) => {
|
||||
await setupTopicsMockResponse();
|
||||
await setupPostsMockResponse(parentId, 3);
|
||||
|
||||
await act(async () => {
|
||||
renderComponent({ topicId: parentId });
|
||||
});
|
||||
|
||||
await waitFor(async () => {
|
||||
const posts = await container.querySelectorAll('.discussion-post');
|
||||
const backButton = screen.getByLabelText('Back to topics list');
|
||||
const parentHeader = await screen.findByText(parentTitle);
|
||||
|
||||
expect(lastLocation.pathname.endsWith(`/topics/${parentId}`)).toBeTruthy();
|
||||
expect(posts).toHaveLength(3);
|
||||
expect(backButton).toBeInTheDocument();
|
||||
expect(parentHeader).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('A back button should redirect from list of posts to list of units.', async () => {
|
||||
await setupTopicsMockResponse();
|
||||
const subSection = coursewareTopics[0].children[0];
|
||||
const unit = subSection.children[0];
|
||||
|
||||
await act(async () => {
|
||||
setupPostsMockResponse(unit.id, 2);
|
||||
renderComponent({ topicId: unit.id });
|
||||
});
|
||||
|
||||
const backButton = await screen.getByLabelText('Back to topics list');
|
||||
|
||||
await act(async () => fireEvent.click(backButton));
|
||||
await waitFor(async () => {
|
||||
renderComponent({ category: subSection.id });
|
||||
|
||||
const subSectionList = await container.querySelector('.list-group');
|
||||
const units = subSectionList.querySelectorAll('.discussion-topic');
|
||||
const unitHeader = within(subSectionList).queryByText(unit.name);
|
||||
|
||||
expect(lastLocation.pathname.endsWith(`/category/${subSection.id}`)).toBeTruthy();
|
||||
expect(unitHeader).toBeInTheDocument();
|
||||
expect(units).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
|
||||
it('A back button should redirect from units to the parent/selected subsection.', async () => {
|
||||
await setupTopicsMockResponse();
|
||||
const subSection = coursewareTopics[0].children[0];
|
||||
|
||||
renderComponent({ category: subSection.id });
|
||||
|
||||
const backButton = await screen.getByLabelText('Back to topics list');
|
||||
|
||||
await act(async () => fireEvent.click(backButton));
|
||||
await waitFor(async () => {
|
||||
renderComponent();
|
||||
|
||||
const sectionList = await container.querySelector('.list-group');
|
||||
const subSections = sectionList.querySelectorAll('.discussion-topic-group');
|
||||
const subSectionHeader = within(sectionList).queryByText(subSection.displayName);
|
||||
|
||||
expect(lastLocation.pathname.endsWith('/topics')).toBeTruthy();
|
||||
expect(subSectionHeader).toBeInTheDocument();
|
||||
expect(subSections).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ searchText: 'hello world', output: 'Showing 0 results for', resultCount: 0 },
|
||||
{ searchText: 'introduction', output: 'Showing 8 results for', resultCount: 8 },
|
||||
])('It should have a search bar with a clear button and \'$output\' results found text.',
|
||||
async ({ searchText, output, resultCount }) => {
|
||||
await setupTopicsMockResponse();
|
||||
await renderComponent();
|
||||
|
||||
const searchField = await within(container).getByPlaceholderText('Search topics');
|
||||
const searchButton = await within(container).getByTestId('search-icon');
|
||||
fireEvent.change(searchField, { target: { value: searchText } });
|
||||
|
||||
await waitFor(async () => expect(searchField).toHaveValue(searchText));
|
||||
await act(async () => fireEvent.click(searchButton));
|
||||
await waitFor(async () => {
|
||||
const clearButton = await within(container).queryByText('Clear results');
|
||||
const searchMessage = within(container).queryByText(`${output} "${searchText}"`);
|
||||
const units = container.querySelectorAll('.discussion-topic');
|
||||
|
||||
expect(searchMessage).toBeInTheDocument();
|
||||
expect(clearButton).toBeInTheDocument();
|
||||
expect(units).toHaveLength(resultCount);
|
||||
});
|
||||
});
|
||||
|
||||
it('When click on the clear button it should move to main topics pages.', async () => {
|
||||
await setupTopicsMockResponse();
|
||||
await renderComponent();
|
||||
|
||||
const searchText = 'hello world';
|
||||
const searchField = await within(container).getByPlaceholderText('Search topics');
|
||||
const searchButton = await within(container).getByTestId('search-icon');
|
||||
|
||||
fireEvent.change(searchField, { target: { value: searchText } });
|
||||
|
||||
await waitFor(async () => expect(searchField).toHaveValue(searchText));
|
||||
await act(async () => fireEvent.click(searchButton));
|
||||
await waitFor(async () => {
|
||||
const clearButton = await within(container).queryByText('Clear results');
|
||||
|
||||
await act(async () => fireEvent.click(clearButton));
|
||||
await waitFor(async () => {
|
||||
const coursewareTopicList = await container.querySelectorAll('.discussion-topic-group');
|
||||
|
||||
expect(coursewareTopicList).toHaveLength(3);
|
||||
expect(within(container).queryByText('Clear results')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
233
src/discussions/in-context-topics/TopicsView.test.jsx
Normal file
233
src/discussions/in-context-topics/TopicsView.test.jsx
Normal file
@@ -0,0 +1,233 @@
|
||||
import {
|
||||
fireEvent, render, screen, waitFor,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { IntlProvider } from 'react-intl';
|
||||
import { MemoryRouter, Route } from 'react-router';
|
||||
import { Factory } from 'rosie';
|
||||
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
|
||||
import { initializeStore } from '../../store';
|
||||
import { executeThunk } from '../../test-utils';
|
||||
import { DiscussionContext } from '../common/context';
|
||||
import { getCourseTopicsApiUrl } from './data/api';
|
||||
import { selectCoursewareTopics, selectNonCoursewareTopics } from './data/selectors';
|
||||
import { fetchCourseTopicsV3 } from './data/thunks';
|
||||
import TopicPostsView from './TopicPostsView';
|
||||
import TopicsView from './TopicsView';
|
||||
|
||||
import './data/__factories__';
|
||||
|
||||
const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||
const category = 'section-topic-1';
|
||||
|
||||
const topicsApiUrl = `${getCourseTopicsApiUrl()}`;
|
||||
let store;
|
||||
let axiosMock;
|
||||
let lastLocation;
|
||||
let container;
|
||||
|
||||
function renderComponent() {
|
||||
const wrapper = render(
|
||||
<IntlProvider locale="en">
|
||||
<AppProvider store={store}>
|
||||
<DiscussionContext.Provider value={{ courseId, category }}>
|
||||
<MemoryRouter initialEntries={[`/${courseId}/topics/`]}>
|
||||
<Route path="/:courseId/topics/">
|
||||
<TopicsView />
|
||||
</Route>
|
||||
<Route path="/:courseId/category/:category">
|
||||
<TopicPostsView />
|
||||
</Route>
|
||||
<Route
|
||||
render={({ location }) => {
|
||||
lastLocation = location;
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
</DiscussionContext.Provider>
|
||||
</AppProvider>
|
||||
</IntlProvider>,
|
||||
);
|
||||
container = wrapper.container;
|
||||
}
|
||||
|
||||
describe('InContext Topics View', () => {
|
||||
let nonCoursewareTopics;
|
||||
let coursewareTopics;
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
store = initializeStore({
|
||||
config: { enableInContext: true, provider: 'openedx', hasModerationPrivileges: true },
|
||||
});
|
||||
Factory.resetAll();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
lastLocation = undefined;
|
||||
});
|
||||
|
||||
async function setupMockResponse() {
|
||||
axiosMock.onGet(`${topicsApiUrl}${courseId}`)
|
||||
.reply(200, (Factory.buildList('topic', 1, null, {
|
||||
topicPrefix: 'noncourseware-topic',
|
||||
enabledInContext: true,
|
||||
topicNamePrefix: 'general-topic',
|
||||
usageKey: '',
|
||||
courseware: false,
|
||||
discussionCount: 1,
|
||||
questionCount: 1,
|
||||
}).concat(Factory.buildList('section', 2, null, { topicPrefix: 'courseware' })))
|
||||
.concat(Factory.buildList('archived-topics', 2, null)));
|
||||
await executeThunk(fetchCourseTopicsV3(courseId), store.dispatch, store.getState);
|
||||
|
||||
const state = store.getState();
|
||||
nonCoursewareTopics = selectNonCoursewareTopics(state);
|
||||
coursewareTopics = selectCoursewareTopics(state);
|
||||
}
|
||||
|
||||
it('A non-courseware topic should be clickable and should have a title', async () => {
|
||||
await setupMockResponse();
|
||||
renderComponent();
|
||||
|
||||
const nonCourseware = nonCoursewareTopics[0];
|
||||
const nonCoursewareTopic = await screen.findByText(nonCourseware.name);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(nonCoursewareTopic);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(nonCourseware.name)).toBeInTheDocument();
|
||||
expect(lastLocation.pathname.endsWith(`/topics/${nonCourseware.id}`)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('A non-courseware topic should be on the top of the list', async () => {
|
||||
await setupMockResponse();
|
||||
renderComponent();
|
||||
const topic = await container.querySelector('.discussion-topic');
|
||||
|
||||
expect(within(topic).queryByText('general-topic-1')).toBeInTheDocument();
|
||||
expect(topic.nextSibling).toBe(container.querySelector('.divider'));
|
||||
});
|
||||
|
||||
it('A non-Courseware topic should have 3 stats and should be hoverable', async () => {
|
||||
await setupMockResponse();
|
||||
renderComponent();
|
||||
|
||||
const topic = await container.querySelector('.discussion-topic');
|
||||
const statsList = await topic.querySelectorAll('.icon-size');
|
||||
|
||||
expect(statsList.length).toBe(3);
|
||||
fireEvent.mouseOver(statsList[0]);
|
||||
expect(screen.queryByText('1 Discussion')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Section groups should be listed in the middle of the topics list.', async () => {
|
||||
await setupMockResponse();
|
||||
renderComponent();
|
||||
const topicsList = await screen.getByRole('list');
|
||||
const sectionGroups = await screen.getAllByTestId('section-group');
|
||||
|
||||
expect(topicsList.children[1]).toStrictEqual(topicsList.querySelector('.divider'));
|
||||
expect(sectionGroups.length).toBe(2);
|
||||
expect(topicsList.children[5]).toStrictEqual(topicsList.querySelector('.divider'));
|
||||
});
|
||||
|
||||
it('A section group should have only a title and required subsections.', async () => {
|
||||
await setupMockResponse();
|
||||
renderComponent();
|
||||
const sectionGroups = await screen.getAllByTestId('section-group');
|
||||
|
||||
coursewareTopics.forEach(async (topic, index) => {
|
||||
const stats = await sectionGroups[index].querySelectorAll('.icon-size:not([data-testid="subsection-group"].icon-size)');
|
||||
const subsectionGroups = await within(sectionGroups[index]).getAllByTestId('subsection-group');
|
||||
|
||||
expect(within(sectionGroups[index]).queryByText(topic.displayName)).toBeInTheDocument();
|
||||
expect(stats).toHaveLength(0);
|
||||
expect(subsectionGroups).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
it('The subsection should have a title name, be clickable, and have the stats', async () => {
|
||||
await setupMockResponse();
|
||||
renderComponent();
|
||||
const subsectionObject = coursewareTopics[0].children[0];
|
||||
const subSection = await container.querySelector(`[data-subsection-id=${subsectionObject.id}]`);
|
||||
const subSectionTitle = await within(subSection).queryByText(subsectionObject.displayName);
|
||||
const statsList = await subSection.querySelectorAll('.icon-size');
|
||||
|
||||
expect(subSectionTitle).toBeInTheDocument();
|
||||
expect(statsList).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('Subsection names should be clickable and redirected to the units lists', async () => {
|
||||
await setupMockResponse();
|
||||
renderComponent();
|
||||
|
||||
const subsectionObject = coursewareTopics[0].children[0];
|
||||
const subSection = await container.querySelector(`[data-subsection-id=${subsectionObject.id}]`);
|
||||
|
||||
await act(async () => fireEvent.click(subSection));
|
||||
await waitFor(async () => {
|
||||
const backButton = await screen.getByLabelText('Back to topics list');
|
||||
const topicsList = await screen.getByRole('list');
|
||||
const subSectionHeading = await screen.findByText(subsectionObject.displayName);
|
||||
const units = await topicsList.querySelectorAll('.discussion-topic');
|
||||
|
||||
expect(backButton).toBeInTheDocument();
|
||||
expect(subSectionHeading).toBeInTheDocument();
|
||||
expect(units).toHaveLength(4);
|
||||
expect(lastLocation.pathname.endsWith(`/category/${subsectionObject.id}`)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('The number of units should be matched with the actual unit length.', async () => {
|
||||
await setupMockResponse();
|
||||
renderComponent();
|
||||
const subSection = await container.querySelector(`[data-subsection-id=${coursewareTopics[0].children[0].id}]`);
|
||||
|
||||
await act(async () => fireEvent.click(subSection));
|
||||
await waitFor(async () => {
|
||||
const units = await container.querySelectorAll('.discussion-topic');
|
||||
|
||||
expect(units).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
|
||||
it('A unit should have a title and stats and should be clickable', async () => {
|
||||
await setupMockResponse();
|
||||
renderComponent();
|
||||
const subSectionObject = coursewareTopics[0].children[0];
|
||||
const unitObject = subSectionObject.children[0];
|
||||
|
||||
const subSection = await container.querySelector(`[data-subsection-id=${subSectionObject.id}]`);
|
||||
|
||||
await act(async () => fireEvent.click(subSection));
|
||||
await waitFor(async () => {
|
||||
const unitElement = await screen.findByText(unitObject.name);
|
||||
const unitContainer = await container.querySelector(`[data-topic-id=${unitObject.id}]`);
|
||||
const statsList = await unitContainer.querySelectorAll('.icon-size');
|
||||
|
||||
expect(unitElement).toBeInTheDocument();
|
||||
expect(statsList).toHaveLength(3);
|
||||
|
||||
await act(async () => fireEvent.click(unitContainer));
|
||||
await waitFor(async () => {
|
||||
expect(lastLocation.pathname.endsWith(`/topics/${unitObject.id}`)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,12 +4,14 @@ import PropTypes from 'prop-types';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Icon, IconButton } from '@edx/paragon';
|
||||
import { Icon, IconButton, Spinner } from '@edx/paragon';
|
||||
import { ArrowBack } from '@edx/paragon/icons';
|
||||
|
||||
import messages from '../messages';
|
||||
|
||||
function BackButton({ intl, path, title }) {
|
||||
function BackButton({
|
||||
intl, path, title, loading,
|
||||
}) {
|
||||
const history = useHistory();
|
||||
|
||||
return (
|
||||
@@ -24,7 +26,7 @@ function BackButton({ intl, path, title }) {
|
||||
alt={intl.formatMessage(messages.backAlt)}
|
||||
/>
|
||||
<div className="d-flex flex-fill justify-content-center align-items-center mr-4.5">
|
||||
{title}
|
||||
{loading ? <Spinner animation="border" variant="primary" size="sm" /> : title}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-bottom border-light-400" />
|
||||
@@ -36,6 +38,11 @@ BackButton.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
path: PropTypes.shape({}).isRequired,
|
||||
title: PropTypes.string.isRequired,
|
||||
loading: PropTypes.bool,
|
||||
};
|
||||
|
||||
BackButton.defaultProps = {
|
||||
loading: false,
|
||||
};
|
||||
|
||||
export default injectIntl(BackButton);
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Factory } from 'rosie';
|
||||
|
||||
import { getApiBaseUrl } from '../../../../data/constants';
|
||||
|
||||
Factory.define('topic')
|
||||
.sequence('id', ['topicPrefix'], (idx, topicPrefix) => `${topicPrefix}-${idx}`)
|
||||
.sequence('enabled-in-context', ['enabledInContext'], (idx, enabledInContext) => enabledInContext)
|
||||
.sequence('name', ['topicNamePrefix'], (idx, topicNamePrefix) => `${topicNamePrefix}-${idx}`)
|
||||
.sequence('usage-key', ['usageKey'], (idx, usageKey) => usageKey)
|
||||
.sequence('courseware', ['courseware'], (idx, courseware) => courseware)
|
||||
.attr('activeFlags', null, true)
|
||||
.attr('thread_counts', ['discussionCount', 'questionCount'], (discCount, questCount) => {
|
||||
Factory.reset('thread-counts');
|
||||
return Factory.build('thread-counts', null, { discussionCount: discCount, questionCount: questCount });
|
||||
});
|
||||
|
||||
Factory.define('sub-section')
|
||||
.sequence('block_id', (idx) => `${idx}`)
|
||||
.option('topicPrefix', null, '')
|
||||
.sequence('id', ['topicPrefix'], (idx, topicPrefix) => `${topicPrefix}-topic-${idx}`)
|
||||
.sequence('display-name', ['sectionPrefix'], (idx, sectionPrefix) => `Introduction ${sectionPrefix + idx}`)
|
||||
.option('courseId', null, 'course-v1:edX+DemoX+Demo_Course')
|
||||
.sequence('legacy_web_url', ['id', 'courseId'],
|
||||
(idx, id, courseId) => `${getApiBaseUrl}/courses/${courseId}/jump_to/block-v1:${id}?experience=legacy`)
|
||||
.sequence('lms_web_url', ['id', 'courseId'],
|
||||
(idx, id, courseId) => `${getApiBaseUrl}/courses/${courseId}/jump_to/block-v1:${id}`)
|
||||
.sequence('student_view_url', ['id', 'courseId'],
|
||||
(idx, id) => `${getApiBaseUrl}/xblock/block-v1:${id}`)
|
||||
.attr('type', null, 'sequential')
|
||||
.attr('activeFlags', null, true)
|
||||
.attr('thread_counts', ['discussionCount', 'questionCount'], (discCount, questCount) => {
|
||||
Factory.reset('thread-counts');
|
||||
return Factory.build('thread-counts', null, { discussionCount: discCount, questionCount: questCount });
|
||||
})
|
||||
.attr('children', ['id', 'display-name', 'courseId'], (id, name, courseId) => {
|
||||
Factory.reset('topic');
|
||||
return Factory.buildList('topic', 2, null, {
|
||||
topicPrefix: `${id}`,
|
||||
enabledInContext: true,
|
||||
topicNamePrefix: `${name}`,
|
||||
usageKey: `${courseId.replace('course-v1:', 'block-v1:')} +type@vertical+block@vertical_`,
|
||||
discussionCount: 1,
|
||||
questionCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
Factory.define('section')
|
||||
.sequence('block_id', (idx) => `${idx}`)
|
||||
.option('topicPrefix', null, '')
|
||||
.sequence('id', ['topicPrefix'], (idx, topicPrefix) => `${topicPrefix}-topic-${idx}-v3`)
|
||||
.attr('courseware', null, true)
|
||||
.sequence('display-name', (idx) => `Introduction ${idx}`)
|
||||
.option('courseId', null, 'course-v1:edX+DemoX+Demo_Course')
|
||||
.sequence('legacy_web_url', ['id', 'courseId'],
|
||||
(idx, id, courseId) => `${getApiBaseUrl}/courses/${courseId}/jump_to/${courseId.replace('course-v1:', 'block-v1:')}+type@chapter+block@${id}?experience=legacy`)
|
||||
.sequence('lms_web_url', ['id', 'courseId'],
|
||||
(idx, id, courseId) => `${getApiBaseUrl}/courses/${courseId}/jump_to/${courseId.replace('course-v1:', 'block-v1:')}+type@chapter+block@${id}`)
|
||||
.sequence('student_view_url', ['id', 'courseId'],
|
||||
(idx, id, courseId) => `${getApiBaseUrl}/xblock/${courseId.replace('course-v1:', 'block-v1:')}+type@chapter+block@${id}`)
|
||||
.attr('type', null, 'chapter')
|
||||
.attr('children', ['id', 'display-name'], (id, name) => {
|
||||
Factory.reset('sub-section');
|
||||
return Factory.buildList('sub-section', 2, null, {
|
||||
sectionPrefix: `${name}-`,
|
||||
topicPrefix: 'section',
|
||||
id,
|
||||
discussionCount: 1,
|
||||
questionCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
Factory.define('thread-counts')
|
||||
.sequence('discussion', ['discussionCount'], (idx, discussionCount) => discussionCount)
|
||||
.sequence('question', ['questionCount'], (idx, questionCount) => questionCount);
|
||||
|
||||
Factory.define('archived-topics')
|
||||
.attr('id', null, 'archived')
|
||||
.option('courseId', null, 'course-v1:edX+DemoX+Demo_Course')
|
||||
.attr('children', ['id', 'courseId'], (id, courseId) => {
|
||||
Factory.reset('topic');
|
||||
return Factory.buildList('topic', 2, null, {
|
||||
topicPrefix: `${id}`,
|
||||
enabledInContext: false,
|
||||
topicNamePrefix: `${id}`,
|
||||
usageKey: `${courseId.replace('course-v1:', 'block-v1:')} +type@vertical+block@`,
|
||||
discussionCount: 1,
|
||||
questionCount: 1,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
import './inContextTopics.factory';
|
||||
@@ -4,6 +4,8 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
import { getApiBaseUrl } from '../../../data/constants';
|
||||
|
||||
export const getCourseTopicsApiUrl = () => `${getApiBaseUrl()}/api/discussion/v3/course_topics/`;
|
||||
|
||||
export async function getCourseTopicsV3(courseId) {
|
||||
const url = `${getApiBaseUrl()}/api/discussion/v3/course_topics/${courseId}`;
|
||||
const { data } = await getAuthenticatedHttpClient().get(url);
|
||||
|
||||
72
src/discussions/in-context-topics/data/api.test.js
Normal file
72
src/discussions/in-context-topics/data/api.test.js
Normal file
@@ -0,0 +1,72 @@
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { Factory } from 'rosie';
|
||||
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { initializeMockApp } from '@edx/frontend-platform/testing';
|
||||
|
||||
import { initializeStore } from '../../../store';
|
||||
import { executeThunk } from '../../../test-utils';
|
||||
import { getCourseTopicsApiUrl, getCourseTopicsV3 } from './api';
|
||||
import { fetchCourseTopicsV3 } from './thunks';
|
||||
|
||||
import './__factories__';
|
||||
|
||||
const courseId = 'course-v1:edX+TestX+Test_Course';
|
||||
const courseId2 = 'course-v1:edX+TestX+Test_Course2';
|
||||
const courseTopicsApiUrl = getCourseTopicsApiUrl();
|
||||
|
||||
let axiosMock = null;
|
||||
let store;
|
||||
|
||||
describe('In context topic api tests', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
store = initializeStore();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
axiosMock.reset();
|
||||
});
|
||||
|
||||
test('successfully get topics', async () => {
|
||||
axiosMock.onGet(`${courseTopicsApiUrl}${courseId}`)
|
||||
.reply(200, (Factory.buildList('topic', 1, null, {
|
||||
topicPrefix: 'noncourseware-topic',
|
||||
enabledInContext: true,
|
||||
topicNamePrefix: 'general-topic',
|
||||
usageKey: '',
|
||||
courseware: false,
|
||||
discussionCount: 1,
|
||||
questionCount: 1,
|
||||
}).concat(Factory.buildList('section', 2, null, { topicPrefix: 'courseware' })))
|
||||
.concat(Factory.buildList('archived-topics', 2, null)));
|
||||
|
||||
const response = await getCourseTopicsV3(courseId);
|
||||
|
||||
expect(response).not.toBeUndefined();
|
||||
});
|
||||
|
||||
it('failed to fetch topics', async () => {
|
||||
axiosMock.onGet(`${courseTopicsApiUrl}${courseId2}`)
|
||||
.reply(404);
|
||||
await executeThunk(fetchCourseTopicsV3(courseId2), store.dispatch, store.getState);
|
||||
|
||||
expect(store.getState().inContextTopics.status).toEqual('failed');
|
||||
});
|
||||
|
||||
it('denied to fetch topics', async () => {
|
||||
axiosMock.onGet(`${courseTopicsApiUrl}${courseId}`)
|
||||
.reply(403, {});
|
||||
await executeThunk(fetchCourseTopicsV3(courseId), store.dispatch);
|
||||
|
||||
expect(store.getState().inContextTopics.status).toEqual('denied');
|
||||
});
|
||||
});
|
||||
186
src/discussions/in-context-topics/data/redux.test.js
Normal file
186
src/discussions/in-context-topics/data/redux.test.js
Normal file
@@ -0,0 +1,186 @@
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { Factory } from 'rosie';
|
||||
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { initializeMockApp } from '@edx/frontend-platform/testing';
|
||||
|
||||
import { initializeStore } from '../../../store';
|
||||
import { executeThunk } from '../../../test-utils';
|
||||
import { getCourseTopicsApiUrl } from './api';
|
||||
import { fetchCourseTopicsV3 } from './thunks';
|
||||
|
||||
import './__factories__';
|
||||
|
||||
const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||
const courseTopicsApiUrl = getCourseTopicsApiUrl();
|
||||
|
||||
let axiosMock;
|
||||
let store;
|
||||
|
||||
describe('Redux in context topics tests', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
Factory.resetAll();
|
||||
store = initializeStore();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
axiosMock.reset();
|
||||
});
|
||||
|
||||
async function setupMockData() {
|
||||
axiosMock.onGet(`${courseTopicsApiUrl}${courseId}`)
|
||||
.reply(200, (Factory.buildList('topic', 1, null, {
|
||||
topicPrefix: 'noncourseware-topic',
|
||||
enabledInContext: true,
|
||||
topicNamePrefix: 'general-topic',
|
||||
usageKey: '',
|
||||
courseware: false,
|
||||
discussionCount: 1,
|
||||
questionCount: 1,
|
||||
}).concat(Factory.buildList('section', 2, null, { topicPrefix: 'courseware' })))
|
||||
.concat(Factory.buildList('archived-topics', 2, null)));
|
||||
|
||||
await executeThunk(fetchCourseTopicsV3(courseId), store.dispatch, store.getState);
|
||||
const state = store.getState();
|
||||
return state;
|
||||
}
|
||||
|
||||
test('successfully load initial states in redux', async () => {
|
||||
executeThunk(fetchCourseTopicsV3(courseId), store.dispatch, store.getState);
|
||||
const state = store.getState();
|
||||
|
||||
expect(state.inContextTopics.status).toEqual('in-progress');
|
||||
expect(state.inContextTopics.topics).toHaveLength(0);
|
||||
expect(state.inContextTopics.coursewareTopics).toHaveLength(0);
|
||||
expect(state.inContextTopics.nonCoursewareTopics).toHaveLength(0);
|
||||
expect(state.inContextTopics.nonCoursewareIds).toHaveLength(0);
|
||||
expect(state.inContextTopics.units).toHaveLength(0);
|
||||
expect(state.inContextTopics.archivedTopics).toHaveLength(0);
|
||||
expect(state.inContextTopics.filter).toEqual('');
|
||||
});
|
||||
|
||||
test('successfully store all api data of courseware and noncourseware in redux', async () => {
|
||||
setupMockData().then((state) => {
|
||||
const { coursewareTopics, nonCoursewareTopics } = state.inContextTopics;
|
||||
|
||||
expect(coursewareTopics).toHaveLength(2);
|
||||
expect(nonCoursewareTopics).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
test('successfully store the combined list of courseware and noncourseware topics in topics', async () => {
|
||||
setupMockData().then((state) => {
|
||||
const {
|
||||
coursewareTopics, nonCoursewareTopics, archivedTopics, topics,
|
||||
} = state.inContextTopics;
|
||||
|
||||
expect(topics).toHaveLength(coursewareTopics.length + nonCoursewareTopics.length + archivedTopics.length);
|
||||
});
|
||||
});
|
||||
|
||||
test('successfully get the posts ', async () => {
|
||||
setupMockData().then((state) => {
|
||||
expect(state?.inContextTopics?.status).toEqual('successful');
|
||||
});
|
||||
});
|
||||
|
||||
test('successfully checked that the coursewaretopic has three levels', async () => {
|
||||
setupMockData().then((state) => {
|
||||
const { coursewareTopics } = state.inContextTopics;
|
||||
|
||||
// contain chapter at first level
|
||||
coursewareTopics.forEach((chapter, index) => {
|
||||
expect(chapter.courseware).toEqual(true);
|
||||
expect(chapter.id).toEqual(`courseware-topic-${index + 1}-v3`);
|
||||
expect(chapter.type).toEqual('chapter');
|
||||
expect(chapter).toHaveProperty('blockId');
|
||||
expect(chapter).toHaveProperty('lmsWebUrl');
|
||||
expect(chapter).toHaveProperty('legacyWebUrl');
|
||||
expect(chapter).toHaveProperty('studentViewUrl');
|
||||
|
||||
// contain section at second level
|
||||
chapter.children.forEach((section, secIndex) => {
|
||||
expect(section.id).toEqual(`section-topic-${secIndex + 1}`);
|
||||
expect(section.type).toEqual('sequential');
|
||||
expect(section).toHaveProperty('blockId');
|
||||
expect(section).toHaveProperty('lmsWebUrl');
|
||||
expect(section).toHaveProperty('legacyWebUrl');
|
||||
expect(section).toHaveProperty('studentViewUrl');
|
||||
|
||||
// contain sub section at third level
|
||||
section.children.forEach((subSection, subSecIndex) => {
|
||||
expect(subSection.enabledInContext).toEqual(true);
|
||||
expect(subSection.id).toEqual(`courseware-topic-${index + 1}-v3-${subSecIndex + 1}`);
|
||||
expect(subSection).toHaveProperty('usageKey');
|
||||
expect(subSection).not.toHaveProperty('blockId');
|
||||
expect(subSection?.threadCounts?.discussion).toEqual(1);
|
||||
expect(subSection?.threadCounts?.question).toEqual(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('successfully checked that the noncoursewaretopic have proper attributes', async () => {
|
||||
setupMockData().then((state) => {
|
||||
const { nonCoursewareTopics } = state.inContextTopics;
|
||||
|
||||
nonCoursewareTopics.forEach((topic, index) => {
|
||||
expect(topic.usageKey).toEqual('');
|
||||
expect(topic.id).toEqual(`noncourseware-topic-${index + 1}`);
|
||||
expect(topic.name).toEqual(`general-topic-${index + 1}`);
|
||||
expect(topic.enabledInContext).toEqual(true);
|
||||
expect(topic?.threadCounts?.discussion).toEqual(1);
|
||||
expect(topic?.threadCounts?.question).toEqual(1);
|
||||
expect(topic).not.toHaveProperty('blockId');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('nonCoursewareIds successfully contains ids of noncourseware topics', async () => {
|
||||
setupMockData().then((state) => {
|
||||
const { nonCoursewareIds, nonCoursewareTopics } = state.inContextTopics;
|
||||
|
||||
nonCoursewareIds.forEach((nonCoursewareId, index) => {
|
||||
expect(nonCoursewareTopics[index].id).toEqual(nonCoursewareId);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('selectUnits successfully contains all sub sections', async () => {
|
||||
setupMockData().then((state) => {
|
||||
const subSections = state.inContextTopics.coursewareTopics?.map(x => x.children)
|
||||
?.flat()?.map(x => x.children)?.flat();
|
||||
const { units } = state.inContextTopics;
|
||||
|
||||
units.forEach(unit => {
|
||||
const subSection = subSections.find(x => x.id === unit.id);
|
||||
expect(subSection?.id).toEqual(unit.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('successfully stored archived data in redux', async () => {
|
||||
setupMockData().then((state) => {
|
||||
const { archivedTopics } = state.inContextTopics;
|
||||
|
||||
archivedTopics.forEach((archivedTopic, index) => {
|
||||
expect(archivedTopic?.enabledInContext).toEqual(false);
|
||||
expect(archivedTopic?.id).toEqual(`archived-${index + 1}`);
|
||||
expect(archivedTopic?.usageKey).not.toBeNull();
|
||||
expect(archivedTopic?.threadCounts?.discussion).toEqual(1);
|
||||
expect(archivedTopic?.threadCounts?.question).toEqual(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
147
src/discussions/in-context-topics/data/selector.test.jsx
Normal file
147
src/discussions/in-context-topics/data/selector.test.jsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { Factory } from 'rosie';
|
||||
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { initializeMockApp } from '@edx/frontend-platform/testing';
|
||||
|
||||
import { initializeStore } from '../../../store';
|
||||
import { executeThunk } from '../../../test-utils';
|
||||
import { getCourseTopicsApiUrl } from './api';
|
||||
import {
|
||||
selectArchivedTopics,
|
||||
selectCoursewareTopics,
|
||||
selectLoadingStatus,
|
||||
selectNonCoursewareIds,
|
||||
selectNonCoursewareTopics,
|
||||
selectTopics,
|
||||
selectUnits,
|
||||
} from './selectors';
|
||||
import { fetchCourseTopicsV3 } from './thunks';
|
||||
|
||||
import './__factories__';
|
||||
|
||||
const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||
const courseTopicsApiUrl = getCourseTopicsApiUrl();
|
||||
|
||||
let axiosMock;
|
||||
let store;
|
||||
|
||||
describe('In Context Topics Selector test cases', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
Factory.resetAll();
|
||||
store = initializeStore();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
axiosMock.reset();
|
||||
});
|
||||
|
||||
async function setupMockData() {
|
||||
axiosMock.onGet(`${courseTopicsApiUrl}${courseId}`)
|
||||
.reply(200, (Factory.buildList('topic', 1, null, {
|
||||
topicPrefix: 'noncourseware-topic',
|
||||
enabledInContext: true,
|
||||
topicNamePrefix: 'general-topic',
|
||||
usageKey: '',
|
||||
courseware: false,
|
||||
discussionCount: 1,
|
||||
questionCount: 1,
|
||||
}).concat(Factory.buildList('section', 2, null, { topicPrefix: 'courseware' })))
|
||||
.concat(Factory.buildList('archived-topics', 2, null)));
|
||||
await executeThunk(fetchCourseTopicsV3(courseId), store.dispatch, store.getState);
|
||||
|
||||
const state = store.getState();
|
||||
return state;
|
||||
}
|
||||
|
||||
test('should return topics list', async () => {
|
||||
setupMockData().then((state) => {
|
||||
const topics = selectTopics(state);
|
||||
|
||||
expect(topics).not.toBeUndefined();
|
||||
topics.forEach(data => {
|
||||
const topicFunc = jest.fn((topic) => {
|
||||
if (topic.id.includes('noncourseware-topic')) { return true; }
|
||||
if (topic.id.includes('courseware-topic')) { return true; }
|
||||
if (topic.id.includes('archived')) { return true; }
|
||||
return false;
|
||||
});
|
||||
topicFunc(data);
|
||||
expect(topicFunc).toHaveReturnedWith(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('should return courseware topics list', async () => {
|
||||
setupMockData().then((state) => {
|
||||
const coursewareTopics = selectCoursewareTopics(state);
|
||||
|
||||
expect(coursewareTopics).not.toBeUndefined();
|
||||
coursewareTopics.forEach((topic, index) => {
|
||||
expect(topic?.id).toEqual(`courseware-topic-${index + 1}-v3`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('should return noncourseware topics list', async () => {
|
||||
setupMockData().then((state) => {
|
||||
const nonCoursewareTopics = selectNonCoursewareTopics(state);
|
||||
|
||||
expect(nonCoursewareTopics).not.toBeUndefined();
|
||||
nonCoursewareTopics.forEach((topic, index) => {
|
||||
expect(topic?.id).toEqual(`noncourseware-topic-${index + 1}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('should return noncourseware ids list', async () => {
|
||||
setupMockData().then((state) => {
|
||||
const nonCoursewareIds = selectNonCoursewareIds(state);
|
||||
|
||||
expect(nonCoursewareIds).not.toBeUndefined();
|
||||
nonCoursewareIds.forEach((id, index) => {
|
||||
expect(id).toEqual(`noncourseware-topic-${index + 1}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('should return units list', async () => {
|
||||
setupMockData().then((state) => {
|
||||
const units = selectUnits(state);
|
||||
|
||||
expect(units).not.toBeUndefined();
|
||||
units.forEach(unit => {
|
||||
expect(unit?.usageKey).not.toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('should return archived topics list', async () => {
|
||||
setupMockData().then((state) => {
|
||||
const archivedTopics = selectArchivedTopics(state);
|
||||
|
||||
expect(archivedTopics).not.toBeUndefined();
|
||||
archivedTopics.forEach((topic, index) => {
|
||||
expect(topic.id).toEqual(`archived-${index + 1}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('should return loading status successful', async () => {
|
||||
setupMockData().then((state) => {
|
||||
const status = selectLoadingStatus(state);
|
||||
|
||||
expect(status).toEqual('successful');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import { RequestStatus } from '../../../data/constants';
|
||||
const topicsSlice = createSlice({
|
||||
name: 'inContextTopics',
|
||||
initialState: {
|
||||
status: RequestStatus.IN_PROGRESS,
|
||||
status: RequestStatus.IDLE,
|
||||
topics: [],
|
||||
coursewareTopics: [],
|
||||
nonCoursewareTopics: [],
|
||||
@@ -44,6 +44,7 @@ export const {
|
||||
fetchCourseTopicsRequest,
|
||||
fetchCourseTopicsSuccess,
|
||||
fetchCourseTopicsFailed,
|
||||
fetchCourseTopicsDenied,
|
||||
setFilter,
|
||||
setSortBy,
|
||||
} = topicsSlice.actions;
|
||||
|
||||
@@ -3,8 +3,11 @@ import { reduce } from 'lodash';
|
||||
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
|
||||
import { getHttpErrorStatus } from '../../utils';
|
||||
import { getCourseTopicsV3 } from './api';
|
||||
import { fetchCourseTopicsFailed, fetchCourseTopicsRequest, fetchCourseTopicsSuccess } from './slices';
|
||||
import {
|
||||
fetchCourseTopicsDenied, fetchCourseTopicsFailed, fetchCourseTopicsRequest, fetchCourseTopicsSuccess,
|
||||
} from './slices';
|
||||
|
||||
function normalizeTopicsV3(topics) {
|
||||
const coursewareUnits = reduce(topics, (arrayOfUnits, chapter) => {
|
||||
@@ -57,7 +60,11 @@ export function fetchCourseTopicsV3(courseId) {
|
||||
const data = await getCourseTopicsV3(courseId);
|
||||
dispatch(fetchCourseTopicsSuccess(normalizeTopicsV3(data)));
|
||||
} catch (error) {
|
||||
dispatch(fetchCourseTopicsFailed());
|
||||
if (getHttpErrorStatus(error) === 403) {
|
||||
dispatch(fetchCourseTopicsDenied());
|
||||
} else {
|
||||
dispatch(fetchCourseTopicsFailed());
|
||||
}
|
||||
logError(error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -50,6 +50,7 @@ function TopicSearchBar({ intl }) {
|
||||
<Icon
|
||||
src={SearchIcon}
|
||||
onClick={() => onSubmit(searchValue)}
|
||||
data-testid="search-icon"
|
||||
/>
|
||||
</span>
|
||||
</SearchField.Advanced>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Link } from 'react-router-dom';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import TopicStats from '../../../components/TopicStats';
|
||||
import { Routes } from '../../../data/constants';
|
||||
import { discussionsPath } from '../../utils';
|
||||
import messages from '../messages';
|
||||
@@ -49,12 +50,13 @@ function SectionBaseGroup({
|
||||
aria-current={isSelected(section.id) ? 'page' : undefined}
|
||||
tabIndex={(isSelected(subsection.id) || index === 0) ? 0 : -1}
|
||||
>
|
||||
<div className="d-flex flex-row py-3.5 px-4">
|
||||
<div className="d-flex flex-row pt-2.5 pb-2 px-4">
|
||||
<div className="d-flex flex-column flex-fill" style={{ minWidth: 0 }}>
|
||||
<div className="d-flex flex-column justify-content-start mw-100 flex-fill">
|
||||
<div className="topic-name text-truncate">
|
||||
{subsection?.displayName || intl.formatMessage(messages.unnamedSubsection)}
|
||||
</div>
|
||||
<TopicStats threadCounts={subsection?.threadCounts} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Icon, OverlayTrigger, Tooltip } from '@edx/paragon';
|
||||
import { HelpOutline, PostOutline, Report } from '@edx/paragon/icons';
|
||||
|
||||
import TopicStats from '../../../components/TopicStats';
|
||||
import { Routes } from '../../../data/constants';
|
||||
import { selectUserHasModerationPrivileges, selectUserIsGroupTa } from '../../data/selectors';
|
||||
import { discussionsPath } from '../../utils';
|
||||
@@ -53,65 +54,11 @@ function Topic({
|
||||
{topic?.name || topic?.displayName || intl.formatMessage(messages.unnamedTopicSubCategories)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="d-flex align-items-center mt-2.5" style={{ marginBottom: '2px' }}>
|
||||
<OverlayTrigger
|
||||
overlay={(
|
||||
<Tooltip>
|
||||
<div className="d-flex flex-column align-items-start">
|
||||
{intl.formatMessage(messages.discussions, {
|
||||
count: topic.threadCounts?.discussion || 0,
|
||||
})}
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
>
|
||||
<div className="d-flex align-items-center mr-3.5">
|
||||
<Icon src={PostOutline} className="icon-size mr-2" />
|
||||
{topic.threadCounts?.discussion || 0}
|
||||
</div>
|
||||
</OverlayTrigger>
|
||||
<OverlayTrigger
|
||||
overlay={(
|
||||
<Tooltip>
|
||||
<div className="d-flex flex-column align-items-start">
|
||||
{intl.formatMessage(messages.questions, {
|
||||
count: topic.threadCounts?.question || 0,
|
||||
})}
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
>
|
||||
<div className="d-flex align-items-center mr-3.5">
|
||||
<Icon src={HelpOutline} className="icon-size mr-2" />
|
||||
{topic.threadCounts?.question || 0}
|
||||
</div>
|
||||
</OverlayTrigger>
|
||||
{Boolean(canSeeReportedStats) && (
|
||||
<OverlayTrigger
|
||||
overlay={(
|
||||
<Tooltip>
|
||||
<div className="d-flex flex-column align-items-start">
|
||||
{Boolean(activeFlags) && (
|
||||
<span>
|
||||
{intl.formatMessage(messages.reported, { reported: activeFlags })}
|
||||
</span>
|
||||
)}
|
||||
{Boolean(inactiveFlags) && (
|
||||
<span>
|
||||
{intl.formatMessage(messages.previouslyReported, { previouslyReported: inactiveFlags })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
>
|
||||
<div className="d-flex align-items-center">
|
||||
<Icon src={Report} className="icon-size mr-2 text-danger" />
|
||||
{activeFlags}{Boolean(inactiveFlags) && `/${inactiveFlags}`}
|
||||
</div>
|
||||
</OverlayTrigger>
|
||||
)}
|
||||
</div>
|
||||
<TopicStats
|
||||
threadCounts={topic?.threadCounts}
|
||||
activeFlags={topic?.activeFlags}
|
||||
inactiveFlags={topic?.inactiveFlags}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export * from './comments';
|
||||
export * from './discussions-home';
|
||||
export * from './post-comments';
|
||||
export * from './posts';
|
||||
export * from './topics';
|
||||
|
||||
@@ -88,7 +88,7 @@ function LearnerPostsView({ intl }) {
|
||||
onClick={() => history.push(discussionsPath(Routes.LEARNERS.PATH, { courseId })(location))}
|
||||
alt={intl.formatMessage(messages.back)}
|
||||
/>
|
||||
<div className="text-primary-500 font-style-normal font-family-inter font-weight-bold py-2.5">
|
||||
<div className="text-primary-500 font-style font-weight-bold py-2.5">
|
||||
{intl.formatMessage(messages.activityForLearner, { username: capitalize(username) })}
|
||||
</div>
|
||||
<div style={{ padding: '18px' }} />
|
||||
|
||||
@@ -64,9 +64,9 @@ describe('LearnersView', () => {
|
||||
axiosMock.onGet(`${coursesApiUrl}${courseId}/activity_stats/`)
|
||||
.reply(() => [200, learnersData]);
|
||||
const learnersProfile = Factory.build('learnersProfile', {}, {
|
||||
username: ['leaner-1', 'leaner-2', 'leaner-3'],
|
||||
username: ['learner-1', 'learner-2', 'learner-3'],
|
||||
});
|
||||
axiosMock.onGet(`${userProfileApiUrl}?username=leaner-1,leaner-2,leaner-3`)
|
||||
axiosMock.onGet(`${userProfileApiUrl}?username=learner-1,learner-2,learner-3`)
|
||||
.reply(() => [200, learnersProfile.profiles]);
|
||||
await executeThunk(fetchLearners(courseId), store.dispatch, store.getState);
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Factory } from 'rosie';
|
||||
|
||||
Factory.define('learner')
|
||||
.sequence('id')
|
||||
.attr('username', ['id'], (id) => `leaner-${id}`)
|
||||
.attr('username', ['id'], (id) => `learner-${id}`)
|
||||
.option('activeFlags', null, null)
|
||||
.attr('active_flags', ['activeFlags'], (activeFlags) => activeFlags)
|
||||
.attrs({
|
||||
@@ -23,14 +23,8 @@ Factory.define('learnersResult')
|
||||
['courseId', 'count', 'page', 'pageSize'],
|
||||
(courseId, count, page, pageSize) => {
|
||||
const numPages = Math.ceil(count / pageSize);
|
||||
const next = page < numPages
|
||||
? `http://test.site/api/discussion/v1/courses/course-v1:edX+DemoX+Demo_Course/activity_stats?page=${page + 1
|
||||
}`
|
||||
: null;
|
||||
const prev = page > 1
|
||||
? `http://test.site/api/discussion/v1/courses/course-v1:edX+DemoX+Demo_Course/activity_stats?page=${page - 1
|
||||
}`
|
||||
: null;
|
||||
const next = page < numPages ? page + 1 : null;
|
||||
const prev = page > 1 ? page - 1 : null;
|
||||
return {
|
||||
next,
|
||||
prev,
|
||||
@@ -65,7 +59,7 @@ Factory.define('learnersResult')
|
||||
);
|
||||
|
||||
Factory.define('learnersProfile')
|
||||
.option('usernames', null, ['leaner-1', 'leaner-2', 'leaner-3'])
|
||||
.option('usernames', null, ['learner-1', 'learner-2', 'learner-3'])
|
||||
.attr('profiles', ['usernames'], (usernames) => {
|
||||
const profiles = usernames.map((user) => ({
|
||||
account_privacy: 'private',
|
||||
|
||||
@@ -10,6 +10,8 @@ ensureConfig([
|
||||
|
||||
export const getCoursesApiUrl = () => `${getConfig().LMS_BASE_URL}/api/discussion/v1/courses/`;
|
||||
export const getUserProfileApiUrl = () => `${getConfig().LMS_BASE_URL}/api/user/v1/accounts`;
|
||||
export const learnerPostsApiUrl = (courseId) => `${getCoursesApiUrl()}${courseId}/learner/`;
|
||||
export const learnersApiUrl = (courseId) => `${getCoursesApiUrl()}${courseId}/activity_stats/`;
|
||||
|
||||
/**
|
||||
* Fetches all the learners in the given course.
|
||||
@@ -18,8 +20,7 @@ export const getUserProfileApiUrl = () => `${getConfig().LMS_BASE_URL}/api/user/
|
||||
* @returns {Promise<{}>}
|
||||
*/
|
||||
export async function getLearners(courseId, params) {
|
||||
const url = `${getCoursesApiUrl()}${courseId}/activity_stats/`;
|
||||
const { data } = await getAuthenticatedHttpClient().get(url, { params });
|
||||
const { data } = await getAuthenticatedHttpClient().get(learnersApiUrl(courseId), { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -65,8 +66,6 @@ export async function getUserPosts(courseId, {
|
||||
countFlagged,
|
||||
cohort,
|
||||
} = {}) {
|
||||
const learnerPostsApiUrl = `${getCoursesApiUrl()}${courseId}/learner/`;
|
||||
|
||||
const params = snakeCaseObject({
|
||||
page,
|
||||
pageSize,
|
||||
@@ -81,6 +80,6 @@ export async function getUserPosts(courseId, {
|
||||
});
|
||||
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(learnerPostsApiUrl, { params });
|
||||
.get(learnerPostsApiUrl(courseId), { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
79
src/discussions/learners/data/api.test.jsx
Normal file
79
src/discussions/learners/data/api.test.jsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { Factory } from 'rosie';
|
||||
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { initializeMockApp } from '@edx/frontend-platform/testing';
|
||||
|
||||
import { setupLearnerMockResponse, setupPostsMockResponse } from '../test-utils';
|
||||
|
||||
import './__factories__';
|
||||
|
||||
const courseId2 = 'course-v1:edX+TestX+Test_Course2';
|
||||
let axiosMock;
|
||||
|
||||
describe('Learner api test cases', () => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
Factory.resetAll();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
axiosMock.reset();
|
||||
});
|
||||
|
||||
it('Successfully get and store API response for the learner\'s list and learners posts in redux',
|
||||
async () => {
|
||||
const learners = await setupLearnerMockResponse();
|
||||
const threads = await setupPostsMockResponse();
|
||||
|
||||
expect(learners.status).toEqual('successful');
|
||||
expect(Object.values(learners.learnerProfiles)).toHaveLength(3);
|
||||
expect(threads.status).toEqual('successful');
|
||||
expect(Object.values(threads.threadsById)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ status: 'statusUnread', search: 'Title', cohort: 'post' },
|
||||
{ status: 'statusUnanswered', search: 'Title', cohort: 'post' },
|
||||
{ status: 'statusReported', search: 'Title', cohort: 'post' },
|
||||
{ status: 'statusUnresponded', search: 'Title', cohort: 'post' },
|
||||
])('Successfully fetch user posts based on %s filters',
|
||||
async ({ status, search, cohort }) => {
|
||||
const threads = await setupPostsMockResponse({ filters: { status, search, cohort } });
|
||||
|
||||
expect(threads.status).toEqual('successful');
|
||||
expect(Object.values(threads.threadsById)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('Failed to fetch learners', async () => {
|
||||
const learners = await setupLearnerMockResponse({ learnerCourseId: courseId2 });
|
||||
|
||||
expect(learners.status).toEqual('failed');
|
||||
});
|
||||
|
||||
it('Denied to fetch learners', async () => {
|
||||
const learners = await setupLearnerMockResponse({ statusCode: 403 });
|
||||
|
||||
expect(learners.status).toEqual('denied');
|
||||
});
|
||||
|
||||
it('Failed to fetch learnerPosts', async () => {
|
||||
const threads = await setupPostsMockResponse({ learnerCourseId: courseId2 });
|
||||
|
||||
expect(threads.status).toEqual('failed');
|
||||
});
|
||||
|
||||
it('Denied to fetch learnerPosts', async () => {
|
||||
const threads = await setupPostsMockResponse({ statusCode: 403 });
|
||||
|
||||
expect(threads.status).toEqual('denied');
|
||||
});
|
||||
});
|
||||
120
src/discussions/learners/data/redux.test.jsx
Normal file
120
src/discussions/learners/data/redux.test.jsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { Factory } from 'rosie';
|
||||
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { initializeMockApp } from '@edx/frontend-platform/testing';
|
||||
|
||||
import { initializeStore } from '../../../store';
|
||||
import { executeThunk } from '../../../test-utils';
|
||||
import { setupLearnerMockResponse } from '../test-utils';
|
||||
import { setPostFilter, setSortedBy, setUsernameSearch } from './slices';
|
||||
import { fetchLearners } from './thunks';
|
||||
|
||||
import './__factories__';
|
||||
|
||||
let axiosMock;
|
||||
let store;
|
||||
|
||||
describe('Learner redux test cases', () => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
Factory.resetAll();
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
axiosMock.reset();
|
||||
});
|
||||
|
||||
test('Successfully load initial states in redux', async () => {
|
||||
executeThunk(
|
||||
fetchLearners('course-v1:edX+DemoX+Demo_Course', { usernameSearch: 'learner-1' }),
|
||||
store.dispatch, store.getState,
|
||||
);
|
||||
const { learners } = store.getState();
|
||||
|
||||
expect(learners.status).toEqual('in-progress');
|
||||
expect(learners.learnerProfiles).toEqual({});
|
||||
expect(learners.pages).toHaveLength(0);
|
||||
expect(learners.nextPage).toBeNull();
|
||||
expect(learners.totalPages).toBeNull();
|
||||
expect(learners.totalLearners).toBeNull();
|
||||
expect(learners.sortedBy).toEqual('activity');
|
||||
expect(learners.usernameSearch).toBeNull();
|
||||
expect(learners.postFilter.postType).toEqual('all');
|
||||
expect(learners.postFilter.status).toEqual('statusAll');
|
||||
expect(learners.postFilter.orderBy).toEqual('lastActivityAt');
|
||||
expect(learners.postFilter.cohort).toEqual('');
|
||||
});
|
||||
|
||||
test('Successfully store a learner posts stats data as pages object in redux',
|
||||
async () => {
|
||||
const learners = await setupLearnerMockResponse();
|
||||
const page = learners.pages[0];
|
||||
const statsObject = page[0];
|
||||
|
||||
expect(page).toHaveLength(3);
|
||||
expect(statsObject.responses).toEqual(3);
|
||||
expect(statsObject.threads).toEqual(1);
|
||||
expect(statsObject.replies).toEqual(0);
|
||||
});
|
||||
|
||||
test('Successfully store the nextPage, totalPages, totalLearners, and sortedBy data in redux',
|
||||
async () => {
|
||||
const learners = await setupLearnerMockResponse();
|
||||
|
||||
expect(learners.nextPage).toEqual(2);
|
||||
expect(learners.totalPages).toEqual(2);
|
||||
expect(learners.totalLearners).toEqual(6);
|
||||
expect(learners.sortedBy).toEqual('activity');
|
||||
});
|
||||
|
||||
test('Successfully updated the learner\'s sort data in redux', async () => {
|
||||
const learners = await setupLearnerMockResponse();
|
||||
|
||||
expect(learners.sortedBy).toEqual('activity');
|
||||
expect(learners.pages[0]).toHaveLength(3);
|
||||
|
||||
await store.dispatch(setSortedBy('recency'));
|
||||
const updatedLearners = store.getState().learners;
|
||||
|
||||
expect(updatedLearners.sortedBy).toEqual('recency');
|
||||
expect(updatedLearners.pages).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('Successfully updated the post-filter data in redux', async () => {
|
||||
const learners = await setupLearnerMockResponse();
|
||||
const filter = {
|
||||
...learners.postFilter,
|
||||
postType: 'discussion',
|
||||
};
|
||||
|
||||
expect(learners.postFilter.postType).toEqual('all');
|
||||
|
||||
await store.dispatch(setPostFilter(filter));
|
||||
const updatedLearners = store.getState().learners;
|
||||
|
||||
expect(updatedLearners.postFilter.postType).toEqual('discussion');
|
||||
expect(updatedLearners.pages).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('Successfully update the learner\'s search query in redux when searching for a learner',
|
||||
async () => {
|
||||
const learners = await setupLearnerMockResponse();
|
||||
|
||||
expect(learners.usernameSearch).toBeNull();
|
||||
|
||||
await store.dispatch(setUsernameSearch('learner-2'));
|
||||
const updatedLearners = store.getState().learners;
|
||||
|
||||
expect(updatedLearners.usernameSearch).toEqual('learner-2');
|
||||
});
|
||||
});
|
||||
81
src/discussions/learners/data/selector.test.jsx
Normal file
81
src/discussions/learners/data/selector.test.jsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { Factory } from 'rosie';
|
||||
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { initializeMockApp } from '@edx/frontend-platform/testing';
|
||||
|
||||
import { initializeStore } from '../../../store';
|
||||
import { executeThunk } from '../../../test-utils';
|
||||
import { getUserProfileApiUrl, learnersApiUrl } from './api';
|
||||
import {
|
||||
learnersLoadingStatus,
|
||||
selectLearnerNextPage,
|
||||
selectLearnerSorting,
|
||||
selectUsernameSearch,
|
||||
} from './selectors';
|
||||
import { fetchLearners } from './thunks';
|
||||
|
||||
import './__factories__';
|
||||
|
||||
const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||
const userProfileApiUrl = getUserProfileApiUrl();
|
||||
|
||||
let axiosMock;
|
||||
let store;
|
||||
const username = 'abc123';
|
||||
const learnerCount = 6;
|
||||
let state;
|
||||
|
||||
describe('Learner selectors test cases', () => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username,
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
Factory.resetAll();
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
|
||||
axiosMock.onGet(learnersApiUrl(courseId))
|
||||
.reply(() => [200, Factory.build('learnersResult', {}, {
|
||||
count: learnerCount,
|
||||
pageSize: 3,
|
||||
})]);
|
||||
|
||||
axiosMock.onGet(`${userProfileApiUrl}?username=learner-1,learner-2,learner-3`)
|
||||
.reply(() => [200, Factory.build('learnersProfile', {}, {
|
||||
username: ['learner-1', 'learner-2', 'learner-3'],
|
||||
}).profiles]);
|
||||
|
||||
await executeThunk(fetchLearners(courseId), store.dispatch, store.getState);
|
||||
state = store.getState();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
axiosMock.reset();
|
||||
});
|
||||
|
||||
test('learnersLoadingStatus should return learners list loading status.', async () => {
|
||||
const status = learnersLoadingStatus()(state);
|
||||
expect(status).toEqual('successful');
|
||||
});
|
||||
|
||||
test('selectUsernameSearch should return a learner search query.', async () => {
|
||||
const userNameSearch = selectUsernameSearch()(state);
|
||||
expect(userNameSearch).toBeNull();
|
||||
});
|
||||
|
||||
test('selectLearnerSorting should return learner sortedBy.', async () => {
|
||||
const learnerSorting = selectLearnerSorting()(state);
|
||||
expect(learnerSorting).toEqual('activity');
|
||||
});
|
||||
|
||||
test('selectLearnerNextPage should return learners next page.', async () => {
|
||||
const learnerNextPage = selectLearnerNextPage()(state);
|
||||
expect(learnerNextPage).toEqual(2);
|
||||
});
|
||||
});
|
||||
@@ -40,7 +40,7 @@ function LearnerCard({
|
||||
<div className="d-flex flex-column justify-content-start mw-100 flex-fill">
|
||||
<div className="d-flex align-items-center flex-fill">
|
||||
<div
|
||||
className="text-truncate font-weight-500 font-size-14 text-primary-500 font-style-normal font-family-inter"
|
||||
className="text-truncate font-weight-500 font-size-14 text-primary-500 font-style"
|
||||
>
|
||||
{learner.username}
|
||||
</div>
|
||||
|
||||
52
src/discussions/learners/test-utils.js
Normal file
52
src/discussions/learners/test-utils.js
Normal file
@@ -0,0 +1,52 @@
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { Factory } from 'rosie';
|
||||
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
import { initializeStore } from '../../store';
|
||||
import { executeThunk } from '../../test-utils';
|
||||
import { getUserProfileApiUrl, learnerPostsApiUrl, learnersApiUrl } from './data/api';
|
||||
import { fetchLearners, fetchUserPosts } from './data/thunks';
|
||||
|
||||
const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||
|
||||
export async function setupLearnerMockResponse({
|
||||
learnerCourseId = courseId,
|
||||
statusCode = 200,
|
||||
learnerCount = 6,
|
||||
} = {}) {
|
||||
const store = initializeStore();
|
||||
const axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
|
||||
axiosMock.onGet(learnersApiUrl(learnerCourseId))
|
||||
.reply(() => [statusCode, Factory.build('learnersResult', {}, {
|
||||
count: learnerCount,
|
||||
pageSize: 3,
|
||||
})]);
|
||||
|
||||
axiosMock.onGet(`${getUserProfileApiUrl()}?username=learner-1,learner-2,learner-3`)
|
||||
.reply(() => [statusCode, Factory.build('learnersProfile', {}, {
|
||||
username: ['learner-1', 'learner-2', 'learner-3'],
|
||||
}).profiles]);
|
||||
|
||||
await executeThunk(fetchLearners(courseId), store.dispatch, store.getState);
|
||||
return store.getState().learners;
|
||||
}
|
||||
|
||||
export async function setupPostsMockResponse({
|
||||
learnerCourseId = courseId,
|
||||
statusCode = 200,
|
||||
username = 'abc123',
|
||||
filters = { status: 'all' },
|
||||
} = {}) {
|
||||
const store = initializeStore();
|
||||
const axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
|
||||
axiosMock.onGet(learnerPostsApiUrl(learnerCourseId), { username, count_flagged: true })
|
||||
.reply(() => [statusCode, Factory.build('learnerPosts', {}, {
|
||||
abuseFlaggedCount: 1,
|
||||
})]);
|
||||
|
||||
await executeThunk(fetchUserPosts(courseId, { filters }), store.dispatch, store.getState);
|
||||
return store.getState().threads;
|
||||
}
|
||||
149
src/discussions/post-comments/PostCommentsView.jsx
Normal file
149
src/discussions/post-comments/PostCommentsView.jsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useParams } from 'react-router';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Button, Icon, IconButton, Spinner,
|
||||
} from '@edx/paragon';
|
||||
import { ArrowBack } from '@edx/paragon/icons';
|
||||
|
||||
import {
|
||||
EndorsementStatus, PostsPages, RequestStatus, 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 { fetchThread } from '../posts/data/thunks';
|
||||
import { discussionsPath } from '../utils';
|
||||
import { ResponseEditor } from './comments/comment';
|
||||
import CommentsSort from './comments/CommentsSort';
|
||||
import CommentsView from './comments/CommentsView';
|
||||
import { useCommentsCount, usePost } from './data/hooks';
|
||||
import { selectCommentsStatus } from './data/selectors';
|
||||
import messages from './messages';
|
||||
|
||||
function PostCommentsView({ intl }) {
|
||||
const [isLoading, submitDispatch] = useDispatchWithState();
|
||||
const { postId } = useParams();
|
||||
const thread = usePost(postId);
|
||||
const commentsStatus = useSelector(selectCommentsStatus);
|
||||
const commentsCount = useCommentsCount(postId);
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
const isOnDesktop = useIsOnDesktop();
|
||||
const [addingResponse, setAddingResponse] = useState(false);
|
||||
const {
|
||||
courseId, learnerUsername, category, topicId, page, enableInContextSidebar,
|
||||
} = useContext(DiscussionContext);
|
||||
const enableCommentsSort = false;
|
||||
|
||||
useEffect(() => {
|
||||
if (!thread) { submitDispatch(fetchThread(postId, courseId, true)); }
|
||||
setAddingResponse(false);
|
||||
}, [postId]);
|
||||
|
||||
if (!thread) {
|
||||
if (!isLoading) {
|
||||
return (
|
||||
<EmptyPage title={intl.formatMessage(messages.noThreadFound)} />
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
}}
|
||||
>
|
||||
<Spinner animation="border" variant="primary" data-testid="loading-indicator" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isOnDesktop && (
|
||||
enableInContextSidebar ? (
|
||||
<>
|
||||
<div className="px-4 py-1.5 bg-white">
|
||||
<Button
|
||||
variant="plain"
|
||||
className="px-0 line-height-24 py-0 my-1.5 border-0 font-weight-normal font-style 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="discussion-comments d-flex flex-column card border-0 post-card-margin post-card-padding on-focus"
|
||||
>
|
||||
<Post post={thread} handleAddResponseButton={() => setAddingResponse(true)} />
|
||||
{!thread.closed && (
|
||||
<ResponseEditor
|
||||
postId={postId}
|
||||
handleCloseEditor={() => setAddingResponse(false)}
|
||||
addingResponse={addingResponse}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{!!commentsCount && commentsStatus === RequestStatus.SUCCESSFUL && enableCommentsSort && <CommentsSort />}
|
||||
{thread.type === ThreadType.DISCUSSION && (
|
||||
<CommentsView
|
||||
postId={postId}
|
||||
intl={intl}
|
||||
postType={thread.type}
|
||||
endorsed={EndorsementStatus.DISCUSSION}
|
||||
isClosed={thread.closed}
|
||||
/>
|
||||
)}
|
||||
{thread.type === ThreadType.QUESTION && (
|
||||
<>
|
||||
<CommentsView
|
||||
postId={postId}
|
||||
intl={intl}
|
||||
postType={thread.type}
|
||||
endorsed={EndorsementStatus.ENDORSED}
|
||||
isClosed={thread.closed}
|
||||
/>
|
||||
<CommentsView
|
||||
postId={postId}
|
||||
intl={intl}
|
||||
postType={thread.type}
|
||||
endorsed={EndorsementStatus.UNENDORSED}
|
||||
isClosed={thread.closed}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
PostCommentsView.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(PostCommentsView);
|
||||
750
src/discussions/post-comments/PostCommentsView.test.jsx
Normal file
750
src/discussions/post-comments/PostCommentsView.test.jsx
Normal file
@@ -0,0 +1,750 @@
|
||||
import {
|
||||
act, fireEvent, render, screen, waitFor, within,
|
||||
} from '@testing-library/react';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { IntlProvider } from 'react-intl';
|
||||
import { MemoryRouter, Route } from 'react-router';
|
||||
import { Factory } from 'rosie';
|
||||
|
||||
import { camelCaseObject, initializeMockApp } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
|
||||
import { getApiBaseUrl } from '../../data/constants';
|
||||
import { initializeStore } from '../../store';
|
||||
import { executeThunk } from '../../test-utils';
|
||||
import { DiscussionContext } from '../common/context';
|
||||
import { getCourseConfigApiUrl } from '../data/api';
|
||||
import { fetchCourseConfig } from '../data/thunks';
|
||||
import DiscussionContent from '../discussions-home/DiscussionContent';
|
||||
import { getThreadsApiUrl } from '../posts/data/api';
|
||||
import { fetchThread, fetchThreads } from '../posts/data/thunks';
|
||||
import { fetchCourseTopics } from '../topics/data/thunks';
|
||||
import { getCommentsApiUrl } from './data/api';
|
||||
|
||||
import '../posts/data/__factories__';
|
||||
import './data/__factories__';
|
||||
import '../topics/data/__factories__';
|
||||
|
||||
const courseConfigApiUrl = getCourseConfigApiUrl();
|
||||
const commentsApiUrl = getCommentsApiUrl();
|
||||
const threadsApiUrl = getThreadsApiUrl();
|
||||
const discussionPostId = 'thread-1';
|
||||
const questionPostId = 'thread-2';
|
||||
const closedPostId = 'thread-2';
|
||||
const courseId = 'course-v1:edX+TestX+Test_Course';
|
||||
const topicsApiUrl = `${getApiBaseUrl()}/api/discussion/v1/course_topics/${courseId}`;
|
||||
const reverseOrder = false;
|
||||
const enableInContextSidebar = false;
|
||||
let store;
|
||||
let axiosMock;
|
||||
let testLocation;
|
||||
|
||||
function mockAxiosReturnPagedComments() {
|
||||
[null, false, true].forEach(endorsed => {
|
||||
const postId = endorsed === null ? discussionPostId : questionPostId;
|
||||
[1, 2].forEach(page => {
|
||||
axiosMock
|
||||
.onGet(commentsApiUrl, {
|
||||
params: {
|
||||
thread_id: postId,
|
||||
page,
|
||||
page_size: undefined,
|
||||
requested_fields: 'profile_image',
|
||||
endorsed,
|
||||
reverse_order: reverseOrder,
|
||||
enable_in_context_sidebar: enableInContextSidebar,
|
||||
},
|
||||
})
|
||||
.reply(200, Factory.build('commentsResult', { can_delete: true }, {
|
||||
threadId: postId,
|
||||
page,
|
||||
pageSize: 1,
|
||||
count: 2,
|
||||
endorsed,
|
||||
childCount: page === 1 ? 2 : 0,
|
||||
}));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function mockAxiosReturnPagedCommentsResponses() {
|
||||
const parentId = 'comment-1';
|
||||
const commentsResponsesApiUrl = `${commentsApiUrl}${parentId}/`;
|
||||
const paramsTemplate = {
|
||||
page: undefined,
|
||||
page_size: undefined,
|
||||
requested_fields: 'profile_image',
|
||||
};
|
||||
|
||||
for (let page = 1; page <= 2; page++) {
|
||||
axiosMock
|
||||
.onGet(commentsResponsesApiUrl, { params: { ...paramsTemplate, page } })
|
||||
.reply(200, Factory.build('commentsResult', null, {
|
||||
parentId,
|
||||
page,
|
||||
pageSize: 1,
|
||||
count: 2,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
async function getThreadAPIResponse(threadId, topicId) {
|
||||
axiosMock.onGet(`${threadsApiUrl}${discussionPostId}/`)
|
||||
.reply(200, Factory.build('thread', { id: threadId, topic_id: topicId }));
|
||||
await executeThunk(fetchThread(discussionPostId), store.dispatch, store.getState);
|
||||
}
|
||||
|
||||
function renderComponent(postId) {
|
||||
const wrapper = 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>
|
||||
</AppProvider>
|
||||
</IntlProvider>,
|
||||
);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
describe('PostView', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
store = initializeStore();
|
||||
Factory.resetAll();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
|
||||
axiosMock.onGet(topicsApiUrl)
|
||||
.reply(200, {
|
||||
non_courseware_topics: Factory.buildList('topic', 1, {}, { topicPrefix: 'non-courseware-' }),
|
||||
courseware_topics: Factory.buildList('category', 1, {}, { name: 'courseware' }),
|
||||
});
|
||||
executeThunk(fetchCourseTopics(courseId), store.dispatch, store.getState);
|
||||
});
|
||||
|
||||
it('should show Topic Info for non-courseware topics', async () => {
|
||||
await getThreadAPIResponse('thread-1', 'non-courseware-topic-1');
|
||||
renderComponent(discussionPostId);
|
||||
expect(await screen.findByText('Related to')).toBeInTheDocument();
|
||||
expect(await screen.findByText('non-courseware-topic 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show Topic Info for courseware topics with category', async () => {
|
||||
await getThreadAPIResponse('thread-2', 'courseware-topic-2');
|
||||
|
||||
renderComponent('thread-2');
|
||||
expect(await screen.findByText('Related to')).toBeInTheDocument();
|
||||
expect(await screen.findByText('category-1 / courseware-topic 2')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ThreadView', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
store = initializeStore();
|
||||
Factory.resetAll();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
axiosMock.onGet(threadsApiUrl)
|
||||
.reply(200, Factory.build('threadsResult'));
|
||||
axiosMock.onPatch(new RegExp(`${commentsApiUrl}*`)).reply(({
|
||||
url,
|
||||
data,
|
||||
}) => {
|
||||
const commentId = url.match(/comments\/(?<id>[a-z1-9-]+)\//).groups.id;
|
||||
const {
|
||||
rawBody,
|
||||
} = camelCaseObject(JSON.parse(data));
|
||||
return [200, Factory.build('comment', {
|
||||
id: commentId,
|
||||
rendered_body: rawBody,
|
||||
raw_body: rawBody,
|
||||
})];
|
||||
});
|
||||
axiosMock.onPost(commentsApiUrl)
|
||||
.reply(({ data }) => {
|
||||
const {
|
||||
rawBody,
|
||||
threadId,
|
||||
} = camelCaseObject(JSON.parse(data));
|
||||
return [200, Factory.build(
|
||||
'comment',
|
||||
{
|
||||
rendered_body: rawBody,
|
||||
raw_body: rawBody,
|
||||
thread_id: threadId,
|
||||
},
|
||||
)];
|
||||
});
|
||||
|
||||
executeThunk(fetchThreads(courseId), store.dispatch, store.getState);
|
||||
mockAxiosReturnPagedComments();
|
||||
mockAxiosReturnPagedCommentsResponses();
|
||||
});
|
||||
|
||||
describe('for all post types', () => {
|
||||
function assertLastUpdateData(data) {
|
||||
expect(JSON.parse(axiosMock.history.patch[axiosMock.history.patch.length - 1].data)).toMatchObject(data);
|
||||
}
|
||||
|
||||
it('should show and hide the editor', async () => {
|
||||
renderComponent(discussionPostId);
|
||||
const post = screen.getByTestId('post-thread-1');
|
||||
const hoverCard = within(post).getByTestId('hover-card-thread-1');
|
||||
const addResponseButton = within(hoverCard).getByRole('button', { name: /Add response/i });
|
||||
await act(async () => {
|
||||
fireEvent.click(
|
||||
addResponseButton,
|
||||
);
|
||||
});
|
||||
expect(screen.queryByTestId('tinymce-editor')).toBeInTheDocument();
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
|
||||
});
|
||||
expect(screen.queryByTestId('tinymce-editor')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should allow posting a response', async () => {
|
||||
renderComponent(discussionPostId);
|
||||
const post = await screen.findByTestId('post-thread-1');
|
||||
const hoverCard = within(post).getByTestId('hover-card-thread-1');
|
||||
const addResponseButton = within(hoverCard).getByRole('button', { name: /Add response/i });
|
||||
await act(async () => {
|
||||
fireEvent.click(
|
||||
addResponseButton,
|
||||
);
|
||||
});
|
||||
await act(() => {
|
||||
fireEvent.change(screen.getByTestId('tinymce-editor'), { target: { value: 'testing123' } });
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(
|
||||
screen.getByText(/submit/i),
|
||||
);
|
||||
});
|
||||
expect(screen.queryByTestId('tinymce-editor')).not.toBeInTheDocument();
|
||||
await waitFor(async () => expect(await screen.findByTestId('comment-1')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('should not allow posting a response on a closed post', async () => {
|
||||
renderComponent(closedPostId);
|
||||
const post = screen.getByTestId('post-thread-2');
|
||||
const hoverCard = within(post).getByTestId('hover-card-thread-2');
|
||||
expect(within(hoverCard).getByRole('button', { name: /Add response/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should allow posting a comment', async () => {
|
||||
renderComponent(discussionPostId);
|
||||
const comment = await waitFor(() => screen.findByTestId('comment-comment-1'));
|
||||
const hoverCard = within(comment).getByTestId('hover-card-comment-1');
|
||||
await act(async () => {
|
||||
fireEvent.click(
|
||||
within(hoverCard).getByRole('button', { name: /Add comment/i }),
|
||||
);
|
||||
});
|
||||
act(() => {
|
||||
fireEvent.change(screen.getByTestId('tinymce-editor'), { target: { value: 'testing123' } });
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(
|
||||
screen.getByText(/submit/i),
|
||||
);
|
||||
});
|
||||
expect(screen.queryByTestId('tinymce-editor')).not.toBeInTheDocument();
|
||||
await waitFor(async () => expect(await screen.findByTestId('reply-comment-7')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('should not allow posting a comment on a closed post', async () => {
|
||||
renderComponent(closedPostId);
|
||||
const comment = await waitFor(() => screen.findByTestId('comment-comment-3'));
|
||||
const hoverCard = within(comment).getByTestId('hover-card-comment-3');
|
||||
expect(within(hoverCard).getByRole('button', { name: /Add comment/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should allow editing an existing comment', async () => {
|
||||
renderComponent(discussionPostId);
|
||||
const comment = await waitFor(() => screen.findByTestId('comment-comment-1'));
|
||||
const hoverCard = within(comment).getByTestId('hover-card-comment-1');
|
||||
await act(async () => {
|
||||
fireEvent.click(
|
||||
within(hoverCard).getByRole('button', { name: /actions menu/i }),
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
|
||||
});
|
||||
act(() => {
|
||||
fireEvent.change(screen.getByTestId('tinymce-editor'), { target: { value: 'testing123' } });
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /submit/i }));
|
||||
});
|
||||
await waitFor(async () => {
|
||||
expect(await screen.findByTestId('comment-comment-1')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
async function setupCourseConfig(reasonCodesEnabled = true) {
|
||||
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`).reply(200, {
|
||||
has_moderation_privileges: true,
|
||||
reason_codes_enabled: reasonCodesEnabled,
|
||||
editReasons: [
|
||||
{ code: 'reason-1', label: 'reason 1' },
|
||||
{ code: 'reason-2', label: 'reason 2' },
|
||||
],
|
||||
postCloseReasons: [
|
||||
{ code: 'reason-1', label: 'reason 1' },
|
||||
{ code: 'reason-2', label: 'reason 2' },
|
||||
],
|
||||
});
|
||||
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/settings`).reply(200, {});
|
||||
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
|
||||
}
|
||||
|
||||
it('should show reason codes when editing an existing comment', async () => {
|
||||
setupCourseConfig();
|
||||
renderComponent(discussionPostId);
|
||||
const comment = await waitFor(() => screen.findByTestId('comment-comment-1'));
|
||||
const hoverCard = within(comment).getByTestId('hover-card-comment-1');
|
||||
await act(async () => {
|
||||
fireEvent.click(
|
||||
within(hoverCard).getByRole('button', { name: /actions menu/i }),
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
|
||||
});
|
||||
expect(screen.queryByRole('combobox', { name: /reason for editing/i })).toBeInTheDocument();
|
||||
expect(screen.getAllByRole('option', { name: /reason \d/i })).toHaveLength(2);
|
||||
await act(async () => {
|
||||
fireEvent.change(screen.queryByRole('combobox', { name: /reason for editing/i }),
|
||||
{ target: { value: null } });
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.change(screen.queryByRole('combobox',
|
||||
{ name: /reason for editing/i }), { target: { value: 'reason-1' } });
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.change(screen.getByTestId('tinymce-editor'), { target: { value: 'testing123' } });
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /submit/i }));
|
||||
});
|
||||
assertLastUpdateData({ edit_reason_code: 'reason-1' });
|
||||
});
|
||||
|
||||
it('should show reason codes when closing a post', async () => {
|
||||
setupCourseConfig();
|
||||
renderComponent(discussionPostId);
|
||||
const post = await screen.findByTestId('post-thread-1');
|
||||
const hoverCard = within(post).getByTestId('hover-card-thread-1');
|
||||
await act(async () => {
|
||||
fireEvent.click(
|
||||
within(hoverCard).getByRole('button', { name: /actions menu/i }),
|
||||
);
|
||||
});
|
||||
expect(screen.queryByRole('dialog', { name: /close post/i })).not.toBeInTheDocument();
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /close/i }));
|
||||
});
|
||||
expect(screen.queryByRole('dialog', { name: /close post/i })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('combobox', { name: /reason/i })).toBeInTheDocument();
|
||||
expect(screen.getAllByRole('option', { name: /reason \d/i })).toHaveLength(2);
|
||||
await act(async () => {
|
||||
fireEvent.change(screen.queryByRole('combobox', { name: /reason/i }), { target: { value: 'reason-1' } });
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /close post/i }));
|
||||
});
|
||||
expect(screen.queryByRole('dialog', { name: /close post/i })).not.toBeInTheDocument();
|
||||
assertLastUpdateData({ closed: true, close_reason_code: 'reason-1' });
|
||||
});
|
||||
|
||||
it('should close the post directly if reason codes are not enabled', async () => {
|
||||
setupCourseConfig(false);
|
||||
renderComponent(discussionPostId);
|
||||
const post = await screen.findByTestId('post-thread-1');
|
||||
const hoverCard = within(post).getByTestId('hover-card-thread-1');
|
||||
await act(async () => {
|
||||
fireEvent.click(
|
||||
within(hoverCard).getByRole('button', { name: /actions menu/i }),
|
||||
);
|
||||
});
|
||||
expect(screen.queryByRole('dialog', { name: /close post/i })).not.toBeInTheDocument();
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /close/i }));
|
||||
});
|
||||
expect(screen.queryByRole('dialog', { name: /close post/i })).not.toBeInTheDocument();
|
||||
assertLastUpdateData({ closed: true });
|
||||
});
|
||||
|
||||
it.each([true, false])(
|
||||
'should reopen the post directly when reason codes enabled=%s',
|
||||
async (reasonCodesEnabled) => {
|
||||
setupCourseConfig(reasonCodesEnabled);
|
||||
renderComponent(closedPostId);
|
||||
const post = screen.getByTestId('post-thread-2');
|
||||
const hoverCard = within(post).getByTestId('hover-card-thread-2');
|
||||
await act(async () => {
|
||||
fireEvent.click(
|
||||
within(hoverCard).getByRole('button', { name: /actions menu/i }),
|
||||
);
|
||||
});
|
||||
expect(screen.queryByRole('dialog', { name: /close post/i })).not.toBeInTheDocument();
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /reopen/i }));
|
||||
});
|
||||
expect(screen.queryByRole('dialog', { name: /close post/i })).not.toBeInTheDocument();
|
||||
assertLastUpdateData({ closed: false });
|
||||
},
|
||||
);
|
||||
|
||||
it('should show the editor if the post is edited', async () => {
|
||||
setupCourseConfig(false);
|
||||
renderComponent(discussionPostId);
|
||||
const post = await screen.findByTestId('post-thread-1');
|
||||
const hoverCard = within(post).getByTestId('hover-card-thread-1');
|
||||
await act(async () => {
|
||||
fireEvent.click(
|
||||
within(hoverCard).getByRole('button', { name: /actions menu/i }),
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
|
||||
});
|
||||
expect(testLocation.pathname).toBe(`/${courseId}/posts/${discussionPostId}/edit`);
|
||||
});
|
||||
|
||||
it('should allow pinning the post', async () => {
|
||||
renderComponent(discussionPostId);
|
||||
const post = await screen.findByTestId('post-thread-1');
|
||||
const hoverCard = within(post).getByTestId('hover-card-thread-1');
|
||||
await act(async () => {
|
||||
fireEvent.click(
|
||||
within(hoverCard).getByRole('button', { name: /actions menu/i }),
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /pin/i }));
|
||||
});
|
||||
assertLastUpdateData({ pinned: false });
|
||||
});
|
||||
|
||||
it('should allow reporting the post', async () => {
|
||||
renderComponent(discussionPostId);
|
||||
const post = await screen.findByTestId('post-thread-1');
|
||||
const hoverCard = within(post).getByTestId('hover-card-thread-1');
|
||||
await act(async () => {
|
||||
fireEvent.click(
|
||||
within(hoverCard).getByRole('button', { name: /actions menu/i }),
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /report/i }));
|
||||
});
|
||||
expect(screen.queryByRole('dialog', { name: /Report \w+/i, exact: false })).toBeInTheDocument();
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.queryByRole('button', { name: /Confirm/i }));
|
||||
});
|
||||
expect(screen.queryByRole('dialog', { name: /Report \w+/i, exact: false })).not.toBeInTheDocument();
|
||||
assertLastUpdateData({ abuse_flagged: true });
|
||||
});
|
||||
|
||||
it('handles liking a post', async () => {
|
||||
renderComponent(discussionPostId);
|
||||
|
||||
const post = await screen.findByTestId('post-thread-1');
|
||||
const hoverCard = within(post).getByTestId('hover-card-thread-1');
|
||||
await act(async () => {
|
||||
fireEvent.click(
|
||||
within(hoverCard).getByRole('button', { name: /like/i }),
|
||||
);
|
||||
});
|
||||
expect(axiosMock.history.patch).toHaveLength(2);
|
||||
expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject({ voted: true });
|
||||
});
|
||||
|
||||
it('handles liking a comment', async () => {
|
||||
renderComponent(discussionPostId);
|
||||
|
||||
const comment = await waitFor(() => screen.findByTestId('comment-comment-1'));
|
||||
const hoverCard = within(comment).getByTestId('hover-card-comment-1');
|
||||
await act(async () => {
|
||||
fireEvent.click(
|
||||
within(hoverCard).getByRole('button', { name: /like/i }),
|
||||
);
|
||||
});
|
||||
expect(axiosMock.history.patch).toHaveLength(2);
|
||||
expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject({ voted: true });
|
||||
});
|
||||
|
||||
it('handles endorsing comments', async () => {
|
||||
renderComponent(discussionPostId);
|
||||
// Wait for the content to load
|
||||
const comment = await waitFor(() => screen.findByTestId('comment-comment-1'));
|
||||
const hoverCard = within(comment).getByTestId('hover-card-comment-1');
|
||||
await act(async () => {
|
||||
fireEvent.click(within(hoverCard).getByRole('button', { name: /Endorse/i }));
|
||||
});
|
||||
expect(axiosMock.history.patch).toHaveLength(2);
|
||||
expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject({ endorsed: true });
|
||||
});
|
||||
|
||||
it('handles reporting comments', async () => {
|
||||
renderComponent(discussionPostId);
|
||||
// Wait for the content to load
|
||||
const comment = await waitFor(() => screen.findByTestId('comment-comment-1'));
|
||||
const hoverCard = within(comment).getByTestId('hover-card-comment-1');
|
||||
await act(async () => {
|
||||
fireEvent.click(
|
||||
within(hoverCard).getByRole('button', { name: /actions menu/i }),
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /Report/i }));
|
||||
});
|
||||
expect(screen.queryByRole('dialog', { name: /Report \w+/i, exact: false })).toBeInTheDocument();
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.queryByRole('button', { name: /Confirm/i }));
|
||||
});
|
||||
expect(screen.queryByRole('dialog', { name: /Report \w+/i, exact: false })).not.toBeInTheDocument();
|
||||
expect(axiosMock.history.patch).toHaveLength(2);
|
||||
expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject({ abuse_flagged: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('for discussion thread', () => {
|
||||
const findLoadMoreCommentsButton = () => screen.findByTestId('load-more-comments');
|
||||
|
||||
it('shown post not found when post id does not belong to course', async () => {
|
||||
renderComponent('unloaded-id');
|
||||
expect(await screen.findByText('Thread not found', { exact: true }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('initially loads only the first page', async () => {
|
||||
renderComponent(discussionPostId);
|
||||
expect(await screen.findByTestId('comment-comment-1'))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('comment-comment-2'))
|
||||
.not
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('pressing load more button will load next page of comments', async () => {
|
||||
renderComponent(discussionPostId);
|
||||
|
||||
const loadMoreButton = await findLoadMoreCommentsButton();
|
||||
fireEvent.click(loadMoreButton);
|
||||
|
||||
await screen.findByTestId('comment-comment-1');
|
||||
await screen.findByTestId('comment-comment-2');
|
||||
});
|
||||
|
||||
it('newly loaded comments are appended to the old ones', async () => {
|
||||
renderComponent(discussionPostId);
|
||||
|
||||
const loadMoreButton = await findLoadMoreCommentsButton();
|
||||
fireEvent.click(loadMoreButton);
|
||||
|
||||
await screen.findByTestId('comment-comment-1');
|
||||
// check that comments from the first page are also displayed
|
||||
expect(screen.queryByTestId('comment-comment-2'))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('load more button is hidden when no more comments pages to load', async () => {
|
||||
const totalPages = 2;
|
||||
renderComponent(discussionPostId);
|
||||
|
||||
const loadMoreButton = await findLoadMoreCommentsButton();
|
||||
for (let page = 1; page < totalPages; page++) {
|
||||
fireEvent.click(loadMoreButton);
|
||||
}
|
||||
|
||||
await screen.findByTestId('comment-comment-2');
|
||||
await expect(findLoadMoreCommentsButton())
|
||||
.rejects
|
||||
.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('for question thread', () => {
|
||||
const findLoadMoreCommentsButtons = () => screen.findAllByTestId('load-more-comments');
|
||||
|
||||
it('initially loads only the first page', async () => {
|
||||
act(() => renderComponent(questionPostId));
|
||||
expect(await screen.findByTestId('comment-comment-3'))
|
||||
.toBeInTheDocument();
|
||||
expect(await screen.findByTestId('comment-comment-5'))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('comment-comment-4'))
|
||||
.not
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('pressing load more button will load next page of comments', async () => {
|
||||
act(() => {
|
||||
renderComponent(questionPostId);
|
||||
});
|
||||
|
||||
const [loadMoreButtonEndorsed, loadMoreButtonUnendorsed] = await findLoadMoreCommentsButtons();
|
||||
// Both load more buttons should show
|
||||
expect(await findLoadMoreCommentsButtons()).toHaveLength(2);
|
||||
expect(await screen.findByTestId('comment-comment-3'))
|
||||
.toBeInTheDocument();
|
||||
expect(await screen.findByTestId('comment-comment-5'))
|
||||
.toBeInTheDocument();
|
||||
// Comments from next page should not be loaded yet.
|
||||
expect(await screen.queryByTestId('comment-comment-6'))
|
||||
.not
|
||||
.toBeInTheDocument();
|
||||
expect(await screen.queryByTestId('comment-comment-4'))
|
||||
.not
|
||||
.toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(loadMoreButtonEndorsed);
|
||||
});
|
||||
// Endorsed comment from next page should be loaded now.
|
||||
await waitFor(() => expect(screen.queryByTestId('comment-comment-6'))
|
||||
.toBeInTheDocument());
|
||||
// Unendorsed comment from next page should not be loaded yet.
|
||||
expect(await screen.queryByTestId('comment-comment-4'))
|
||||
.not
|
||||
.toBeInTheDocument();
|
||||
// Now only one load more buttons should show, for unendorsed comments
|
||||
expect(await findLoadMoreCommentsButtons()).toHaveLength(1);
|
||||
await act(async () => {
|
||||
fireEvent.click(loadMoreButtonUnendorsed);
|
||||
});
|
||||
// Unendorsed comment from next page should be loaded now.
|
||||
await waitFor(() => expect(screen.queryByTestId('comment-comment-4'))
|
||||
.toBeInTheDocument());
|
||||
await expect(findLoadMoreCommentsButtons()).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('for comments replies', () => {
|
||||
const findLoadMoreCommentsResponsesButton = () => screen.findByTestId('load-more-comments-responses');
|
||||
|
||||
it('initially loads only the first page', async () => {
|
||||
renderComponent(discussionPostId);
|
||||
|
||||
await waitFor(() => screen.findByTestId('reply-comment-7'));
|
||||
expect(screen.queryByTestId('reply-comment-8')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('pressing load more button will load next page of responses', async () => {
|
||||
renderComponent(discussionPostId);
|
||||
|
||||
const loadMoreButton = await findLoadMoreCommentsResponsesButton();
|
||||
await act(async () => {
|
||||
fireEvent.click(loadMoreButton);
|
||||
});
|
||||
|
||||
await screen.findByTestId('reply-comment-8');
|
||||
});
|
||||
|
||||
it('newly loaded responses are appended to the old ones', async () => {
|
||||
renderComponent(discussionPostId);
|
||||
|
||||
const loadMoreButton = await findLoadMoreCommentsResponsesButton();
|
||||
await act(async () => {
|
||||
fireEvent.click(loadMoreButton);
|
||||
});
|
||||
|
||||
await screen.findByTestId('reply-comment-8');
|
||||
// check that comments from the first page are also displayed
|
||||
expect(screen.queryByTestId('reply-comment-7')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('load more button is hidden when no more responses pages to load', async () => {
|
||||
const totalPages = 2;
|
||||
renderComponent(discussionPostId);
|
||||
|
||||
const loadMoreButton = await findLoadMoreCommentsResponsesButton();
|
||||
for (let page = 1; page < totalPages; page++) {
|
||||
act(() => {
|
||||
fireEvent.click(loadMoreButton);
|
||||
});
|
||||
}
|
||||
|
||||
await screen.findByTestId('reply-comment-8');
|
||||
await expect(findLoadMoreCommentsResponsesButton())
|
||||
.rejects
|
||||
.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe.each([
|
||||
{ component: 'post', testId: 'post-thread-1', cardId: 'hover-card-thread-1' },
|
||||
{ component: 'comment', testId: 'comment-comment-1', cardId: 'hover-card-comment-1' },
|
||||
])('delete confirmation modal', ({
|
||||
component,
|
||||
testId,
|
||||
cardId,
|
||||
}) => {
|
||||
test(`for ${component}`, async () => {
|
||||
renderComponent(discussionPostId);
|
||||
// Wait for the content to load
|
||||
const post = await screen.findByTestId(testId);
|
||||
const hoverCard = within(post).getByTestId(cardId);
|
||||
expect(screen.queryByRole('dialog', { name: /Delete response/i, exact: false })).not.toBeInTheDocument();
|
||||
await act(async () => {
|
||||
fireEvent.click(
|
||||
within(hoverCard).getByRole('button', { name: /actions menu/i }),
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.queryByRole('button', { name: /Delete/i }));
|
||||
});
|
||||
expect(screen.queryByRole('dialog', { name: /Delete/i, exact: false })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('for comments replies', () => {
|
||||
it('shows delete confirmation modal', async () => {
|
||||
renderComponent(discussionPostId);
|
||||
const reply = await waitFor(() => screen.findByTestId('reply-comment-7'));
|
||||
await act(async () => {
|
||||
fireEvent.click(
|
||||
within(reply).getByRole('button', { name: /actions menu/i }),
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.queryByRole('button', { name: /Delete/i }));
|
||||
});
|
||||
expect(screen.queryByRole('dialog', { name: /Delete/i, exact: false })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
92
src/discussions/post-comments/comments/CommentsSort.jsx
Normal file
92
src/discussions/post-comments/comments/CommentsSort.jsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Button, Dropdown, ModalPopup, useToggle,
|
||||
} from '@edx/paragon';
|
||||
import {
|
||||
ExpandLess, ExpandMore,
|
||||
} from '@edx/paragon/icons';
|
||||
|
||||
import { selectCommentSortOrder } from '../data/selectors';
|
||||
import { setCommentSortOrder } from '../data/slices';
|
||||
import messages from '../messages';
|
||||
|
||||
function CommentSortDropdown({
|
||||
intl,
|
||||
}) {
|
||||
const dispatch = useDispatch();
|
||||
const sortedOrder = useSelector(selectCommentSortOrder);
|
||||
const [isOpen, open, close] = useToggle(false);
|
||||
const [target, setTarget] = useState(null);
|
||||
|
||||
const handleActions = (reverseOrder) => {
|
||||
close();
|
||||
dispatch(setCommentSortOrder(reverseOrder));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="comments-sort d-flex justify-content-end mx-4 mt-2">
|
||||
<Button
|
||||
alt={intl.formatMessage(messages.actionsAlt)}
|
||||
ref={setTarget}
|
||||
variant="tertiary"
|
||||
onClick={open}
|
||||
size="sm"
|
||||
iconAfter={isOpen ? ExpandLess : ExpandMore}
|
||||
>
|
||||
{intl.formatMessage(messages.commentSort, {
|
||||
sort: sortedOrder,
|
||||
})}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="actions-dropdown">
|
||||
<ModalPopup
|
||||
onClose={close}
|
||||
positionRef={target}
|
||||
isOpen={isOpen}
|
||||
>
|
||||
<div
|
||||
className="bg-white p-1 shadow d-flex flex-column"
|
||||
data-testid="comment-sort-dropdown-modal-popup"
|
||||
>
|
||||
<Dropdown.Item
|
||||
className="d-flex justify-content-start py-1.5 mb-1"
|
||||
as={Button}
|
||||
variant="tertiary"
|
||||
size="inline"
|
||||
onClick={() => handleActions(false)}
|
||||
autoFocus={sortedOrder === false}
|
||||
>
|
||||
{intl.formatMessage(messages.commentSort, {
|
||||
sort: false,
|
||||
})}
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item
|
||||
className="d-flex justify-content-start py-1.5"
|
||||
as={Button}
|
||||
variant="tertiary"
|
||||
size="inline"
|
||||
onClick={() => handleActions(true)}
|
||||
autoFocus={sortedOrder === true}
|
||||
>
|
||||
{intl.formatMessage(messages.commentSort, {
|
||||
sort: true,
|
||||
})}
|
||||
</Dropdown.Item>
|
||||
</div>
|
||||
</ModalPopup>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
CommentSortDropdown.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
|
||||
};
|
||||
|
||||
export default injectIntl(CommentSortDropdown);
|
||||
130
src/discussions/post-comments/comments/CommentsView.jsx
Normal file
130
src/discussions/post-comments/comments/CommentsView.jsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Button, Spinner } from '@edx/paragon';
|
||||
|
||||
import { EndorsementStatus } from '../../../data/constants';
|
||||
import { useUserCanAddThreadInBlackoutDate } from '../../data/hooks';
|
||||
import { filterPosts, isLastElementOfList } from '../../utils';
|
||||
import { usePostComments } from '../data/hooks';
|
||||
import messages from '../messages';
|
||||
import { Comment, ResponseEditor } from './comment';
|
||||
|
||||
function CommentsView({
|
||||
postType,
|
||||
postId,
|
||||
intl,
|
||||
endorsed,
|
||||
isClosed,
|
||||
}) {
|
||||
const {
|
||||
comments,
|
||||
hasMorePages,
|
||||
isLoading,
|
||||
handleLoadMoreResponses,
|
||||
} = usePostComments(postId, endorsed);
|
||||
|
||||
const endorsedComments = useMemo(() => [...filterPosts(comments, 'endorsed')], [comments]);
|
||||
const unEndorsedComments = useMemo(() => [...filterPosts(comments, 'unendorsed')], [comments]);
|
||||
const userCanAddThreadInBlackoutDate = useUserCanAddThreadInBlackoutDate();
|
||||
const [addingResponse, setAddingResponse] = useState(false);
|
||||
|
||||
const handleDefinition = (message, commentsLength) => (
|
||||
<div
|
||||
className="mx-4 my-14px text-gray-700 font-style"
|
||||
role="heading"
|
||||
aria-level="2"
|
||||
>
|
||||
{intl.formatMessage(message, { num: commentsLength })}
|
||||
</div>
|
||||
);
|
||||
|
||||
const handleComments = (postComments, showLoadMoreResponses = false) => (
|
||||
<div className="mx-4" role="list">
|
||||
{postComments.map((comment) => (
|
||||
<Comment
|
||||
comment={comment}
|
||||
key={comment.id}
|
||||
postType={postType}
|
||||
isClosedPost={isClosed}
|
||||
marginBottom={isLastElementOfList(postComments, comment)}
|
||||
/>
|
||||
))}
|
||||
{hasMorePages && !isLoading && !showLoadMoreResponses && (
|
||||
<Button
|
||||
onClick={handleLoadMoreResponses}
|
||||
variant="link"
|
||||
block="true"
|
||||
className="px-4 mt-3 border-0 line-height-24 py-0 mb-2 font-style font-weight-500 font-size-14"
|
||||
data-testid="load-more-comments"
|
||||
>
|
||||
{intl.formatMessage(messages.loadMoreResponses)}
|
||||
</Button>
|
||||
)}
|
||||
{isLoading && !showLoadMoreResponses && (
|
||||
<div className="mb-2 mt-3 d-flex justify-content-center">
|
||||
<Spinner animation="border" variant="primary" className="spinner-dimentions" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{((hasMorePages && isLoading) || !isLoading) && (
|
||||
<>
|
||||
{endorsedComments.length > 0 && (
|
||||
<>
|
||||
{handleDefinition(messages.endorsedResponseCount, endorsedComments.length)}
|
||||
{endorsed === EndorsementStatus.DISCUSSION
|
||||
? handleComments(endorsedComments, true)
|
||||
: handleComments(endorsedComments, false)}
|
||||
</>
|
||||
)}
|
||||
{endorsed !== EndorsementStatus.ENDORSED && (
|
||||
<>
|
||||
{handleDefinition(messages.responseCount, unEndorsedComments.length)}
|
||||
{unEndorsedComments.length === 0 && <br />}
|
||||
{handleComments(unEndorsedComments, false)}
|
||||
{(userCanAddThreadInBlackoutDate && !!unEndorsedComments.length && !isClosed) && (
|
||||
<div className="mx-4">
|
||||
{!addingResponse && (
|
||||
<Button
|
||||
variant="plain"
|
||||
block="true"
|
||||
className="card mb-4 px-0 border-0 py-10px mt-2 font-style font-weight-500
|
||||
line-height-24 font-size-14 text-primary-500"
|
||||
onClick={() => setAddingResponse(true)}
|
||||
data-testid="add-response"
|
||||
>
|
||||
{intl.formatMessage(messages.addResponse)}
|
||||
</Button>
|
||||
)}
|
||||
<ResponseEditor
|
||||
postId={postId}
|
||||
handleCloseEditor={() => setAddingResponse(false)}
|
||||
addWrappingDiv
|
||||
addingResponse={addingResponse}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
CommentsView.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,
|
||||
]).isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(CommentsView);
|
||||
@@ -1,4 +1,7 @@
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import React, {
|
||||
useCallback,
|
||||
useContext, useEffect, useMemo, useState,
|
||||
} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import classNames from 'classnames';
|
||||
@@ -7,16 +10,18 @@ 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, Confirmation, EndorsedAlertBanner } from '../../common';
|
||||
import { DiscussionContext } from '../../common/context';
|
||||
import { useUserCanAddThreadInBlackoutDate } from '../../data/hooks';
|
||||
import { fetchThread } from '../../posts/data/thunks';
|
||||
import CommentIcons from '../comment-icons/CommentIcons';
|
||||
import { selectCommentCurrentPage, selectCommentHasMorePages, selectCommentResponses } from '../data/selectors';
|
||||
import { editComment, fetchCommentResponses, removeComment } from '../data/thunks';
|
||||
import messages from '../messages';
|
||||
import HTMLLoader from '../../../../components/HTMLLoader';
|
||||
import { ContentActions, EndorsementStatus } from '../../../../data/constants';
|
||||
import { AlertBanner, Confirmation, EndorsedAlertBanner } from '../../../common';
|
||||
import { DiscussionContext } from '../../../common/context';
|
||||
import HoverCard from '../../../common/HoverCard';
|
||||
import { useUserCanAddThreadInBlackoutDate } from '../../../data/hooks';
|
||||
import { fetchThread } from '../../../posts/data/thunks';
|
||||
import LikeButton from '../../../posts/post/LikeButton';
|
||||
import { useActions } from '../../../utils';
|
||||
import { selectCommentCurrentPage, selectCommentHasMorePages, selectCommentResponses } from '../../data/selectors';
|
||||
import { editComment, fetchCommentResponses, removeComment } from '../../data/thunks';
|
||||
import messages from '../../messages';
|
||||
import CommentEditor from './CommentEditor';
|
||||
import CommentHeader from './CommentHeader';
|
||||
import { commentShape } from './proptypes';
|
||||
@@ -28,6 +33,7 @@ function Comment({
|
||||
showFullThread = true,
|
||||
isClosedPost,
|
||||
intl,
|
||||
marginBottom,
|
||||
}) {
|
||||
const dispatch = useDispatch();
|
||||
const hasChildren = comment.childCount > 0;
|
||||
@@ -40,9 +46,8 @@ function Comment({
|
||||
const hasMorePages = useSelector(selectCommentHasMorePages(comment.id));
|
||||
const currentPage = useSelector(selectCommentCurrentPage(comment.id));
|
||||
const userCanAddThreadInBlackoutDate = useUserCanAddThreadInBlackoutDate();
|
||||
const {
|
||||
courseId,
|
||||
} = useContext(DiscussionContext);
|
||||
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) {
|
||||
@@ -50,13 +55,19 @@ function Comment({
|
||||
}
|
||||
}, [comment.id]);
|
||||
|
||||
const handleAbusedFlag = () => {
|
||||
const actions = useActions({
|
||||
...comment,
|
||||
postType,
|
||||
});
|
||||
const endorseIcons = actions.find(({ action }) => action === EndorsementStatus.ENDORSED);
|
||||
|
||||
const handleAbusedFlag = useCallback(() => {
|
||||
if (comment.abuseFlagged) {
|
||||
dispatch(editComment(comment.id, { flagged: !comment.abuseFlagged }));
|
||||
} else {
|
||||
showReportConfirmation();
|
||||
}
|
||||
};
|
||||
}, [comment.abuseFlagged, comment.id, dispatch, showReportConfirmation]);
|
||||
|
||||
const handleDeleteConfirmation = () => {
|
||||
dispatch(removeComment(comment.id));
|
||||
@@ -68,7 +79,7 @@ function Comment({
|
||||
hideReportConfirmation();
|
||||
};
|
||||
|
||||
const actionHandlers = {
|
||||
const actionHandlers = useMemo(() => ({
|
||||
[ContentActions.EDIT_CONTENT]: () => setEditing(true),
|
||||
[ContentActions.ENDORSE]: async () => {
|
||||
await dispatch(editComment(comment.id, { endorsed: !comment.endorsed }, ContentActions.ENDORSE));
|
||||
@@ -76,15 +87,21 @@ function Comment({
|
||||
},
|
||||
[ContentActions.DELETE]: showDeleteConfirmation,
|
||||
[ContentActions.REPORT]: () => handleAbusedFlag(),
|
||||
};
|
||||
}), [showDeleteConfirmation, dispatch, comment.id, comment.endorsed, comment.threadId, courseId, handleAbusedFlag]);
|
||||
|
||||
const handleLoadMoreComments = () => (
|
||||
dispatch(fetchCommentResponses(comment.id, { page: currentPage + 1 }))
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={classNames({ 'py-2 my-3': showFullThread })}>
|
||||
<div className="d-flex flex-column card" data-testid={`comment-${comment.id}`} role="listitem">
|
||||
<div className={classNames({ 'mb-3': (showFullThread && !marginBottom) })}>
|
||||
{/* eslint-disable jsx-a11y/no-noninteractive-tabindex */}
|
||||
<div
|
||||
tabIndex="0"
|
||||
className="d-flex flex-column card on-focus"
|
||||
data-testid={`comment-${comment.id}`}
|
||||
role="listitem"
|
||||
>
|
||||
<Confirmation
|
||||
isOpen={isDeleting}
|
||||
title={intl.formatMessage(messages.deleteResponseTitle)}
|
||||
@@ -105,73 +122,86 @@ function Comment({
|
||||
/>
|
||||
)}
|
||||
<EndorsedAlertBanner postType={postType} content={comment} />
|
||||
<div className="d-flex flex-column p-4.5">
|
||||
<div className="d-flex flex-column post-card-comment px-4 pt-3.5 pb-10px" tabIndex="0">
|
||||
<HoverCard
|
||||
commentOrPost={comment}
|
||||
actionHandlers={actionHandlers}
|
||||
handleResponseCommentButton={() => setReplying(true)}
|
||||
onLike={() => dispatch(editComment(comment.id, { voted: !comment.voted }))}
|
||||
addResponseCommentButtonMessage={intl.formatMessage(messages.addComment)}
|
||||
isClosedPost={isClosedPost}
|
||||
endorseIcons={endorseIcons}
|
||||
/>
|
||||
<AlertBanner content={comment} />
|
||||
<CommentHeader comment={comment} actionHandlers={actionHandlers} postType={postType} />
|
||||
<CommentHeader comment={comment} />
|
||||
{isEditing
|
||||
? (
|
||||
<CommentEditor comment={comment} onCloseEditor={() => setEditing(false)} formClasses="pt-3" />
|
||||
)
|
||||
: <HTMLLoader cssClassName="comment-body text-break 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}
|
||||
: (
|
||||
<HTMLLoader
|
||||
cssClassName="comment-body html-loader text-break mt-14px font-style text-primary-500"
|
||||
componentId="comment"
|
||||
htmlNode={comment.renderedBody}
|
||||
testId={comment.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{comment.voted && (
|
||||
<div className="ml-n1.5 mt-10px">
|
||||
<LikeButton
|
||||
count={comment.voteCount}
|
||||
onClick={() => dispatch(editComment(comment.id, { voted: !comment.voted }))}
|
||||
voted={comment.voted}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{inlineReplies.length > 0 && (
|
||||
<div className="d-flex flex-column mt-0.5" role="list">
|
||||
{/* Pass along intl since component used here is the one before it's injected with `injectIntl` */}
|
||||
{inlineReplies.map(inlineReply => (
|
||||
<Reply
|
||||
reply={inlineReply}
|
||||
postType={postType}
|
||||
key={inlineReply.id}
|
||||
intl={intl}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{hasMorePages && (
|
||||
<Button
|
||||
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="font-size-14 line-height-24 font-style pt-10px border-0 font-weight-500 pb-0"
|
||||
data-testid="load-more-comments-responses"
|
||||
style={{
|
||||
lineHeight: '20px',
|
||||
}}
|
||||
>
|
||||
{intl.formatMessage(messages.loadMoreComments)}
|
||||
</Button>
|
||||
)}
|
||||
{!isNested && showFullThread && (
|
||||
isReplying ? (
|
||||
<CommentEditor
|
||||
comment={{
|
||||
threadId: comment.threadId,
|
||||
parentId: comment.id,
|
||||
}}
|
||||
edit={false}
|
||||
onCloseEditor={() => setReplying(false)}
|
||||
/>
|
||||
<div className="mt-2.5">
|
||||
<CommentEditor
|
||||
comment={{ threadId: comment.threadId, parentId: comment.id }}
|
||||
edit={false}
|
||||
onCloseEditor={() => setReplying(false)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{!isClosedPost && userCanAddThreadInBlackoutDate
|
||||
{!isClosedPost && userCanAddThreadInBlackoutDate && (inlineReplies.length >= 5)
|
||||
&& (
|
||||
<Button
|
||||
className="d-flex flex-grow mt-3 py-2 font-size-14"
|
||||
variant="outline-primary"
|
||||
style={{
|
||||
lineHeight: '20px',
|
||||
}}
|
||||
className="d-flex flex-grow mt-2 font-size-14 font-style font-weight-500 text-primary-500"
|
||||
variant="plain"
|
||||
style={{ height: '36px' }}
|
||||
onClick={() => setReplying(true)}
|
||||
>
|
||||
{intl.formatMessage(messages.addComment)}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
@@ -186,11 +216,13 @@ Comment.propTypes = {
|
||||
showFullThread: PropTypes.bool,
|
||||
isClosedPost: PropTypes.bool,
|
||||
intl: intlShape.isRequired,
|
||||
marginBottom: PropTypes.bool,
|
||||
};
|
||||
|
||||
Comment.defaultProps = {
|
||||
showFullThread: true,
|
||||
isClosedPost: false,
|
||||
marginBottom: true,
|
||||
};
|
||||
|
||||
export default injectIntl(Comment);
|
||||
@@ -9,19 +9,20 @@ import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
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 { TinyMCEEditor } from '../../../../components';
|
||||
import FormikErrorFeedback from '../../../../components/FormikErrorFeedback';
|
||||
import PostPreviewPane from '../../../../components/PostPreviewPane';
|
||||
import { useDispatchWithState } from '../../../../data/hooks';
|
||||
import { DiscussionContext } from '../../../common/context';
|
||||
import {
|
||||
selectModerationSettings,
|
||||
selectUserHasModerationPrivileges,
|
||||
selectUserIsGroupTa,
|
||||
selectUserIsStaff,
|
||||
} from '../../data/selectors';
|
||||
import { formikCompatibleHandler, isFormikFieldInvalid } from '../../utils';
|
||||
import { addComment, editComment } from '../data/thunks';
|
||||
import messages from '../messages';
|
||||
} from '../../../data/selectors';
|
||||
import { formikCompatibleHandler, isFormikFieldInvalid } from '../../../utils';
|
||||
import { addComment, editComment } from '../../data/thunks';
|
||||
import messages from '../../messages';
|
||||
|
||||
function CommentEditor({
|
||||
intl,
|
||||
@@ -32,6 +33,7 @@ function CommentEditor({
|
||||
}) {
|
||||
const editorRef = useRef(null);
|
||||
const { authenticatedUser } = useContext(AppContext);
|
||||
const { enableInContextSidebar } = useContext(DiscussionContext);
|
||||
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
|
||||
const userIsGroupTa = useSelector(selectUserIsGroupTa);
|
||||
const userIsStaff = useSelector(selectUserIsStaff);
|
||||
@@ -71,7 +73,7 @@ function CommentEditor({
|
||||
};
|
||||
await dispatch(editComment(comment.id, payload));
|
||||
} else {
|
||||
await dispatch(addComment(values.comment, comment.threadId, comment.parentId));
|
||||
await dispatch(addComment(values.comment, comment.threadId, comment.parentId, enableInContextSidebar));
|
||||
}
|
||||
/* istanbul ignore if: TinyMCE is mocked so this cannot be easily tested */
|
||||
if (editorRef.current) {
|
||||
@@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { injectIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Avatar } from '@edx/paragon';
|
||||
|
||||
import { AvatarOutlineAndLabelColors } from '../../../../data/constants';
|
||||
import { AuthorLabel } from '../../../common';
|
||||
import { useAlertBannerVisible } from '../../../data/hooks';
|
||||
import { selectAuthorAvatars } from '../../../posts/data/selectors';
|
||||
import { commentShape } from './proptypes';
|
||||
|
||||
function CommentHeader({
|
||||
comment,
|
||||
}) {
|
||||
const authorAvatars = useSelector(selectAuthorAvatars(comment.author));
|
||||
const colorClass = AvatarOutlineAndLabelColors[comment.authorLabel];
|
||||
const hasAnyAlert = useAlertBannerVisible(comment);
|
||||
|
||||
return (
|
||||
<div className={classNames('d-flex flex-row justify-content-between', {
|
||||
'mt-2': hasAnyAlert,
|
||||
})}
|
||||
>
|
||||
<div className="align-items-center d-flex flex-row">
|
||||
<Avatar
|
||||
className={`border-0 ml-0.5 mr-2.5 ${colorClass ? `outline-${colorClass}` : 'outline-anonymous'}`}
|
||||
alt={comment.author}
|
||||
src={authorAvatars?.imageUrlSmall}
|
||||
style={{
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
}}
|
||||
/>
|
||||
<AuthorLabel
|
||||
author={comment.author}
|
||||
authorLabel={comment.authorLabel}
|
||||
labelColor={colorClass && `text-${colorClass}`}
|
||||
linkToProfile
|
||||
postCreatedAt={comment.createdAt}
|
||||
postOrComment
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
CommentHeader.propTypes = {
|
||||
comment: commentShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(CommentHeader);
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
@@ -7,16 +7,16 @@ 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 HTMLLoader from '../../../../components/HTMLLoader';
|
||||
import { AvatarOutlineAndLabelColors, ContentActions } from '../../../../data/constants';
|
||||
import {
|
||||
ActionsDropdown, AlertBanner, AuthorLabel, Confirmation,
|
||||
} 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';
|
||||
} 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';
|
||||
import CommentEditor from './CommentEditor';
|
||||
import { commentShape } from './proptypes';
|
||||
|
||||
@@ -31,13 +31,13 @@ function Reply({
|
||||
const [isDeleting, showDeleteConfirmation, hideDeleteConfirmation] = useToggle(false);
|
||||
const [isReporting, showReportConfirmation, hideReportConfirmation] = useToggle(false);
|
||||
|
||||
const handleAbusedFlag = () => {
|
||||
const handleAbusedFlag = useCallback(() => {
|
||||
if (reply.abuseFlagged) {
|
||||
dispatch(editComment(reply.id, { flagged: !reply.abuseFlagged }));
|
||||
} else {
|
||||
showReportConfirmation();
|
||||
}
|
||||
};
|
||||
}, [dispatch, reply.abuseFlagged, reply.id, showReportConfirmation]);
|
||||
|
||||
const handleDeleteConfirmation = () => {
|
||||
dispatch(removeComment(reply.id));
|
||||
@@ -49,7 +49,7 @@ function Reply({
|
||||
hideReportConfirmation();
|
||||
};
|
||||
|
||||
const actionHandlers = {
|
||||
const actionHandlers = useMemo(() => ({
|
||||
[ContentActions.EDIT_CONTENT]: () => setEditing(true),
|
||||
[ContentActions.ENDORSE]: () => dispatch(editComment(
|
||||
reply.id,
|
||||
@@ -58,13 +58,14 @@ function Reply({
|
||||
)),
|
||||
[ContentActions.DELETE]: showDeleteConfirmation,
|
||||
[ContentActions.REPORT]: () => handleAbusedFlag(),
|
||||
};
|
||||
}), [dispatch, handleAbusedFlag, reply.endorsed, reply.id, showDeleteConfirmation]);
|
||||
|
||||
const authorAvatars = useSelector(selectAuthorAvatars(reply.author));
|
||||
const colorClass = AvatarOutlineAndLabelColors[reply.authorLabel];
|
||||
const hasAnyAlert = useAlertBannerVisible(reply);
|
||||
|
||||
return (
|
||||
<div className="d-flex flex-column mt-4.5" data-testid={`reply-${reply.id}`} role="listitem">
|
||||
<div className="d-flex flex-column mt-2.5 " data-testid={`reply-${reply.id}`} role="listitem">
|
||||
<Confirmation
|
||||
isOpen={isDeleting}
|
||||
title={intl.formatMessage(messages.deleteCommentTitle)}
|
||||
@@ -108,11 +109,18 @@ function Reply({
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="bg-light-300 px-4 pb-2 pt-2.5 flex-fill"
|
||||
className="bg-light-300 pl-4 pt-2.5 pr-2.5 pb-10px 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="d-flex flex-row justify-content-between" style={{ height: '24px' }}>
|
||||
<AuthorLabel
|
||||
author={reply.author}
|
||||
authorLabel={reply.authorLabel}
|
||||
labelColor={colorClass && `text-${colorClass}`}
|
||||
linkToProfile
|
||||
postCreatedAt={reply.createdAt}
|
||||
postOrComment
|
||||
/>
|
||||
<div className="ml-auto d-flex">
|
||||
<ActionsDropdown
|
||||
commentOrPost={{
|
||||
@@ -120,17 +128,22 @@ function Reply({
|
||||
postType,
|
||||
}}
|
||||
actionHandlers={actionHandlers}
|
||||
iconSize="inline"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{isEditing
|
||||
? <CommentEditor comment={reply} onCloseEditor={() => setEditing(false)} />
|
||||
: <HTMLLoader componentId="reply" htmlNode={reply.renderedBody} cssClassName="text-break text-primary-500" />}
|
||||
: (
|
||||
<HTMLLoader
|
||||
componentId="reply"
|
||||
htmlNode={reply.renderedBody}
|
||||
cssClassName="html-loader text-break font-style text-primary-500"
|
||||
testId={reply.id}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-gray-500 align-self-end mt-2" title={reply.createdAt}>
|
||||
{timeago.format(reply.createdAt, 'time-locale')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { injectIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import CommentEditor from './CommentEditor';
|
||||
|
||||
function ResponseEditor({
|
||||
postId,
|
||||
addWrappingDiv,
|
||||
handleCloseEditor,
|
||||
addingResponse,
|
||||
}) {
|
||||
useEffect(() => {
|
||||
handleCloseEditor();
|
||||
}, [postId]);
|
||||
|
||||
return addingResponse
|
||||
&& (
|
||||
<div className={classNames({ 'bg-white p-4 mb-4 rounded mt-2': addWrappingDiv })}>
|
||||
<CommentEditor
|
||||
comment={{ threadId: postId }}
|
||||
edit={false}
|
||||
onCloseEditor={handleCloseEditor}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ResponseEditor.propTypes = {
|
||||
postId: PropTypes.string.isRequired,
|
||||
addWrappingDiv: PropTypes.bool,
|
||||
handleCloseEditor: PropTypes.func.isRequired,
|
||||
addingResponse: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
ResponseEditor.defaultProps = {
|
||||
addWrappingDiv: false,
|
||||
};
|
||||
|
||||
export default injectIntl(ResponseEditor);
|
||||
@@ -16,6 +16,8 @@ export const getCommentsApiUrl = () => `${getConfig().LMS_BASE_URL}/api/discussi
|
||||
* @param {EndorsementStatus} endorsed
|
||||
* @param {number=} page
|
||||
* @param {number=} pageSize
|
||||
* @param reverseOrder
|
||||
* @param enableInContextSidebar
|
||||
* @returns {Promise<{}>}
|
||||
*/
|
||||
export async function getThreadComments(
|
||||
@@ -23,6 +25,8 @@ export async function getThreadComments(
|
||||
endorsed,
|
||||
page,
|
||||
pageSize,
|
||||
reverseOrder,
|
||||
enableInContextSidebar = false,
|
||||
} = {},
|
||||
) {
|
||||
const params = snakeCaseObject({
|
||||
@@ -30,7 +34,9 @@ export async function getThreadComments(
|
||||
endorsed: EndorsementValue[endorsed],
|
||||
page,
|
||||
pageSize,
|
||||
reverseOrder,
|
||||
requestedFields: 'profile_image',
|
||||
enableInContextSidebar,
|
||||
});
|
||||
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
@@ -67,11 +73,14 @@ export async function getCommentResponses(
|
||||
* @param {string} comment Raw comment data to post.
|
||||
* @param {string} threadId Thread ID for thread in which to post comment.
|
||||
* @param {string=} parentId ID for a comments parent.
|
||||
* @param {boolean} enableInContextSidebar
|
||||
* @returns {Promise<{}>}
|
||||
*/
|
||||
export async function postComment(comment, threadId, parentId = null) {
|
||||
export async function postComment(comment, threadId, parentId = null, enableInContextSidebar = false) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getCommentsApiUrl(), snakeCaseObject({ threadId, raw_body: comment, parentId }));
|
||||
.post(getCommentsApiUrl(), snakeCaseObject({
|
||||
threadId, raw_body: comment, parentId, enableInContextSidebar,
|
||||
}));
|
||||
return data;
|
||||
}
|
||||
|
||||
81
src/discussions/post-comments/data/hooks.js
Normal file
81
src/discussions/post-comments/data/hooks.js
Normal file
@@ -0,0 +1,81 @@
|
||||
import { useContext, useEffect } from 'react';
|
||||
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
|
||||
import { EndorsementStatus } from '../../../data/constants';
|
||||
import { useDispatchWithState } from '../../../data/hooks';
|
||||
import { DiscussionContext } from '../../common/context';
|
||||
import { selectThread } from '../../posts/data/selectors';
|
||||
import { markThreadAsRead } from '../../posts/data/thunks';
|
||||
import {
|
||||
selectCommentSortOrder, selectThreadComments, selectThreadCurrentPage, selectThreadHasMorePages,
|
||||
} from './selectors';
|
||||
import { fetchThreadComments } from './thunks';
|
||||
|
||||
function trackLoadMoreEvent(postId, params) {
|
||||
sendTrackEvent(
|
||||
'edx.forum.responses.loadMore',
|
||||
{
|
||||
postId,
|
||||
params,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function usePost(postId) {
|
||||
const dispatch = useDispatch();
|
||||
const thread = useSelector(selectThread(postId));
|
||||
|
||||
useEffect(() => {
|
||||
if (thread && !thread.read) {
|
||||
dispatch(markThreadAsRead(postId));
|
||||
}
|
||||
}, [postId]);
|
||||
|
||||
return thread;
|
||||
}
|
||||
|
||||
export function usePostComments(postId, endorsed = null) {
|
||||
const [isLoading, dispatch] = useDispatchWithState();
|
||||
const comments = useSelector(selectThreadComments(postId, endorsed));
|
||||
const reverseOrder = useSelector(selectCommentSortOrder);
|
||||
const hasMorePages = useSelector(selectThreadHasMorePages(postId, endorsed));
|
||||
const currentPage = useSelector(selectThreadCurrentPage(postId, endorsed));
|
||||
const { enableInContextSidebar } = useContext(DiscussionContext);
|
||||
|
||||
const handleLoadMoreResponses = async () => {
|
||||
const params = {
|
||||
endorsed,
|
||||
page: currentPage + 1,
|
||||
reverseOrder,
|
||||
};
|
||||
await dispatch(fetchThreadComments(postId, params));
|
||||
trackLoadMoreEvent(postId, params);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchThreadComments(postId, {
|
||||
endorsed,
|
||||
page: 1,
|
||||
reverseOrder,
|
||||
enableInContextSidebar,
|
||||
}));
|
||||
}, [postId, reverseOrder]);
|
||||
|
||||
return {
|
||||
comments,
|
||||
hasMorePages,
|
||||
isLoading,
|
||||
handleLoadMoreResponses,
|
||||
};
|
||||
}
|
||||
|
||||
export function useCommentsCount(postId) {
|
||||
const discussions = useSelector(selectThreadComments(postId, EndorsementStatus.DISCUSSION));
|
||||
const endorsedQuestions = useSelector(selectThreadComments(postId, EndorsementStatus.ENDORSED));
|
||||
const unendorsedQuestions = useSelector(selectThreadComments(postId, EndorsementStatus.UNENDORSED));
|
||||
|
||||
return [...discussions, ...endorsedQuestions, ...unendorsedQuestions].length;
|
||||
}
|
||||
@@ -276,8 +276,7 @@ describe('Comments/Responses data layer tests', () => {
|
||||
const commentId = 'comment-1';
|
||||
|
||||
// This will generate 3 comments, so the responses will start at id = 'comment-4'
|
||||
axiosMock.onGet(commentsApiUrl)
|
||||
.reply(200, Factory.build('commentsResult'));
|
||||
axiosMock.onGet(commentsApiUrl).reply(200, Factory.build('commentsResult'));
|
||||
await executeThunk(fetchThreadComments(threadId), store.dispatch, store.getState);
|
||||
|
||||
// Build all comments first, so we can paginate over them and they
|
||||
@@ -301,8 +300,7 @@ describe('Comments/Responses data layer tests', () => {
|
||||
parent_id: commentId,
|
||||
});
|
||||
allResponses.push(comment);
|
||||
axiosMock.onPost(commentsApiUrl)
|
||||
.reply(200, comment);
|
||||
axiosMock.onPost(commentsApiUrl).reply(200, comment);
|
||||
await executeThunk(addComment('Test Comment', threadId, null), store.dispatch, store.getState);
|
||||
|
||||
// Someone else posted a new response now
|
||||
@@ -316,15 +314,14 @@ describe('Comments/Responses data layer tests', () => {
|
||||
});
|
||||
await executeThunk(fetchCommentResponses(commentId, { page: 2 }), store.dispatch, store.getState);
|
||||
|
||||
// sorting is implemented on backend
|
||||
expect(store.getState().comments.commentsInComments[commentId])
|
||||
.toEqual([
|
||||
'comment-4',
|
||||
'comment-5',
|
||||
'comment-6',
|
||||
'comment-7',
|
||||
// our comment was pushed down
|
||||
'comment-8',
|
||||
// the newer comment is placed correctly
|
||||
'comment-7',
|
||||
'comment-9',
|
||||
]);
|
||||
});
|
||||
@@ -356,8 +353,7 @@ describe('Comments/Responses data layer tests', () => {
|
||||
// Post new comment
|
||||
const comment = Factory.build('comment', { thread_id: threadId });
|
||||
allComments.push(comment);
|
||||
axiosMock.onPost(commentsApiUrl)
|
||||
.reply(200, comment);
|
||||
axiosMock.onPost(commentsApiUrl).reply(200, comment);
|
||||
await executeThunk(addComment('Test Comment', threadId, null), store.dispatch, store.getState);
|
||||
|
||||
// Somebody else posted a new response now
|
||||
@@ -371,15 +367,14 @@ describe('Comments/Responses data layer tests', () => {
|
||||
});
|
||||
await executeThunk(fetchThreadComments(threadId, { page: 2, endorsed }), store.dispatch, store.getState);
|
||||
|
||||
// sorting is implemented on backend
|
||||
expect(store.getState().comments.commentsInThreads[threadId][endorsed])
|
||||
.toEqual([
|
||||
'comment-1',
|
||||
'comment-2',
|
||||
'comment-3',
|
||||
'comment-4',
|
||||
// our comment was pushed down
|
||||
'comment-5',
|
||||
// the newer comment is placed correctly
|
||||
'comment-4',
|
||||
'comment-6',
|
||||
]);
|
||||
});
|
||||
@@ -36,4 +36,6 @@ export const selectCommentCurrentPage = commentId => (
|
||||
state => state.comments.responsesPagination[commentId]?.currentPage || null
|
||||
);
|
||||
|
||||
export const commentsStatus = state => state.comments.status;
|
||||
export const selectCommentsStatus = state => state.comments.status;
|
||||
|
||||
export const selectCommentSortOrder = state => state.comments.sortOrder;
|
||||
@@ -22,6 +22,7 @@ const commentsSlice = createSlice({
|
||||
postStatus: RequestStatus.SUCCESSFUL,
|
||||
pagination: {},
|
||||
responsesPagination: {},
|
||||
sortOrder: false,
|
||||
},
|
||||
reducers: {
|
||||
fetchCommentsRequest: (state) => {
|
||||
@@ -56,15 +57,6 @@ const commentsSlice = createSlice({
|
||||
hasMorePages: Boolean(payload.pagination.next),
|
||||
};
|
||||
state.commentsById = { ...state.commentsById, ...payload.commentsById };
|
||||
// We sort the comments by creation time.
|
||||
// This way our new comments are pushed down to the correct
|
||||
// position when more pages of older comments are loaded.
|
||||
state.commentsInThreads[threadId][endorsed].sort(
|
||||
(a, b) => (
|
||||
Date.parse(state.commentsById[a].createdAt)
|
||||
- Date.parse(state.commentsById[b].createdAt)
|
||||
),
|
||||
);
|
||||
},
|
||||
fetchCommentsFailed: (state) => {
|
||||
state.status = RequestStatus.FAILED;
|
||||
@@ -90,12 +82,6 @@ const commentsSlice = createSlice({
|
||||
]),
|
||||
];
|
||||
state.commentsById = { ...state.commentsById, ...payload.commentsById };
|
||||
state.commentsInComments[payload.commentId].sort(
|
||||
(a, b) => (
|
||||
Date.parse(state.commentsById[a].createdAt)
|
||||
- Date.parse(state.commentsById[b].createdAt)
|
||||
),
|
||||
);
|
||||
state.responsesPagination[payload.commentId] = {
|
||||
currentPage: payload.page,
|
||||
totalPages: payload.pagination.numPages,
|
||||
@@ -181,6 +167,9 @@ const commentsSlice = createSlice({
|
||||
}
|
||||
delete state.commentsById[commentId];
|
||||
},
|
||||
setCommentSortOrder: (state, { payload }) => {
|
||||
state.sortOrder = payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -206,6 +195,7 @@ export const {
|
||||
deleteCommentFailed,
|
||||
deleteCommentRequest,
|
||||
deleteCommentSuccess,
|
||||
setCommentSortOrder,
|
||||
} = commentsSlice.actions;
|
||||
|
||||
export const commentsReducer = commentsSlice.reducer;
|
||||
@@ -74,11 +74,21 @@ function normaliseComments(data) {
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchThreadComments(threadId, { page = 1, endorsed = EndorsementStatus.DISCUSSION } = {}) {
|
||||
export function fetchThreadComments(
|
||||
threadId,
|
||||
{
|
||||
page = 1,
|
||||
reverseOrder,
|
||||
endorsed = EndorsementStatus.DISCUSSION,
|
||||
enableInContextSidebar,
|
||||
} = {},
|
||||
) {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
dispatch(fetchCommentsRequest());
|
||||
const data = await getThreadComments(threadId, { page, endorsed });
|
||||
const data = await getThreadComments(threadId, {
|
||||
page, reverseOrder, endorsed, enableInContextSidebar,
|
||||
});
|
||||
dispatch(fetchCommentsSuccess({
|
||||
...normaliseComments(camelCaseObject(data)),
|
||||
endorsed,
|
||||
@@ -137,7 +147,7 @@ export function editComment(commentId, comment, action = null) {
|
||||
};
|
||||
}
|
||||
|
||||
export function addComment(comment, threadId, parentId = null) {
|
||||
export function addComment(comment, threadId, parentId = null, enableInContextSidebar = false) {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
dispatch(postCommentRequest({
|
||||
@@ -145,7 +155,7 @@ export function addComment(comment, threadId, parentId = null) {
|
||||
threadId,
|
||||
parentId,
|
||||
}));
|
||||
const data = await postComment(comment, threadId, parentId);
|
||||
const data = await postComment(comment, threadId, parentId, enableInContextSidebar);
|
||||
dispatch(postCommentSuccess(camelCaseObject(data)));
|
||||
} catch (error) {
|
||||
if (getHttpErrorStatus(error) === 403) {
|
||||
2
src/discussions/post-comments/index.js
Normal file
2
src/discussions/post-comments/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export { default as PostCommentsView } from './PostCommentsView';
|
||||
@@ -1,15 +1,15 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
addComment: {
|
||||
id: 'discussions.comments.comment.addComment',
|
||||
defaultMessage: 'Add comment',
|
||||
description: 'Button to add a comment to a response',
|
||||
},
|
||||
addResponse: {
|
||||
id: 'discussions.comments.comment.addResponse',
|
||||
defaultMessage: 'Add a response',
|
||||
description: 'Button to add a response in a thread of forum posts',
|
||||
},
|
||||
addComment: {
|
||||
id: 'discussions.comments.comment.addComment',
|
||||
defaultMessage: 'Add a comment',
|
||||
description: 'Button to add a comment to a response',
|
||||
description: 'Button to add a response to a response',
|
||||
},
|
||||
abuseFlaggedMessage: {
|
||||
id: 'discussions.comments.comment.abuseFlaggedMessage',
|
||||
@@ -188,6 +188,11 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Edited by',
|
||||
description: 'Text shown to users to indicate who edited a post. Followed by the username of editor.',
|
||||
},
|
||||
fullStop: {
|
||||
id: 'discussions.comment.comments.fullStop',
|
||||
defaultMessage: '•',
|
||||
description: 'Fullstop shown to users to indicate who edited a post. Followed by a reason.',
|
||||
},
|
||||
reason: {
|
||||
id: 'discussions.comment.comments.reason',
|
||||
defaultMessage: 'Reason',
|
||||
@@ -197,11 +202,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',
|
||||
@@ -212,6 +212,15 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Thread not found',
|
||||
description: 'message to show on screen if the request thread is not found in course',
|
||||
},
|
||||
commentSort: {
|
||||
id: 'discussions.comment.sortFilterStatus',
|
||||
defaultMessage: `{sort, select,
|
||||
false {Oldest first}
|
||||
true {Newest first}
|
||||
other {{sort}}
|
||||
}`,
|
||||
description: 'sort message showing current sorting',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -23,7 +23,7 @@ import NoResults from './NoResults';
|
||||
import { PostLink } from './post';
|
||||
|
||||
function PostsList({
|
||||
posts, topics, intl, isTopicTab,
|
||||
posts, topics, intl, isTopicTab, parentIsLoading,
|
||||
}) {
|
||||
const dispatch = useDispatch();
|
||||
const {
|
||||
@@ -50,8 +50,7 @@ function PostsList({
|
||||
topicIds,
|
||||
isFilterChanged,
|
||||
};
|
||||
|
||||
if (showOwnPosts) {
|
||||
if (showOwnPosts && filters.search === '') {
|
||||
dispatch(fetchUserPosts(courseId, params));
|
||||
} else {
|
||||
dispatch(fetchThreads(courseId, params));
|
||||
@@ -86,10 +85,10 @@ function PostsList({
|
||||
|
||||
return (
|
||||
<>
|
||||
{postInstances(pinnedPosts)}
|
||||
{postInstances(unpinnedPosts)}
|
||||
{!parentIsLoading && postInstances(pinnedPosts)}
|
||||
{!parentIsLoading && postInstances(unpinnedPosts)}
|
||||
{posts?.length === 0 && loadingStatus === RequestStatus.SUCCESSFUL && <NoResults />}
|
||||
{loadingStatus === RequestStatus.IN_PROGRESS ? (
|
||||
{loadingStatus === RequestStatus.IN_PROGRESS || parentIsLoading ? (
|
||||
<div className="d-flex justify-content-center p-4 mx-auto my-auto">
|
||||
<Spinner animation="border" variant="primary" size="lg" />
|
||||
</div>
|
||||
@@ -111,6 +110,7 @@ PostsList.propTypes = {
|
||||
})),
|
||||
topics: PropTypes.arrayOf(PropTypes.string),
|
||||
isTopicTab: PropTypes.bool,
|
||||
parentIsLoading: PropTypes.bool,
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
@@ -118,6 +118,7 @@ PostsList.defaultProps = {
|
||||
posts: [],
|
||||
topics: undefined,
|
||||
isTopicTab: false,
|
||||
parentIsLoading: undefined,
|
||||
};
|
||||
|
||||
export default injectIntl(PostsList);
|
||||
|
||||
@@ -13,10 +13,7 @@ import { fetchCourseTopicsV3 } from '../in-context-topics/data/thunks';
|
||||
import { selectTopics } from '../topics/data/selectors';
|
||||
import { fetchCourseTopics } from '../topics/data/thunks';
|
||||
import { handleKeyDown } from '../utils';
|
||||
import {
|
||||
selectAllThreads,
|
||||
selectTopicThreads,
|
||||
} from './data/selectors';
|
||||
import { selectAllThreads, selectTopicThreads } from './data/selectors';
|
||||
import { setSearchQuery } from './data/slices';
|
||||
import PostFilterBar from './post-filter-bar/PostFilterBar';
|
||||
import PostsList from './PostsList';
|
||||
@@ -40,7 +37,7 @@ function CategoryPostsList({ category }) {
|
||||
const groupedCategory = useSelector(selectCurrentCategoryGrouping)(category);
|
||||
// If grouping at subsection is enabled, only apply it when browsing discussions in context in the learning MFE.
|
||||
const topicIds = useSelector(selectTopicsUnderCategory)(enableInContextSidebar ? groupedCategory : category);
|
||||
const posts = useSelector(selectTopicThreads(topicIds));
|
||||
const posts = useSelector(enableInContextSidebar ? selectAllThreads : selectTopicThreads(topicIds));
|
||||
return <PostsList posts={posts} topics={topicIds} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -160,9 +160,9 @@ describe('PostsView', () => {
|
||||
test('displays a list of posts in a topic', async () => {
|
||||
setupStore();
|
||||
await act(async () => {
|
||||
await renderComponent({ topicId: 'some-topic-1' });
|
||||
await renderComponent({ topicId: 'test-topic-1' });
|
||||
});
|
||||
expect(screen.getAllByText(/this is thread-\d+ in topic some-topic-1/i)).toHaveLength(Math.ceil(threadCount / 3));
|
||||
expect(screen.getAllByText(/this is thread-\d+ in topic test-topic-1/i)).toHaveLength(Math.ceil(threadCount / 3));
|
||||
});
|
||||
|
||||
test.each([true, false])(
|
||||
@@ -173,10 +173,10 @@ describe('PostsView', () => {
|
||||
blocks: {
|
||||
'test-usage-key': {
|
||||
type: 'vertical',
|
||||
topics: ['some-topic-2', 'some-topic-0'],
|
||||
topics: ['test-topic-2', 'test-topic-0'],
|
||||
parent: 'test-seq-key',
|
||||
},
|
||||
'test-seq-key': { type: 'sequential', topics: ['some-topic-0', 'some-topic-1', 'some-topic-2'] },
|
||||
'test-seq-key': { type: 'sequential', topics: ['test-topic-0', 'test-topic-1', 'test-topic-2'] },
|
||||
},
|
||||
},
|
||||
config: { groupAtSubsection: grouping, hasModerationPrivileges: true, provider: 'openedx' },
|
||||
@@ -185,13 +185,13 @@ describe('PostsView', () => {
|
||||
await renderComponent({ category: 'test-usage-key', enableInContextSidebar: true, p: true });
|
||||
});
|
||||
const topicThreadCount = Math.ceil(threadCount / 3);
|
||||
expect(screen.queryAllByText(/this is thread-\d+ in topic some-topic-2/i))
|
||||
expect(screen.queryAllByText(/this is thread-\d+ in topic test-topic-2/i))
|
||||
.toHaveLength(topicThreadCount);
|
||||
expect(screen.queryAllByText(/this is thread-\d+ in topic some-topic-0/i))
|
||||
expect(screen.queryAllByText(/this is thread-\d+ in topic test-topic-0/i))
|
||||
.toHaveLength(topicThreadCount);
|
||||
// When grouping is enabled, topic 1 will be shown, but not otherwise.
|
||||
expect(screen.queryAllByText(/this is thread-\d+ in topic some-topic-1/i))
|
||||
.toHaveLength(grouping ? topicThreadCount : 0);
|
||||
expect(screen.queryAllByText(/this is thread-\d+ in topic test-topic-1/i))
|
||||
.toHaveLength(grouping ? topicThreadCount : 2);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ Factory.define('thread')
|
||||
.sequence('rendered_body', (idx) => `Some contents for <b>thread number ${idx}</b>.`)
|
||||
.sequence('type', (idx) => (idx % 2 === 1 ? 'discussion' : 'question'))
|
||||
.sequence('pinned', idx => (idx < 3))
|
||||
.sequence('topic_id', idx => `some-topic-${(idx % 3)}`)
|
||||
.sequence('topic_id', idx => `test-topic-${(idx % 3)}`)
|
||||
.sequence('closed', idx => Boolean(idx % 3 === 2)) // Mark every 3rd post closed
|
||||
.attr('comment_list_url', ['id'], (threadId) => `http://test.site/api/discussion/v1/comments/?thread_id=${threadId}`)
|
||||
.attrs({
|
||||
|
||||
@@ -87,6 +87,7 @@ export async function getThread(threadId, courseId) {
|
||||
* @param {boolean} following Follow the thread after creating
|
||||
* @param {boolean} anonymous Should the thread be anonymous to all users
|
||||
* @param {boolean} anonymousToPeers Should the thread be anonymous to peers
|
||||
* @param {boolean} enableInContextSidebar
|
||||
* @returns {Promise<{}>}
|
||||
*/
|
||||
export async function postThread(
|
||||
@@ -101,6 +102,7 @@ export async function postThread(
|
||||
anonymous,
|
||||
anonymousToPeers,
|
||||
} = {},
|
||||
enableInContextSidebar = false,
|
||||
) {
|
||||
const postData = snakeCaseObject({
|
||||
courseId,
|
||||
@@ -112,8 +114,8 @@ export async function postThread(
|
||||
anonymous,
|
||||
anonymousToPeers,
|
||||
groupId: cohort,
|
||||
enableInContextSidebar,
|
||||
});
|
||||
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getThreadsApiUrl(), postData);
|
||||
return data;
|
||||
|
||||
@@ -102,7 +102,7 @@ describe('Threads/Posts data layer tests', () => {
|
||||
expect(store.getState().threads.threadsById['thread-1'])
|
||||
.toHaveProperty('topicId');
|
||||
expect(store.getState().threads.threadsById['thread-1'].topicId)
|
||||
.toEqual('some-topic-1');
|
||||
.toEqual('test-topic-1');
|
||||
});
|
||||
|
||||
test('successfully handles thread creation', async () => {
|
||||
|
||||
@@ -204,6 +204,7 @@ export function createNewThread({
|
||||
anonymous,
|
||||
anonymousToPeers,
|
||||
cohort,
|
||||
enableInContextSidebar,
|
||||
}) {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
@@ -223,7 +224,7 @@ export function createNewThread({
|
||||
following,
|
||||
anonymous,
|
||||
anonymousToPeers,
|
||||
});
|
||||
}, enableInContextSidebar);
|
||||
dispatch(postThreadSuccess(camelCaseObject(data)));
|
||||
} catch (error) {
|
||||
if (getHttpErrorStatus(error) === 403) {
|
||||
|
||||
@@ -42,17 +42,17 @@ function PostActionsBar({
|
||||
: <Search />
|
||||
)}
|
||||
{enableInContextSidebar && (
|
||||
<h4 className="d-flex flex-grow-1 font-weight-bold my-0 py-0 align-self-center">
|
||||
<h4 className="d-flex flex-grow-1 font-weight-bold font-style my-0 py-10px align-self-center">
|
||||
{intl.formatMessage(messages.title)}
|
||||
</h4>
|
||||
)}
|
||||
{loadingStatus === RequestStatus.SUCCESSFUL && userCanAddThreadInBlackoutDate
|
||||
&& (
|
||||
{loadingStatus === RequestStatus.SUCCESSFUL && userCanAddThreadInBlackoutDate && (
|
||||
<>
|
||||
{!enableInContextSidebar && <div className="border-right border-light-400 mx-3" />}
|
||||
<Button
|
||||
variant={enableInContextSidebar ? 'plain' : 'brand'}
|
||||
className={classNames('my-0', { 'p-0': enableInContextSidebar })}
|
||||
className={classNames('my-0 font-style border-0 line-height-24',
|
||||
{ 'px-3 py-10px border-0': enableInContextSidebar })}
|
||||
onClick={() => dispatch(showPostEditor())}
|
||||
size={enableInContextSidebar ? 'md' : 'sm'}
|
||||
>
|
||||
@@ -62,13 +62,17 @@ function PostActionsBar({
|
||||
)}
|
||||
{enableInContextSidebar && (
|
||||
<>
|
||||
<div className="border-right border-light-300 mr-2 ml-3.5 my-2" />
|
||||
<IconButton
|
||||
src={Close}
|
||||
iconAs={Icon}
|
||||
onClick={handleCloseInContext}
|
||||
alt={intl.formatMessage(messages.close)}
|
||||
/>
|
||||
<div className="border-right border-light-300 mr-3 ml-1.5 my-10px" />
|
||||
<div className="justify-content-center mt-2.5 mx-3px">
|
||||
<IconButton
|
||||
src={Close}
|
||||
iconAs={Icon}
|
||||
onClick={handleCloseInContext}
|
||||
alt={intl.formatMessage(messages.close)}
|
||||
iconClassNames="spinner-dimentions"
|
||||
className="spinner-dimentions"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -54,9 +54,9 @@ function DiscussionPostType({
|
||||
value,
|
||||
type,
|
||||
selected,
|
||||
description,
|
||||
icon,
|
||||
}) {
|
||||
const { enableInContextSidebar } = useContext(DiscussionContext);
|
||||
// Need to use regular label since Form.Label doesn't support overriding htmlFor
|
||||
return (
|
||||
<label htmlFor={`post-type-${value}`} className="d-flex p-0 my-0 mr-3">
|
||||
@@ -66,12 +66,11 @@ function DiscussionPostType({
|
||||
'border-primary': selected,
|
||||
'border-light-400': !selected,
|
||||
})}
|
||||
style={{ cursor: 'pointer', width: '14.25rem' }}
|
||||
style={{ cursor: 'pointer', width: `${enableInContextSidebar ? '10.021rem' : '14.25rem'}` }}
|
||||
>
|
||||
<Card.Section className="py-3 px-10px d-flex flex-column align-items-center">
|
||||
<Card.Section className="px-4 py-3 d-flex flex-column align-items-center">
|
||||
<span className="text-primary-300 mb-0.5">{icon}</span>
|
||||
<span className="text-gray-700 mb-0.5">{type}</span>
|
||||
<span className="x-small text-gray-500">{description}</span>
|
||||
<span className="text-gray-700">{type}</span>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
</label>
|
||||
@@ -82,7 +81,6 @@ DiscussionPostType.propTypes = {
|
||||
value: PropTypes.string.isRequired,
|
||||
type: PropTypes.string.isRequired,
|
||||
selected: PropTypes.bool.isRequired,
|
||||
description: PropTypes.string.isRequired,
|
||||
icon: PropTypes.element.isRequired,
|
||||
};
|
||||
|
||||
@@ -108,7 +106,7 @@ function PostEditor({
|
||||
const nonCoursewareIds = useSelector(enableInContext ? inContextCoursewareIds : selectNonCoursewareIds);
|
||||
const coursewareTopics = useSelector(enableInContext ? inContextCourseware : selectCoursewareTopics);
|
||||
const cohorts = useSelector(selectCourseCohorts);
|
||||
const post = useSelector(selectThread(postId));
|
||||
const post = useSelector(editExisting ? selectThread(postId) : () => ({}));
|
||||
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
|
||||
const userIsGroupTa = useSelector(selectUserIsGroupTa);
|
||||
const settings = useSelector(selectDivisionSettings);
|
||||
@@ -189,6 +187,7 @@ function PostEditor({
|
||||
anonymous: allowAnonymous ? values.anonymous : undefined,
|
||||
anonymousToPeers: allowAnonymousToPeers ? values.anonymousToPeers : undefined,
|
||||
cohort,
|
||||
enableInContextSidebar,
|
||||
}));
|
||||
}
|
||||
/* istanbul ignore if: TinyMCE is mocked so this cannot be easily tested */
|
||||
@@ -270,7 +269,7 @@ function PostEditor({
|
||||
resetForm,
|
||||
}) => (
|
||||
<Form className="m-4 card p-4 post-form" onSubmit={handleSubmit}>
|
||||
<h4 className="mb-4" style={{ lineHeight: '16px' }}>
|
||||
<h4 className="mb-4 font-style font-size-16" style={{ lineHeight: '16px' }}>
|
||||
{editExisting
|
||||
? intl.formatMessage(messages.editPostHeading)
|
||||
: intl.formatMessage(messages.addPostHeading)}
|
||||
@@ -444,7 +443,7 @@ function PostEditor({
|
||||
|
||||
<PostPreviewPane htmlNode={values.comment} isPost editExisting={editExisting} />
|
||||
|
||||
<div className="d-flex flex-row mt-n4 w-75 text-primary">
|
||||
<div className="d-flex flex-row mt-n4 w-75 text-primary font-style">
|
||||
{!editExisting && (
|
||||
<>
|
||||
<Form.Group>
|
||||
@@ -461,18 +460,18 @@ function PostEditor({
|
||||
</Form.Checkbox>
|
||||
</Form.Group>
|
||||
{allowAnonymousToPeers && (
|
||||
<Form.Group>
|
||||
<Form.Checkbox
|
||||
name="anonymousToPeers"
|
||||
checked={values.anonymousToPeers}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
>
|
||||
<span className="font-size-14">
|
||||
{intl.formatMessage(messages.anonymousToPeersPost)}
|
||||
</span>
|
||||
</Form.Checkbox>
|
||||
</Form.Group>
|
||||
<Form.Group>
|
||||
<Form.Checkbox
|
||||
name="anonymousToPeers"
|
||||
checked={values.anonymousToPeers}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
>
|
||||
<span className="font-size-14">
|
||||
{intl.formatMessage(messages.anonymousToPeersPost)}
|
||||
</span>
|
||||
</Form.Checkbox>
|
||||
</Form.Group>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@@ -498,7 +497,7 @@ function PostEditor({
|
||||
</div>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
}
|
||||
</Formik>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -141,6 +141,16 @@ describe('PostEditor', () => {
|
||||
}
|
||||
},
|
||||
);
|
||||
test('selectThread is not called while creating a new post', async () => {
|
||||
const mockSelectThread = jest.fn();
|
||||
jest.mock('../data/selectors', () => ({
|
||||
selectThread: mockSelectThread,
|
||||
}));
|
||||
await renderComponent();
|
||||
expect(mockSelectThread)
|
||||
.not
|
||||
.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cohorting', () => {
|
||||
|
||||
@@ -113,7 +113,7 @@ const messages = defineMessages({
|
||||
},
|
||||
showPreviewButton: {
|
||||
id: 'discussions.editor.posts.showPreview.button',
|
||||
defaultMessage: 'Show Preview',
|
||||
defaultMessage: 'Show preview',
|
||||
description: 'show preview button text to allow user to see their post content.',
|
||||
},
|
||||
actionsAlt: {
|
||||
|
||||
@@ -71,7 +71,6 @@ function PostFilterBar({
|
||||
const currentFilters = useSelector(selectThreadFilters());
|
||||
const { status } = useSelector(state => state.cohorts);
|
||||
const cohorts = useSelector(selectCourseCohorts);
|
||||
|
||||
const [isOpen, setOpen] = useState(false);
|
||||
|
||||
const selectedCohort = useMemo(() => cohorts.find(cohort => (
|
||||
@@ -138,7 +137,7 @@ function PostFilterBar({
|
||||
className="filter-bar collapsible-card-lg border-0"
|
||||
>
|
||||
<Collapsible.Trigger className="collapsible-trigger border-0">
|
||||
<span className="text-primary-700 pr-4">
|
||||
<span className="text-primary-500 pr-4 font-style">
|
||||
{intl.formatMessage(messages.sortFilterStatus, {
|
||||
own: false,
|
||||
type: currentFilters.postType,
|
||||
|
||||
@@ -2,7 +2,9 @@ import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Icon, IconButtonWithTooltip } from '@edx/paragon';
|
||||
import {
|
||||
Icon, IconButton, OverlayTrigger, Tooltip,
|
||||
} from '@edx/paragon';
|
||||
|
||||
import { ThumbUpFilled, ThumbUpOutline } from '../../../components/icons';
|
||||
import messages from './messages';
|
||||
@@ -12,7 +14,6 @@ function LikeButton({
|
||||
intl,
|
||||
onClick,
|
||||
voted,
|
||||
preview,
|
||||
}) {
|
||||
const handleClick = (e) => {
|
||||
e.preventDefault();
|
||||
@@ -23,20 +24,27 @@ function LikeButton({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="d-flex align-items-center mr-4 text-primary-500">
|
||||
<IconButtonWithTooltip
|
||||
id={`like-${count}-tooltip`}
|
||||
tooltipPlacement="top"
|
||||
tooltipContent={intl.formatMessage(voted ? messages.removeLike : messages.like)}
|
||||
src={voted ? ThumbUpFilled : ThumbUpOutline}
|
||||
iconAs={Icon}
|
||||
alt="Like"
|
||||
onClick={handleClick}
|
||||
size={preview ? 'inline' : 'sm'}
|
||||
className={`mr-0.5 ${preview && 'p-3'}`}
|
||||
iconClassNames={preview && 'icon-size'}
|
||||
/>
|
||||
{(count && count > 0) ? count : null}
|
||||
<div className="d-flex align-items-center mr-36px text-primary-500">
|
||||
<OverlayTrigger
|
||||
overlay={(
|
||||
<Tooltip id={`liked-${count}-tooltip`}>
|
||||
{intl.formatMessage(voted ? messages.removeLike : messages.like)}
|
||||
</Tooltip>
|
||||
)}
|
||||
>
|
||||
<IconButton
|
||||
src={voted ? ThumbUpFilled : ThumbUpOutline}
|
||||
onClick={handleClick}
|
||||
className="post-footer-icon-dimentions"
|
||||
alt="Like"
|
||||
iconAs={Icon}
|
||||
iconClassNames="like-icon-dimentions"
|
||||
/>
|
||||
</OverlayTrigger>
|
||||
<div className="font-style">
|
||||
{(count && count > 0) ? count : null}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -46,13 +54,11 @@ LikeButton.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
onClick: PropTypes.func,
|
||||
voted: PropTypes.bool,
|
||||
preview: PropTypes.bool,
|
||||
};
|
||||
|
||||
LikeButton.defaultProps = {
|
||||
voted: false,
|
||||
onClick: undefined,
|
||||
preview: false,
|
||||
};
|
||||
|
||||
export default injectIntl(LikeButton);
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React, { useContext } from 'react';
|
||||
import React, { useCallback, useContext, useMemo } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Hyperlink, useToggle } from '@edx/paragon';
|
||||
|
||||
@@ -13,6 +14,7 @@ import { ContentActions } from '../../../data/constants';
|
||||
import { selectorForUnitSubsection, selectTopicContext } from '../../../data/selectors';
|
||||
import { AlertBanner, Confirmation } from '../../common';
|
||||
import { DiscussionContext } from '../../common/context';
|
||||
import HoverCard from '../../common/HoverCard';
|
||||
import { selectModerationSettings } from '../../data/selectors';
|
||||
import { selectTopic } from '../../topics/data/selectors';
|
||||
import { removeThread, updateExistingThread } from '../data/thunks';
|
||||
@@ -26,12 +28,13 @@ function Post({
|
||||
post,
|
||||
preview,
|
||||
intl,
|
||||
handleAddResponseButton,
|
||||
}) {
|
||||
const location = useLocation();
|
||||
const history = useHistory();
|
||||
const dispatch = useDispatch();
|
||||
const { enableInContextSidebar } = useContext(DiscussionContext);
|
||||
const { courseId } = useSelector((state) => state.courseTabs);
|
||||
const courseId = useSelector((state) => state.config.id);
|
||||
const topic = useSelector(selectTopic(post.topicId));
|
||||
const getTopicSubsection = useSelector(selectorForUnitSubsection);
|
||||
const topicContext = useSelector(selectTopicContext(post.topicId));
|
||||
@@ -40,13 +43,13 @@ function Post({
|
||||
const [isReporting, showReportConfirmation, hideReportConfirmation] = useToggle(false);
|
||||
const [isClosing, showClosePostModal, hideClosePostModal] = useToggle(false);
|
||||
|
||||
const handleAbusedFlag = () => {
|
||||
const handleAbusedFlag = useCallback(() => {
|
||||
if (post.abuseFlagged) {
|
||||
dispatch(updateExistingThread(post.id, { flagged: !post.abuseFlagged }));
|
||||
} else {
|
||||
showReportConfirmation();
|
||||
}
|
||||
};
|
||||
}, [dispatch, post.abuseFlagged, post.id, showReportConfirmation]);
|
||||
|
||||
const handleDeleteConfirmation = async () => {
|
||||
await dispatch(removeThread(post.id));
|
||||
@@ -62,7 +65,7 @@ function Post({
|
||||
hideReportConfirmation();
|
||||
};
|
||||
|
||||
const actionHandlers = {
|
||||
const actionHandlers = useMemo(() => ({
|
||||
[ContentActions.EDIT_CONTENT]: () => history.push({
|
||||
...location,
|
||||
pathname: `${location.pathname}/edit`,
|
||||
@@ -80,14 +83,35 @@ function Post({
|
||||
[ContentActions.COPY_LINK]: () => { navigator.clipboard.writeText(`${window.location.origin}/${courseId}/posts/${post.id}`); },
|
||||
[ContentActions.PIN]: () => dispatch(updateExistingThread(post.id, { pinned: !post.pinned })),
|
||||
[ContentActions.REPORT]: () => handleAbusedFlag(),
|
||||
};
|
||||
}), [
|
||||
showDeleteConfirmation,
|
||||
history,
|
||||
location,
|
||||
post.closed,
|
||||
post.id,
|
||||
post.pinned,
|
||||
reasonCodesEnabled,
|
||||
dispatch,
|
||||
showClosePostModal,
|
||||
courseId,
|
||||
handleAbusedFlag,
|
||||
]);
|
||||
|
||||
const getTopicCategoryName = topicData => (
|
||||
topicData.usageKey ? getTopicSubsection(topicData.usageKey)?.displayName : topicData.categoryId
|
||||
);
|
||||
|
||||
const getTopicInfo = topicData => (
|
||||
getTopicCategoryName(topicData) ? `${getTopicCategoryName(topicData)} / ${topicData.name}` : `${topicData.name}`
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="d-flex flex-column w-100 mw-100" data-testid={`post-${post.id}`}>
|
||||
<div
|
||||
className="d-flex flex-column w-100 mw-100 post-card-comment"
|
||||
data-testid={`post-${post.id}`}
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
|
||||
tabIndex="0"
|
||||
>
|
||||
<Confirmation
|
||||
isOpen={isDeleting}
|
||||
title={intl.formatMessage(messages.deletePostTitle)}
|
||||
@@ -107,21 +131,32 @@ function Post({
|
||||
confirmButtonVariant="danger"
|
||||
/>
|
||||
)}
|
||||
<HoverCard
|
||||
commentOrPost={post}
|
||||
actionHandlers={actionHandlers}
|
||||
handleResponseCommentButton={handleAddResponseButton}
|
||||
addResponseCommentButtonMessage={intl.formatMessage(messages.addResponse)}
|
||||
onLike={() => dispatch(updateExistingThread(post.id, { voted: !post.voted }))}
|
||||
onFollow={() => dispatch(updateExistingThread(post.id, { following: !post.following }))}
|
||||
isClosedPost={post.closed}
|
||||
/>
|
||||
<AlertBanner content={post} />
|
||||
<PostHeader post={post} actionHandlers={actionHandlers} />
|
||||
<div className="d-flex mt-4 mb-2 text-break font-style-normal text-primary-500">
|
||||
<HTMLLoader htmlNode={post.renderedBody} componentId="post" />
|
||||
<PostHeader post={post} />
|
||||
<div className="d-flex mt-14px text-break font-style text-primary-500">
|
||||
<HTMLLoader htmlNode={post.renderedBody} componentId="post" cssClassName="html-loader" testId={post.id} />
|
||||
</div>
|
||||
{topicContext && topic && (
|
||||
<div className={classNames('border px-3 rounded mb-4 border-light-400 align-self-start py-2.5',
|
||||
{ 'w-100': enableInContextSidebar })}
|
||||
{(topicContext || topic) && (
|
||||
<div
|
||||
className={classNames('mt-14px mb-1 font-style font-size-12',
|
||||
{ 'w-100': enableInContextSidebar })}
|
||||
style={{ lineHeight: '20px' }}
|
||||
>
|
||||
<span className="text-gray-500">{intl.formatMessage(messages.relatedTo)}{' '}</span>
|
||||
<span className="text-gray-500" style={{ lineHeight: '20px' }}>{intl.formatMessage(messages.relatedTo)}{' '}</span>
|
||||
<Hyperlink
|
||||
destination={topicContext.unitLink}
|
||||
destination={topicContext ? topicContext.unitLink : `${getConfig().BASE_URL}/${courseId}/topics/${post.topicId}`}
|
||||
target="_top"
|
||||
>
|
||||
{enableInContextSidebar
|
||||
{(topicContext && !topic)
|
||||
? (
|
||||
<>
|
||||
<span className="w-auto">{topicContext.chapterName}</span>
|
||||
@@ -131,13 +166,11 @@ function Post({
|
||||
<span className="w-auto">{topicContext.unitName}</span>
|
||||
</>
|
||||
)
|
||||
: `${getTopicCategoryName(topic)} / ${topic.name}`}
|
||||
: getTopicInfo(topic)}
|
||||
</Hyperlink>
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-3">
|
||||
<PostFooter post={post} preview={preview} />
|
||||
</div>
|
||||
<PostFooter post={post} preview={preview} />
|
||||
<ClosePostReasonModal
|
||||
isOpen={isClosing}
|
||||
onCancel={hideClosePostModal}
|
||||
@@ -154,6 +187,7 @@ Post.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
post: postShape.isRequired,
|
||||
preview: PropTypes.bool,
|
||||
handleAddResponseButton: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
Post.defaultProps = {
|
||||
|
||||
@@ -1,25 +1,14 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import * as timeago from 'timeago.js';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Badge, Icon, IconButtonWithTooltip, OverlayTrigger, Tooltip,
|
||||
Icon, IconButton, OverlayTrigger, Tooltip,
|
||||
} from '@edx/paragon';
|
||||
import {
|
||||
Locked,
|
||||
} from '@edx/paragon/icons';
|
||||
import { Locked, People } from '@edx/paragon/icons';
|
||||
|
||||
import {
|
||||
People,
|
||||
QuestionAnswer,
|
||||
QuestionAnswerOutline,
|
||||
StarFilled,
|
||||
StarOutline,
|
||||
} from '../../../components/icons';
|
||||
import timeLocale from '../../common/time-locale';
|
||||
import { StarFilled, StarOutline } from '../../../components/icons';
|
||||
import { selectUserHasModerationPrivileges } from '../../data/selectors';
|
||||
import { updateExistingThread } from '../data/thunks';
|
||||
import LikeButton from './LikeButton';
|
||||
@@ -29,81 +18,61 @@ import { postShape } from './proptypes';
|
||||
function PostFooter({
|
||||
post,
|
||||
intl,
|
||||
preview,
|
||||
showNewCountLabel,
|
||||
}) {
|
||||
const dispatch = useDispatch();
|
||||
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
|
||||
timeago.register('time-locale', timeLocale);
|
||||
|
||||
return (
|
||||
<div className="d-flex align-items-center">
|
||||
<LikeButton
|
||||
count={post.voteCount}
|
||||
onClick={() => dispatch(updateExistingThread(post.id, { voted: !post.voted }))}
|
||||
voted={post.voted}
|
||||
preview={preview}
|
||||
/>
|
||||
<IconButtonWithTooltip
|
||||
id={`follow-${post.id}-tooltip`}
|
||||
tooltipPlacement="top"
|
||||
tooltipContent={intl.formatMessage(post.following ? messages.unFollow : messages.follow)}
|
||||
src={post.following ? StarFilled : StarOutline}
|
||||
iconAs={Icon}
|
||||
alt="Follow"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
dispatch(updateExistingThread(post.id, { following: !post.following }));
|
||||
return true;
|
||||
}}
|
||||
size={preview ? 'inline' : 'sm'}
|
||||
className={preview && 'p-3'}
|
||||
iconClassNames={preview && 'icon-size'}
|
||||
/>
|
||||
{preview && post.commentCount > 1 && (
|
||||
<div className="d-flex align-items-center ml-4">
|
||||
<IconButtonWithTooltip
|
||||
tooltipPlacement="top"
|
||||
tooltipContent={intl.formatMessage(messages.viewActivity)}
|
||||
src={post.unreadCommentCount ? QuestionAnswer : QuestionAnswerOutline}
|
||||
iconAs={Icon}
|
||||
alt="Comment Count"
|
||||
size="inline"
|
||||
className="p-3 mr-0.5"
|
||||
iconClassNames="icon-size"
|
||||
/>
|
||||
{post.commentCount}
|
||||
</div>
|
||||
<div className="d-flex align-items-center ml-n1.5 mt-10px" style={{ lineHeight: '32px' }}>
|
||||
{post.voteCount !== 0 && (
|
||||
<LikeButton
|
||||
count={post.voteCount}
|
||||
onClick={() => dispatch(updateExistingThread(post.id, { voted: !post.voted }))}
|
||||
voted={post.voted}
|
||||
/>
|
||||
)}
|
||||
{showNewCountLabel && preview && post?.unreadCommentCount > 0 && post.commentCount > 1 && (
|
||||
<Badge variant="light" className="ml-2">
|
||||
{intl.formatMessage(messages.newLabel, { count: post.unreadCommentCount })}
|
||||
</Badge>
|
||||
{post.following && (
|
||||
<OverlayTrigger
|
||||
overlay={(
|
||||
<Tooltip id={`follow-${post.id}-tooltip`}>
|
||||
{intl.formatMessage(post.following ? messages.unFollow : messages.follow)}
|
||||
</Tooltip>
|
||||
)}
|
||||
>
|
||||
<IconButton
|
||||
src={post.following ? StarFilled : StarOutline}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
dispatch(updateExistingThread(post.id, { following: !post.following }));
|
||||
return true;
|
||||
}}
|
||||
iconAs={Icon}
|
||||
iconClassNames="follow-icon-dimentions"
|
||||
className="post-footer-icon-dimentions"
|
||||
alt="Follow"
|
||||
/>
|
||||
</OverlayTrigger>
|
||||
)}
|
||||
<div className="d-flex flex-fill justify-content-end align-items-center">
|
||||
{post.groupId && userHasModerationPrivileges && (
|
||||
<>
|
||||
<OverlayTrigger
|
||||
overlay={(
|
||||
<Tooltip id={`visibility-${post.id}-tooltip`}>{post.groupName}</Tooltip>
|
||||
)}
|
||||
>
|
||||
<span data-testid="cohort-icon">
|
||||
<People />
|
||||
</span>
|
||||
</OverlayTrigger>
|
||||
<span
|
||||
className="text-gray-700 mx-1.5 font-weight-500"
|
||||
style={{ fontSize: '16px' }}
|
||||
>
|
||||
·
|
||||
<OverlayTrigger
|
||||
overlay={(
|
||||
<Tooltip id={`visibility-${post.id}-tooltip`}>{post.groupName}</Tooltip>
|
||||
)}
|
||||
>
|
||||
<span data-testid="cohort-icon">
|
||||
<Icon
|
||||
src={People}
|
||||
style={{
|
||||
width: '22px',
|
||||
height: '20px',
|
||||
}}
|
||||
className="text-gray-500"
|
||||
/>
|
||||
</span>
|
||||
</>
|
||||
</OverlayTrigger>
|
||||
)}
|
||||
<span title={post.createdAt} className="text-gray-700">
|
||||
{timeago.format(post.createdAt, 'time-locale')}
|
||||
</span>
|
||||
{!preview && post.closed
|
||||
|
||||
{post.closed
|
||||
&& (
|
||||
<OverlayTrigger
|
||||
overlay={(
|
||||
@@ -117,8 +86,8 @@ function PostFooter({
|
||||
style={{
|
||||
width: '1rem',
|
||||
height: '1rem',
|
||||
marginLeft: '19.5px',
|
||||
}}
|
||||
className="ml-3"
|
||||
/>
|
||||
</OverlayTrigger>
|
||||
)}
|
||||
@@ -130,13 +99,7 @@ function PostFooter({
|
||||
PostFooter.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
post: postShape.isRequired,
|
||||
preview: PropTypes.bool,
|
||||
showNewCountLabel: PropTypes.bool,
|
||||
};
|
||||
|
||||
PostFooter.defaultProps = {
|
||||
preview: false,
|
||||
showNewCountLabel: false,
|
||||
};
|
||||
|
||||
export default injectIntl(PostFooter);
|
||||
|
||||
@@ -64,11 +64,6 @@ describe('PostFooter', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("shows 'x new' badge for new comments in case of read post only", () => {
|
||||
renderComponent(mockPost, true, true);
|
||||
expect(screen.getByText('2 New')).toBeTruthy();
|
||||
});
|
||||
|
||||
it("doesn't have 'new' badge when there are 0 new comments", () => {
|
||||
renderComponent({ ...mockPost, unreadCommentCount: 0 });
|
||||
expect(screen.queryByText('2 New')).toBeFalsy();
|
||||
@@ -89,18 +84,24 @@ describe('PostFooter', () => {
|
||||
expect(screen.getByTestId('cohort-icon')).toBeTruthy();
|
||||
});
|
||||
|
||||
it.each([[true, /unfollow/i], [false, /follow/i]])('test follow button when following=%s', async (following, message) => {
|
||||
renderComponent({ ...mockPost, following });
|
||||
it('test follow button when following=true', async () => {
|
||||
renderComponent({ ...mockPost, following: true });
|
||||
const followButton = screen.getByRole('button', { name: /follow/i });
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
|
||||
await act(async () => {
|
||||
fireEvent.mouseEnter(followButton);
|
||||
});
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent(message);
|
||||
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent(/unfollow/i);
|
||||
await act(async () => {
|
||||
fireEvent.click(followButton);
|
||||
});
|
||||
// clicking on the button triggers thread update.
|
||||
expect(store.getState().threads.status === RequestStatus.IN_PROGRESS).toBeTruthy();
|
||||
});
|
||||
|
||||
it('test follow button when following=false', async () => {
|
||||
renderComponent({ ...mockPost, following: false });
|
||||
expect(screen.queryByRole('button', { name: /follow/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Avatar, Badge, Icon } from '@edx/paragon';
|
||||
|
||||
import { Issue, Question } from '../../../components/icons';
|
||||
import { AvatarOutlineAndLabelColors, ThreadType } from '../../../data/constants';
|
||||
import { ActionsDropdown, AuthorLabel } from '../../common';
|
||||
import { AuthorLabel } from '../../common';
|
||||
import { useAlertBannerVisible } from '../../data/hooks';
|
||||
import { selectAuthorAvatars } from '../data/selectors';
|
||||
import messages from './messages';
|
||||
@@ -24,7 +24,7 @@ export function PostAvatar({
|
||||
const avatarSize = useMemo(() => {
|
||||
let size = '2rem';
|
||||
if (post.type === ThreadType.DISCUSSION && !fromPostLink) {
|
||||
size = '2.375rem';
|
||||
size = '2rem';
|
||||
} else if (post.type === ThreadType.QUESTION) {
|
||||
size = '1.5rem';
|
||||
}
|
||||
@@ -52,11 +52,11 @@ export function PostAvatar({
|
||||
/>
|
||||
)}
|
||||
<Avatar
|
||||
className={classNames('border-0', {
|
||||
className={classNames('border-0 mt-1', {
|
||||
[`outline-${outlineColor}`]: outlineColor,
|
||||
'outline-anonymous': !outlineColor,
|
||||
'mt-3 ml-2': post.type === ThreadType.QUESTION && fromPostLink,
|
||||
'avarat-img-position': post.type === ThreadType.QUESTION,
|
||||
'avarat-img-position mt-17px': post.type === ThreadType.QUESTION,
|
||||
})}
|
||||
style={{
|
||||
height: avatarSize,
|
||||
@@ -86,14 +86,13 @@ function PostHeader({
|
||||
intl,
|
||||
post,
|
||||
preview,
|
||||
actionHandlers,
|
||||
}) {
|
||||
const showAnsweredBadge = preview && post.hasEndorsed && post.type === ThreadType.QUESTION;
|
||||
const authorLabelColor = AvatarOutlineAndLabelColors[post.authorLabel];
|
||||
const hasAnyAlert = useAlertBannerVisible(post);
|
||||
|
||||
return (
|
||||
<div className={classNames('d-flex flex-fill mw-100', { 'mt-2': hasAnyAlert && !preview })}>
|
||||
<div className={classNames('d-flex flex-fill mw-100', { 'mt-10px': hasAnyAlert && !preview })}>
|
||||
<div className="flex-shrink-0">
|
||||
<PostAvatar post={post} authorLabel={post.authorLabel} />
|
||||
</div>
|
||||
@@ -109,21 +108,27 @@ function PostHeader({
|
||||
&& <Badge variant="success">{intl.formatMessage(messages.answered)}</Badge>}
|
||||
</div>
|
||||
)
|
||||
: <h4 className="mb-0" style={{ lineHeight: '28px' }} aria-level="1" tabIndex="-1" accessKey="h">{post.title}</h4>}
|
||||
: (
|
||||
<h5
|
||||
className="mb-0 font-style text-primary-500"
|
||||
style={{ lineHeight: '21px' }}
|
||||
aria-level="1"
|
||||
tabIndex="-1"
|
||||
accessKey="h"
|
||||
>
|
||||
{post.title}
|
||||
</h5>
|
||||
)}
|
||||
<AuthorLabel
|
||||
author={post.author || intl.formatMessage(messages.anonymous)}
|
||||
authorLabel={post.authorLabel}
|
||||
labelColor={authorLabelColor && `text-${authorLabelColor}`}
|
||||
linkToProfile
|
||||
postCreatedAt={post.createdAt}
|
||||
postOrComment
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{!preview
|
||||
&& (
|
||||
<div className="ml-auto d-flex">
|
||||
<ActionsDropdown commentOrPost={post} actionHandlers={actionHandlers} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -132,7 +137,6 @@ PostHeader.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
post: postShape.isRequired,
|
||||
preview: PropTypes.bool,
|
||||
actionHandlers: PropTypes.objectOf(PropTypes.func).isRequired,
|
||||
};
|
||||
|
||||
PostHeader.defaultProps = {
|
||||
|
||||
@@ -74,15 +74,15 @@ function PostLink({
|
||||
<Truncate lines={1} className="mr-1.5" whiteSpace>
|
||||
<span
|
||||
class={
|
||||
classNames('font-weight-500 font-size-14 text-primary-500 font-style-normal font-family-inter align-bottom',
|
||||
{ 'font-weight-bolder': !read })
|
||||
}
|
||||
classNames('font-weight-500 font-size-14 text-primary-500 font-style align-bottom',
|
||||
{ 'font-weight-bolder': !read })
|
||||
}
|
||||
>
|
||||
{post.title}
|
||||
</span>
|
||||
<span class="align-bottom"> </span>
|
||||
<span
|
||||
class="text-gray-700 font-weight-normal font-size-14 font-style-normal font-family-inter align-bottom"
|
||||
class="text-gray-700 font-weight-normal font-size-14 font-style align-bottom"
|
||||
>
|
||||
{isPostPreviewAvailable(post.previewBody)
|
||||
? post.previewBody
|
||||
@@ -107,10 +107,11 @@ function PostLink({
|
||||
)}
|
||||
|
||||
{post.pinned && (
|
||||
<Icon
|
||||
src={PushPin}
|
||||
className={`post-summary-icons-dimensions text-gray-700 ${canSeeReportedBadge || showAnsweredBadge ? 'ml-2' : 'ml-auto'}`}
|
||||
/>
|
||||
<Icon
|
||||
src={PushPin}
|
||||
className={`post-summary-icons-dimensions text-gray-700
|
||||
${canSeeReportedBadge || showAnsweredBadge ? 'ml-2' : 'ml-auto'}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,16 +9,10 @@ import {
|
||||
Badge, Icon, OverlayTrigger, Tooltip,
|
||||
} from '@edx/paragon';
|
||||
import {
|
||||
Locked,
|
||||
StarFilled, StarOutline, ThumbUpFilled, ThumbUpOutline,
|
||||
} from '@edx/paragon/icons';
|
||||
|
||||
import {
|
||||
People,
|
||||
QuestionAnswer,
|
||||
QuestionAnswerOutline,
|
||||
StarFilled,
|
||||
StarOutline, ThumbUpFilled, ThumbUpOutline,
|
||||
} from '../../../components/icons';
|
||||
import { People, QuestionAnswer, QuestionAnswerOutline } from '../../../components/icons';
|
||||
import timeLocale from '../../common/time-locale';
|
||||
import { selectUserHasModerationPrivileges } from '../../data/selectors';
|
||||
import messages from './messages';
|
||||
@@ -32,9 +26,8 @@ function PostSummaryFooter({
|
||||
}) {
|
||||
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
|
||||
timeago.register('time-locale', timeLocale);
|
||||
|
||||
return (
|
||||
<div className="d-flex align-items-center text-gray-700">
|
||||
<div className="d-flex align-items-center text-gray-700" style={{ height: '24px' }}>
|
||||
<div className="d-flex align-items-center mr-4.5">
|
||||
<OverlayTrigger
|
||||
overlay={(
|
||||
@@ -43,11 +36,11 @@ function PostSummaryFooter({
|
||||
</Tooltip>
|
||||
)}
|
||||
>
|
||||
<Icon src={post.voted ? ThumbUpFilled : ThumbUpOutline} className="post-summary-icons-dimensions mr-0.5">
|
||||
<Icon src={post.voted ? ThumbUpFilled : ThumbUpOutline} className="post-summary-like-dimensions mr-0.5">
|
||||
<span className="sr-only">{' '}{intl.formatMessage(post.voted ? messages.likedPost : messages.postLikes)}</span>
|
||||
</Icon>
|
||||
</OverlayTrigger>
|
||||
<div className="font-family-inter font-style-normal">
|
||||
<div className="font-style">
|
||||
{(post.voteCount && post.voteCount > 0) ? post.voteCount : null}
|
||||
</div>
|
||||
</div>
|
||||
@@ -60,12 +53,14 @@ function PostSummaryFooter({
|
||||
)}
|
||||
>
|
||||
<Icon src={post.following ? StarFilled : StarOutline} className="post-summary-icons-dimensions mr-0.5">
|
||||
<span className="sr-only">{' '}{ intl.formatMessage(post.following ? messages.srOnlyFollowDescription : messages.srOnlyUnFollowDescription)}</span>
|
||||
<span className="sr-only">
|
||||
{' '}{intl.formatMessage(post.following ? messages.srOnlyFollowDescription : messages.srOnlyUnFollowDescription)}
|
||||
</span>
|
||||
</Icon>
|
||||
</OverlayTrigger>
|
||||
|
||||
{preview && post.commentCount > 1 && (
|
||||
<div className="d-flex align-items-center ml-4.5">
|
||||
<div className="d-flex align-items-center ml-4.5 text-gray-700 font-style font-size-12">
|
||||
<OverlayTrigger
|
||||
overlay={(
|
||||
<Tooltip id={`follow-${post.id}-tooltip`}>
|
||||
@@ -73,7 +68,10 @@ function PostSummaryFooter({
|
||||
</Tooltip>
|
||||
)}
|
||||
>
|
||||
<Icon src={post.unreadCommentCount ? QuestionAnswer : QuestionAnswerOutline} className="post-summary-icons-dimensions mr-0.5">
|
||||
<Icon
|
||||
src={post.unreadCommentCount ? QuestionAnswer : QuestionAnswerOutline}
|
||||
className="post-summary-comment-count-dimensions mr-0.5"
|
||||
>
|
||||
<span className="sr-only">{' '} {intl.formatMessage(messages.activity)}</span>
|
||||
</Icon>
|
||||
</OverlayTrigger>
|
||||
@@ -87,46 +85,22 @@ function PostSummaryFooter({
|
||||
)}
|
||||
<div className="d-flex flex-fill justify-content-end align-items-center">
|
||||
{post.groupId && userHasModerationPrivileges && (
|
||||
<>
|
||||
<OverlayTrigger
|
||||
overlay={(
|
||||
<Tooltip id={`visibility-${post.id}-tooltip`}>{post.groupName}</Tooltip>
|
||||
)}
|
||||
>
|
||||
<span data-testid="cohort-icon" className="post-summary-icons-dimensions">
|
||||
<People />
|
||||
</span>
|
||||
</OverlayTrigger>
|
||||
<span
|
||||
className="text-gray-700 mx-1.5 font-weight-500"
|
||||
style={{ fontSize: '16px' }}
|
||||
>
|
||||
·
|
||||
<OverlayTrigger
|
||||
overlay={(
|
||||
<Tooltip id={`visibility-${post.id}-tooltip`}>{post.groupName}</Tooltip>
|
||||
)}
|
||||
>
|
||||
<span data-testid="cohort-icon" className="mr-2">
|
||||
<Icon
|
||||
src={People}
|
||||
className="text-gray-500 post-summary-icons-dimensions"
|
||||
/>
|
||||
</span>
|
||||
</>
|
||||
</OverlayTrigger>
|
||||
)}
|
||||
<span title={post.createdAt} className="text-gray-700 post-summary-timestamp">
|
||||
<span title={post.createdAt} className="text-gray-700 post-summary-timestamp ml-0.5">
|
||||
{timeago.format(post.createdAt, 'time-locale')}
|
||||
</span>
|
||||
{!preview && post.closed
|
||||
&& (
|
||||
<OverlayTrigger
|
||||
overlay={(
|
||||
<Tooltip id={`closed-${post.id}-tooltip`}>
|
||||
{intl.formatMessage(messages.postClosed)}
|
||||
</Tooltip>
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
src={Locked}
|
||||
style={{
|
||||
width: '1rem',
|
||||
height: '1rem',
|
||||
}}
|
||||
className="ml-3 post-summary-icons-dimensions"
|
||||
/>
|
||||
</OverlayTrigger>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,11 @@ const messages = defineMessages({
|
||||
defaultMessage: 'anonymous',
|
||||
description: 'Author name displayed when a post is anonymous',
|
||||
},
|
||||
addResponse: {
|
||||
id: 'discussions.post.addResponse',
|
||||
defaultMessage: 'Add response',
|
||||
description: 'Button to add a response in a thread of forum posts',
|
||||
},
|
||||
lastResponse: {
|
||||
id: 'discussions.post.lastResponse',
|
||||
defaultMessage: 'Last response {time}',
|
||||
|
||||
@@ -5,9 +5,7 @@ import { generatePath, useRouteMatch } from 'react-router';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import {
|
||||
CheckCircle,
|
||||
CheckCircleOutline,
|
||||
Delete, Edit, Pin, QuestionAnswer, Report, Verified, VerifiedOutline,
|
||||
CheckCircle, CheckCircleOutline, Delete, Edit, Pin, QuestionAnswer, Report, Verified, VerifiedOutline,
|
||||
} from '@edx/paragon/icons';
|
||||
|
||||
import { InsertLink } from '../components/icons';
|
||||
@@ -185,7 +183,6 @@ export function useActions(content) {
|
||||
.every(condition => condition === true)
|
||||
: true
|
||||
);
|
||||
|
||||
return ACTIONS_LIST.filter(
|
||||
({
|
||||
action,
|
||||
@@ -295,3 +292,7 @@ export function handleKeyDown(event) {
|
||||
selectedOption.focus();
|
||||
}
|
||||
}
|
||||
|
||||
export function isLastElementOfList(list, element) {
|
||||
return list[list.length - 1] === element;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user