Compare commits

..

11 Commits

Author SHA1 Message Date
Mehak Nasir
71d04a5353 docs: updated read me and catallog info files 2023-02-06 16:31:26 +05:00
Mehak Nasir
d2b2a2aff9 test: fixed test cases post mathjax-v3 merge 2023-01-27 19:31:40 +05:00
Mehak Nasir
569ce49801 fix: review fixees 2023-01-27 17:41:00 +05:00
ayeshoali
c67bc3e080 fix: fixed blur event for actions dropdown 2023-01-27 17:15:55 +05:00
ayeshoali
bcde4f5f87 refactor: added utility func to check if last element of list 2023-01-27 17:15:54 +05:00
ayeshoali
eaa3ce16ea test: added test cases for hover card component 2023-01-27 17:15:54 +05:00
Mehak Nasir
af5bc1a664 fix: fixed post style according to figma 2023-01-27 17:15:44 +05:00
ayeshoali
2fa0900a65 style: comment time moved next to author name 2023-01-27 17:14:49 +05:00
ayeshoali
afbd894154 fix: preview p changed from capital to small and 2px focus state border 2023-01-27 17:14:49 +05:00
ayeshoali
bfcb1282f0 fix: fixing test cases 2023-01-27 17:14:49 +05:00
Mehak Nasir
f081e8dc77 style: post content design updates 2023-01-27 17:14:38 +05:00
116 changed files with 1964 additions and 4011 deletions

View File

@@ -1,24 +0,0 @@
### 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.

View File

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

View File

@@ -1,20 +0,0 @@
# 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

View File

@@ -1,12 +0,0 @@
# 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

View File

@@ -5,6 +5,8 @@ transifex_langs = "ar,fr,es_419,zh_CN,tr_TR,pl,fr_CA,fr_FR,de_DE,it_IT"
transifex_utils = ./node_modules/.bin/transifex-utils.js
i18n = ./src/i18n
transifex_input = $(i18n)/transifex_input.json
tx_url1 = https://www.transifex.com/api/2/project/edx-platform/resource/$(transifex_resource)/translation/en/strings/
tx_url2 = https://www.transifex.com/api/2/project/edx-platform/resource/$(transifex_resource)/source/
# This directory must match .babelrc .
transifex_temp = ./temp/babel-plugin-react-intl

View File

@@ -27,8 +27,7 @@ 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.
Please tag **@edx/fedx-team** 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.
@@ -42,22 +41,18 @@ 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
.. _How to contribute: https://openedx.org/r/how-to-contribute
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`_.
All community members should familarize 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.
The assigned maintainers for this component and other project details may be found in Backstage or groked from inspecting catalog-info.yaml.
Reporting Security Issues
-------------------------

View File

@@ -2,6 +2,8 @@
# https://open-edx-proposals.readthedocs.io/en/latest/processes/oep-0055-proc-project-maintainers.html
apiVersion: backstage.io/v1alpha1
# (Required) Acceptable Values: Component, Resource, System
# A repo will almost certainly be a Component.
kind: Component
metadata:
name: 'frontend-app-discussions'
@@ -9,10 +11,28 @@ metadata:
links:
- url: "https://github.com/openedx/frontend-app-discussions"
title: "Frontend app discussions"
# Backstage uses the MaterialUI Icon Set.
# https://mui.com/material-ui/material-icons/
# The value will be the name of the icon.
icon: "Web"
annotations:
# (Optional) Annotation keys and values can be whatever you want.
# We use it in Open edX repos to have a comma-separated list of GitHub user
# names that might be interested in changes to the architecture of this
# component.
openedx.org/arch-interest-groups: ""
spec:
owner: group:edx-infinity
# (Required) This can be a group (`group:<github_group_name>`) or a user (`user:<github_username>`).
# Don't forget the "user:" or "group:" prefix. Groups must be GitHub team
# names in the openedx GitHub organization: https://github.com/orgs/openedx/teams
#
# If you need a new team created, create an issue with tCRIL engineering:
# https://github.com/openedx/tcril-engineering/issues/new/choose
owner: group:infinity
# (Required) Acceptable Type Values: service, website, library
type: 'website'
lifecycle: 'production'
# (Required) Acceptable Lifecycle Values: experimental, production, deprecated
lifecycle: 'production'

View File

@@ -13,178 +13,41 @@
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
defer
src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js"
id="MathJax-script"
></script>
<script async 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>

View File

@@ -5,34 +5,30 @@ 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, testId, delay,
htmlNode, componentId, cssClassName, testId,
}) {
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()))
.catch((err) => logError(`Typeset failed: ${err.message}`));
return promise;
}
if (debouncedPostContent) {
typeset(() => {
previewRef.current.innerHTML = sanitizedMath;
});
}
}, [debouncedPostContent]);
typeset(() => {
previewRef.current.innerHTML = sanitizedMath;
});
}, [htmlNode]);
return (
<div ref={previewRef} className={cssClassName} id={componentId} data-testid={testId} />
@@ -45,7 +41,6 @@ HTMLLoader.propTypes = {
componentId: PropTypes.string,
cssClassName: PropTypes.string,
testId: PropTypes.string,
delay: PropTypes.number,
};
HTMLLoader.defaultProps = {
@@ -53,7 +48,6 @@ HTMLLoader.defaultProps = {
componentId: null,
cssClassName: '',
testId: '',
delay: 0,
};
export default HTMLLoader;

View File

@@ -29,22 +29,17 @@ function PostPreviewPane({
className="float-right p-3"
iconClassNames="icon-size"
/>
<HTMLLoader
htmlNode={htmlNode}
cssClassName="text-primary"
componentId="post-preview"
testId="post-preview"
delay={500}
/>
<HTMLLoader htmlNode={htmlNode} cssClassName="text-primary" componentId="post-preview" testId="post-preview" />
</div>
)}
<div className="d-flex justify-content-end">
{!showPreviewPane && (
{!showPreviewPane
&& (
<Button
variant="link"
size="sm"
onClick={() => setShowPreviewPane(true)}
className={`text-primary-500 font-style p-0 ${editExisting && 'mb-4.5'}`}
className={`text-primary-500 p-0 ${editExisting && 'mb-4.5'}`}
style={{ lineHeight: '26px' }}
>
{intl.formatMessage(messages.showPreviewButton)}

View File

@@ -72,7 +72,6 @@ function Search({ intl }) {
<Icon
src={SearchIcon}
onClick={() => onSubmit(searchValue)}
data-testid="search-icon"
/>
</span>
</SearchField.Advanced>

View File

@@ -119,7 +119,6 @@ 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,

View File

@@ -1,108 +0,0 @@
/* 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);

View File

@@ -1,4 +1,3 @@
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';

View File

@@ -63,7 +63,6 @@ export const ContentActions = {
* @enum {string}
*/
export const RequestStatus = {
IDLE: 'idle',
IN_PROGRESS: 'in-progress',
SUCCESSFUL: 'successful',
FAILED: 'failed',

View File

@@ -0,0 +1,317 @@
import React, {
useContext, useEffect, useMemo, useState,
} 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, useUserCanAddThreadInBlackoutDate } 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, isLastElementOfList } 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 userCanAddThreadInBlackoutDate = useUserCanAddThreadInBlackoutDate();
const [addingResponse, setAddingResponse] = useState(false);
const handleDefinition = (message, commentsLength) => (
<div
className="mx-4 my-14px text-gray-700 font-style-normal font-family-inter"
role="heading"
aria-level="2"
style={{ lineHeight: '24px' }}
>
{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 py-0 mb-2 font-style-normal font-family-inter font-weight-500 font-size-14"
style={{
lineHeight: '24px',
border: '0px',
}}
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 py-10px mt-2 font-style-normal font-family-inter font-weight-500 font-size-14 text-primary-500"
style={{
lineHeight: '24px',
border: '0px',
}}
onClick={() => setAddingResponse(true)}
data-testid="add-response"
>
{intl.formatMessage(messages.addResponse)}
</Button>
)}
<ResponseEditor
postId={postId}
handleCloseEditor={() => setAddingResponse(false)}
addingResponse={addingResponse}
/>
</div>
)}
</>
)}
</>
)}
</>
);
}
DiscussionCommentsView.propTypes = {
postId: PropTypes.string.isRequired,
postType: PropTypes.string.isRequired,
isClosed: PropTypes.bool.isRequired,
intl: intlShape.isRequired,
endorsed: PropTypes.oneOf([
EndorsementStatus.ENDORSED, EndorsementStatus.UNENDORSED, EndorsementStatus.DISCUSSION,
]).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 [addingResponse, setAddingResponse] = useState(false);
const {
courseId, learnerUsername, category, topicId, page, enableInContextSidebar,
} = useContext(DiscussionContext);
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 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 border-0', {
'post-card-margin post-card-padding': !enableInContextSidebar,
'post-card-padding rounded-0 border-0 mb-4': enableInContextSidebar,
})}
>
<Post post={thread} handleAddResponseButton={() => setAddingResponse(true)} />
{!thread.closed && (
<ResponseEditor
postId={postId}
handleCloseEditor={() => setAddingResponse(false)}
addingResponse={addingResponse}
/>
)}
</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);

View File

@@ -10,7 +10,6 @@ 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';
@@ -18,18 +17,11 @@ 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 { getDiscussionTourUrl } from '../tours/data/api';
import { selectTours } from '../tours/data/selectors';
import { fetchDiscussionTours } from '../tours/data/thunks';
import discussionTourFactory from '../tours/data/tours.factory';
import { fetchThreads } from '../posts/data/thunks';
import { getCommentsApiUrl } from './data/api';
import { removeComment } from './data/thunks';
import '../posts/data/__factories__';
import './data/__factories__';
import '../topics/data/__factories__';
const courseConfigApiUrl = getCourseConfigApiUrl();
const commentsApiUrl = getCommentsApiUrl();
@@ -38,14 +30,9 @@ 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 = true;
const enableInContextSidebar = false;
let store;
let axiosMock;
let testLocation;
let container;
let unmount;
function mockAxiosReturnPagedComments() {
[null, false, true].forEach(endorsed => {
@@ -59,8 +46,6 @@ function mockAxiosReturnPagedComments() {
page_size: undefined,
requested_fields: 'profile_image',
endorsed,
reverse_order: reverseOrder,
enable_in_context_sidebar: enableInContextSidebar,
},
})
.reply(200, Factory.build('commentsResult', { can_delete: true }, {
@@ -82,7 +67,6 @@ function mockAxiosReturnPagedCommentsResponses() {
page: undefined,
page_size: undefined,
requested_fields: 'profile_image',
reverse_order: true,
};
for (let page = 1; page <= 2; page++) {
@@ -97,14 +81,8 @@ function mockAxiosReturnPagedCommentsResponses() {
}
}
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(
render(
<IntlProvider locale="en">
<AppProvider store={store}>
<DiscussionContext.Provider
@@ -124,50 +102,9 @@ function renderComponent(postId) {
</AppProvider>
</IntlProvider>,
);
container = wrapper.container;
unmount = wrapper.unmount;
}
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', () => {
describe('CommentsView', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
@@ -225,12 +162,11 @@ describe('ThreadView', () => {
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 screen.findByTestId('thread-1');
const addResponseButtons = screen.getAllByRole('button', { name: /add a response/i });
await act(async () => {
fireEvent.click(
addResponseButton,
addResponseButtons[0],
);
});
expect(screen.queryByTestId('tinymce-editor')).toBeInTheDocument();
@@ -242,12 +178,11 @@ describe('ThreadView', () => {
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 screen.findByTestId('thread-1');
const responseButtons = screen.getAllByRole('button', { name: /add a response/i });
await act(async () => {
fireEvent.click(
addResponseButton,
responseButtons[0],
);
});
await act(() => {
@@ -265,46 +200,56 @@ describe('ThreadView', () => {
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();
await act(async () => {
fireEvent.mouseOver(await waitFor(() => screen.findByText('Thread-2', { exact: false })));
});
expect(screen.queryByRole('button', { name: /add response/i }, { hidden: false })).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.mouseOver(await waitFor(() => screen.findByTestId('comment-1')));
});
await act(async () => {
fireEvent.click(
within(hoverCard).getByRole('button', { name: /Add comment/i }),
screen.getAllByRole('button', { name: /add 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.findByTestId('reply-comment-7')).toBeInTheDocument());
await waitFor(async () => expect(await screen.findByTestId('comment-comment-1')).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();
await screen.findByTestId('thread-2');
await act(async () => {
fireEvent.mouseOver(await waitFor(() => screen.findByTestId('comment-3')));
});
const addCommentButton = screen.getAllByRole('button', { name: /add comment/i }, { hidden: false })[0];
expect(addCommentButton).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.mouseOver(await waitFor(() => screen.findByTestId('comment-1')));
});
await act(async () => {
fireEvent.click(
within(hoverCard).getByRole('button', { name: /actions menu/i }),
// 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 () => {
@@ -317,7 +262,7 @@ describe('ThreadView', () => {
fireEvent.click(screen.getByRole('button', { name: /submit/i }));
});
await waitFor(async () => {
expect(await screen.findByTestId('comment-comment-1')).toBeInTheDocument();
expect(await screen.findByTestId('comment-1')).toBeInTheDocument();
});
});
@@ -341,11 +286,13 @@ describe('ThreadView', () => {
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.mouseOver(await waitFor(() => screen.findByTestId('comment-1')));
});
await act(async () => {
fireEvent.click(
within(hoverCard).getByRole('button', { name: /actions menu/i }),
// 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 () => {
@@ -373,11 +320,15 @@ describe('ThreadView', () => {
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.mouseOver(await waitFor(() => screen.findByTestId('post-thread-1')));
});
await act(async () => {
fireEvent.click(
within(hoverCard).getByRole('button', { name: /actions menu/i }),
// 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();
@@ -400,11 +351,13 @@ describe('ThreadView', () => {
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.mouseOver(await waitFor(() => screen.findByTestId('post-thread-1')));
});
await act(async () => {
fireEvent.click(
within(hoverCard).getByRole('button', { name: /actions menu/i }),
// 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();
@@ -420,11 +373,13 @@ describe('ThreadView', () => {
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.mouseOver(await waitFor(() => screen.findByText('Thread-2', { exact: false })));
});
await act(async () => {
fireEvent.click(
within(hoverCard).getByRole('button', { name: /actions menu/i }),
// 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();
@@ -439,11 +394,13 @@ describe('ThreadView', () => {
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.mouseOver(await waitFor(() => screen.findByTestId('post-thread-1')));
});
await act(async () => {
fireEvent.click(
within(hoverCard).getByRole('button', { name: /actions menu/i }),
// The first edit menu is for the post
screen.getAllByRole('button', { name: /actions menu/i })[0],
);
});
await act(async () => {
@@ -454,11 +411,13 @@ describe('ThreadView', () => {
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.mouseOver(await waitFor(() => screen.findByTestId('post-thread-1')));
});
await act(async () => {
fireEvent.click(
within(hoverCard).getByRole('button', { name: /actions menu/i }),
// The first edit menu is for the post
screen.getAllByRole('button', { name: /actions menu/i })[0],
);
});
await act(async () => {
@@ -469,11 +428,13 @@ describe('ThreadView', () => {
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.mouseOver(await waitFor(() => screen.findByTestId('post-thread-1')));
});
await act(async () => {
fireEvent.click(
within(hoverCard).getByRole('button', { name: /actions menu/i }),
// The first edit menu is for the post
screen.getAllByRole('button', { name: /actions menu/i })[0],
);
});
await act(async () => {
@@ -487,29 +448,18 @@ describe('ThreadView', () => {
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');
// Wait for the content to load
await act(async () => {
fireEvent.click(
within(hoverCard).getByRole('button', { name: /like/i }),
);
fireEvent.mouseOver(await waitFor(() => screen.findByTestId('comment-7')));
});
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 });
@@ -518,10 +468,12 @@ describe('ThreadView', () => {
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 }));
fireEvent.mouseOver(await waitFor(() => screen.findByTestId('comment-7')));
});
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 });
@@ -530,12 +482,12 @@ describe('ThreadView', () => {
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 }),
);
fireEvent.mouseOver(await waitFor(() => screen.findByTestId('comment-7')));
});
const actionButtons = screen.queryAllByRole('button', { name: /actions menu/i });
await act(async () => {
fireEvent.click(actionButtons[0]);
});
await act(async () => {
@@ -562,9 +514,9 @@ describe('ThreadView', () => {
it('initially loads only the first page', async () => {
renderComponent(discussionPostId);
expect(await screen.findByTestId('comment-comment-1'))
expect(await screen.findByTestId('comment-1'))
.toBeInTheDocument();
expect(screen.queryByTestId('comment-comment-2'))
expect(screen.queryByTestId('comment-2'))
.not
.toBeInTheDocument();
});
@@ -575,8 +527,8 @@ describe('ThreadView', () => {
const loadMoreButton = await findLoadMoreCommentsButton();
fireEvent.click(loadMoreButton);
await screen.findByTestId('comment-comment-1');
await screen.findByTestId('comment-comment-2');
await screen.findByTestId('comment-1');
await screen.findByTestId('comment-2');
});
it('newly loaded comments are appended to the old ones', async () => {
@@ -585,9 +537,9 @@ describe('ThreadView', () => {
const loadMoreButton = await findLoadMoreCommentsButton();
fireEvent.click(loadMoreButton);
await screen.findByTestId('comment-comment-1');
await screen.findByTestId('comment-1');
// check that comments from the first page are also displayed
expect(screen.queryByTestId('comment-comment-2'))
expect(screen.queryByTestId('comment-2'))
.toBeInTheDocument();
});
@@ -600,7 +552,7 @@ describe('ThreadView', () => {
fireEvent.click(loadMoreButton);
}
await screen.findByTestId('comment-comment-2');
await screen.findByTestId('comment-2');
await expect(findLoadMoreCommentsButton())
.rejects
.toThrow();
@@ -612,11 +564,11 @@ describe('ThreadView', () => {
it('initially loads only the first page', async () => {
act(() => renderComponent(questionPostId));
expect(await screen.findByTestId('comment-comment-3'))
expect(await screen.findByTestId('comment-3'))
.toBeInTheDocument();
expect(await screen.findByTestId('comment-comment-5'))
expect(await screen.findByTestId('comment-5'))
.toBeInTheDocument();
expect(screen.queryByTestId('comment-comment-4'))
expect(screen.queryByTestId('comment-4'))
.not
.toBeInTheDocument();
});
@@ -629,15 +581,15 @@ describe('ThreadView', () => {
const [loadMoreButtonEndorsed, loadMoreButtonUnendorsed] = await findLoadMoreCommentsButtons();
// Both load more buttons should show
expect(await findLoadMoreCommentsButtons()).toHaveLength(2);
expect(await screen.findByTestId('comment-comment-3'))
expect(await screen.findByTestId('comment-3'))
.toBeInTheDocument();
expect(await screen.findByTestId('comment-comment-5'))
expect(await screen.findByTestId('comment-5'))
.toBeInTheDocument();
// Comments from next page should not be loaded yet.
expect(await screen.queryByTestId('comment-comment-6'))
expect(await screen.queryByTestId('comment-6'))
.not
.toBeInTheDocument();
expect(await screen.queryByTestId('comment-comment-4'))
expect(await screen.queryByTestId('comment-4'))
.not
.toBeInTheDocument();
@@ -645,10 +597,10 @@ describe('ThreadView', () => {
fireEvent.click(loadMoreButtonEndorsed);
});
// Endorsed comment from next page should be loaded now.
await waitFor(() => expect(screen.queryByTestId('comment-comment-6'))
await waitFor(() => expect(screen.queryByTestId('comment-6'))
.toBeInTheDocument());
// Unendorsed comment from next page should not be loaded yet.
expect(await screen.queryByTestId('comment-comment-4'))
expect(await screen.queryByTestId('comment-4'))
.not
.toBeInTheDocument();
// Now only one load more buttons should show, for unendorsed comments
@@ -657,20 +609,20 @@ describe('ThreadView', () => {
fireEvent.click(loadMoreButtonUnendorsed);
});
// Unendorsed comment from next page should be loaded now.
await waitFor(() => expect(screen.queryByTestId('comment-comment-4'))
await waitFor(() => expect(screen.queryByTestId('comment-4'))
.toBeInTheDocument());
await expect(findLoadMoreCommentsButtons()).rejects.toThrow();
});
});
describe('for comments replies', () => {
describe('comments responses', () => {
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();
await waitFor(() => screen.findByTestId('comment-7'));
expect(screen.queryByTestId('comment-8')).not.toBeInTheDocument();
});
it('pressing load more button will load next page of responses', async () => {
@@ -681,7 +633,7 @@ describe('ThreadView', () => {
fireEvent.click(loadMoreButton);
});
await screen.findByTestId('reply-comment-8');
await screen.findByTestId('comment-8');
});
it('newly loaded responses are appended to the old ones', async () => {
@@ -692,9 +644,9 @@ describe('ThreadView', () => {
fireEvent.click(loadMoreButton);
});
await screen.findByTestId('reply-comment-8');
await screen.findByTestId('comment-8');
// check that comments from the first page are also displayed
expect(screen.queryByTestId('reply-comment-7')).toBeInTheDocument();
expect(screen.queryByTestId('comment-7')).toBeInTheDocument();
});
it('load more button is hidden when no more responses pages to load', async () => {
@@ -708,120 +660,101 @@ describe('ThreadView', () => {
});
}
await screen.findByTestId('reply-comment-8');
await screen.findByTestId('comment-8');
await expect(findLoadMoreCommentsResponsesButton())
.rejects
.toThrow();
});
it('handles liking a comment', async () => {
renderComponent(discussionPostId);
// Wait for the content to load
await act(async () => {
fireEvent.mouseOver(await waitFor(() => screen.findByTestId('comment-7')));
});
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 act(async () => {
fireEvent.mouseOver(await waitFor(() => screen.findByTestId('comment-7')));
});
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 act(async () => {
fireEvent.mouseOver(await waitFor(() => screen.findByTestId('comment-7')));
});
// 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', cardId: 'hover-card-thread-1' },
{ component: 'comment', testId: 'comment-comment-1', cardId: 'hover-card-comment-1' },
{ component: 'post', testId: 'post-thread-1' },
{ component: 'comment', testId: 'comment-comment-1' },
{ component: 'reply', testId: 'reply-comment-7' },
])('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 waitFor(() => expect(screen.findByTestId('post-thread-1')).toBeInTheDocument());
await waitFor(() => expect(screen.queryByText('This is Thread-1', { exact: false })).toBeInTheDocument());
const content = screen.getByTestId(testId);
await act(async () => {
fireEvent.click(
within(hoverCard).getByRole('button', { name: /actions menu/i }),
);
fireEvent.mouseOver(content);
});
const actionsButton = within(content).getAllByRole('button', { name: /actions menu/i })[0];
await act(async () => {
fireEvent.click(screen.queryByRole('button', { name: /Delete/i }));
fireEvent.click(actionsButton);
});
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();
});
});
describe('for comments sort', () => {
const getCommentSortDropdown = async () => {
renderComponent(discussionPostId);
await waitFor(() => screen.findByTestId('comment-comment-1'));
await act(async () => { fireEvent.click(screen.getByRole('button', { name: /Newest first/i })); });
return waitFor(() => screen.findByTestId('comment-sort-dropdown-modal-popup'));
};
it('should show sort dropdown if there are endorse or unendorsed comments', async () => {
renderComponent(discussionPostId);
const comment = await waitFor(() => screen.findByTestId('comment-comment-1'));
const sortWrapper = container.querySelector('.comments-sort');
const sortDropDown = within(sortWrapper).getByRole('button', { name: /Newest first/i });
expect(comment).toBeInTheDocument();
expect(sortDropDown).toBeInTheDocument();
});
it('should not show sort dropdown if there is no response', async () => {
const commentId = 'comment-1';
renderComponent(discussionPostId);
await waitFor(() => screen.findByTestId('comment-comment-1'));
axiosMock.onDelete(`${commentsApiUrl}${commentId}/`).reply(201);
await executeThunk(removeComment(commentId, discussionPostId), store.dispatch, store.getState);
expect(await waitFor(() => screen.findByText('No responses', { exact: true }))).toBeInTheDocument();
expect(container.querySelector('.comments-sort')).not.toBeInTheDocument();
});
it('should have only two options', async () => {
const dropdown = await getCommentSortDropdown();
expect(dropdown).toBeInTheDocument();
expect(await within(dropdown).getAllByRole('button')).toHaveLength(2);
});
it('should be selected Newest first and auto focus', async () => {
const dropdown = await getCommentSortDropdown();
expect(within(dropdown).getByRole('button', { name: /Newest first/i })).toBeInTheDocument();
expect(within(dropdown).getByRole('button', { name: /Newest first/i })).toHaveFocus();
expect(within(dropdown).getByRole('button', { name: /Oldest first/i })).not.toHaveFocus();
});
test('successfully handles sort state update', async () => {
const dropdown = await getCommentSortDropdown();
expect(store.getState().comments.sortOrder).toBeTruthy();
await act(async () => { fireEvent.click(within(dropdown).getByRole('button', { name: /Oldest first/i })); });
expect(store.getState().comments.sortOrder).toBeFalsy();
});
test('successfully handles tour state update', async () => {
const tourName = 'response_sort';
await axiosMock.onGet(getDiscussionTourUrl(), {}).reply(200, [discussionTourFactory.build({ tourName })]);
await executeThunk(fetchDiscussionTours(), store.dispatch, store.getState);
renderComponent(discussionPostId);
await waitFor(() => screen.findByTestId('comment-comment-1'));
const responseSortTour = () => selectTours(store.getState()).find(item => item.tourName === 'response_sort');
expect(responseSortTour().enabled).toBeTruthy();
await unmount();
expect(responseSortTour().enabled).toBeFalsy();
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();
});
});
});

View File

@@ -0,0 +1,44 @@
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 }));
if (comment.voteCount === 0) {
return null;
}
return (
<div className="ml-n1.5 mt-10px">
<LikeButton
count={comment.voteCount}
onClick={handleLike}
voted={comment.voted}
/>
</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);

View File

@@ -1,7 +1,4 @@
import React, {
useCallback,
useContext, useEffect, useMemo, useState,
} from 'react';
import React, { useContext, useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
@@ -10,23 +7,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, 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,
selectCommentSortOrder,
} 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 { useActions } from '../../utils';
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 CommentEditor from './CommentEditor';
import CommentHeader from './CommentHeader';
import { commentShape } from './proptypes';
@@ -51,32 +43,29 @@ function Comment({
const hasMorePages = useSelector(selectCommentHasMorePages(comment.id));
const currentPage = useSelector(selectCommentCurrentPage(comment.id));
const userCanAddThreadInBlackoutDate = useUserCanAddThreadInBlackoutDate();
const { courseId } = useContext(DiscussionContext);
const sortedOrder = useSelector(selectCommentSortOrder);
const [showHoverCard, setShowHoverCard] = useState(false);
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 && showFullThread) {
dispatch(fetchCommentResponses(comment.id, {
page: 1,
reverseOrder: sortedOrder,
}));
if (hasChildren && !currentPage && showFullThread) {
dispatch(fetchCommentResponses(comment.id, { page: 1 }));
}
}, [comment.id, sortedOrder]);
}, [comment.id]);
const actions = useActions({
...comment,
postType,
});
const endorseIcons = actions.find(({ action }) => action === EndorsementStatus.ENDORSED);
const handleAbusedFlag = useCallback(() => {
const handleAbusedFlag = () => {
if (comment.abuseFlagged) {
dispatch(editComment(comment.id, { flagged: !comment.abuseFlagged }));
} else {
showReportConfirmation();
}
}, [comment.abuseFlagged, comment.id, dispatch, showReportConfirmation]);
};
const handleDeleteConfirmation = () => {
dispatch(removeComment(comment.id));
@@ -88,7 +77,7 @@ function Comment({
hideReportConfirmation();
};
const actionHandlers = useMemo(() => ({
const actionHandlers = {
[ContentActions.EDIT_CONTENT]: () => setEditing(true),
[ContentActions.ENDORSE]: async () => {
await dispatch(editComment(comment.id, { endorsed: !comment.endorsed }, ContentActions.ENDORSE));
@@ -96,15 +85,11 @@ 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,
reverseOrder: sortedOrder,
}))
dispatch(fetchCommentResponses(comment.id, { page: currentPage + 1 }))
);
return (
<div className={classNames({ 'mb-3': (showFullThread && !marginBottom) })}>
{/* eslint-disable jsx-a11y/no-noninteractive-tabindex */}
@@ -134,16 +119,25 @@ function Comment({
/>
)}
<EndorsedAlertBanner postType={postType} content={comment} />
<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}
/>
<div
className="d-flex flex-column post-card-comment px-4 pt-3.5 pb-10px"
aria-level={5}
onMouseEnter={() => setShowHoverCard(true)}
onMouseLeave={() => setShowHoverCard(false)}
onFocus={() => setShowHoverCard(true)}
onBlur={() => setShowHoverCard(false)}
>
{showHoverCard && (
<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} />
{isEditing
@@ -152,21 +146,18 @@ function Comment({
)
: (
<HTMLLoader
cssClassName="comment-body html-loader text-break mt-14px font-style text-primary-500"
cssClassName="comment-body html-loader text-break mt-14px font-style-normal font-family-inter text-primary-500"
componentId="comment"
htmlNode={comment.renderedBody}
testId={comment.id}
/>
)}
{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>
)}
<CommentIcons
comment={comment}
following={comment.following}
onLike={() => dispatch(editComment(comment.id, { voted: !comment.voted }))}
createdAt={comment.createdAt}
/>
{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` */}
@@ -185,8 +176,11 @@ function Comment({
onClick={handleLoadMoreComments}
variant="link"
block="true"
className="font-size-14 line-height-24 font-style pt-10px border-0 font-weight-500 pb-0"
className="font-size-14 font-style-normal font-family-inter pt-10px border-0 font-weight-500 pb-0"
data-testid="load-more-comments-responses"
style={{
lineHeight: '20px',
}}
>
{intl.formatMessage(messages.loadMoreComments)}
</Button>
@@ -195,7 +189,10 @@ function Comment({
isReplying ? (
<div className="mt-2.5">
<CommentEditor
comment={{ threadId: comment.threadId, parentId: comment.id }}
comment={{
threadId: comment.threadId,
parentId: comment.id,
}}
edit={false}
onCloseEditor={() => setReplying(false)}
/>
@@ -205,9 +202,12 @@ function Comment({
{!isClosedPost && userCanAddThreadInBlackoutDate && (inlineReplies.length >= 5)
&& (
<Button
className="d-flex flex-grow mt-2 font-size-14 font-style font-weight-500 text-primary-500"
className="d-flex flex-grow mt-2 font-size-14 font-style-normal font-family-inter font-weight-500 text-primary-500"
variant="plain"
style={{ height: '36px' }}
style={{
lineHeight: '24px',
height: '36px',
}}
onClick={() => setReplying(true)}
>
{intl.formatMessage(messages.addComment)}

View File

@@ -9,20 +9,19 @@ 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 { DiscussionContext } from '../../../common/context';
import { TinyMCEEditor } from '../../../components';
import FormikErrorFeedback from '../../../components/FormikErrorFeedback';
import PostPreviewPane from '../../../components/PostPreviewPane';
import { useDispatchWithState } from '../../../data/hooks';
import {
selectModerationSettings,
selectUserHasModerationPrivileges,
selectUserIsGroupTa,
selectUserIsStaff,
} from '../../../data/selectors';
import { 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,
@@ -33,7 +32,6 @@ 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);
@@ -73,7 +71,7 @@ function CommentEditor({
};
await dispatch(editComment(comment.id, payload));
} else {
await dispatch(addComment(values.comment, comment.threadId, comment.parentId, enableInContextSidebar));
await dispatch(addComment(values.comment, comment.threadId, comment.parentId));
}
/* istanbul ignore if: TinyMCE is mocked so this cannot be easily tested */
if (editorRef.current) {

View File

@@ -6,10 +6,10 @@ 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 { 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({

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useMemo, useState } from 'react';
import React, { 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 = useCallback(() => {
const handleAbusedFlag = () => {
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 = useMemo(() => ({
const actionHandlers = {
[ContentActions.EDIT_CONTENT]: () => setEditing(true),
[ContentActions.ENDORSE]: () => dispatch(editComment(
reply.id,
@@ -58,8 +58,7 @@ 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);
@@ -121,7 +120,7 @@ function Reply({
postCreatedAt={reply.createdAt}
postOrComment
/>
<div className="ml-auto d-flex">
<div className="ml-auto d-flex" style={{ lineHeight: '24px' }}>
<ActionsDropdown
commentOrPost={{
...reply,
@@ -138,7 +137,7 @@ function Reply({
<HTMLLoader
componentId="reply"
htmlNode={reply.renderedBody}
cssClassName="html-loader text-break font-style text-primary-500"
cssClassName="html-loader text-break font-style-normal pb-1 font-family-inter text-primary-500"
testId={reply.id}
/>
)}

View File

@@ -19,7 +19,7 @@ function ResponseEditor({
return addingResponse
&& (
<div className={classNames({ 'bg-white p-4 mb-4 rounded mt-2': addWrappingDiv })}>
<div className={classNames({ 'bg-white p-4 mb-4 rounded': addWrappingDiv })}>
<CommentEditor
comment={{ threadId: postId }}
edit={false}

View File

@@ -16,8 +16,6 @@ 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(
@@ -25,8 +23,6 @@ export async function getThreadComments(
endorsed,
page,
pageSize,
reverseOrder,
enableInContextSidebar = false,
} = {},
) {
const params = snakeCaseObject({
@@ -34,9 +30,7 @@ export async function getThreadComments(
endorsed: EndorsementValue[endorsed],
page,
pageSize,
reverseOrder,
requestedFields: 'profile_image',
enableInContextSidebar,
});
const { data } = await getAuthenticatedHttpClient()
@@ -55,7 +49,6 @@ export async function getCommentResponses(
commentId, {
page,
pageSize,
reverseOrder,
} = {},
) {
const url = `${getCommentsApiUrl()}${commentId}/`;
@@ -63,7 +56,6 @@ export async function getCommentResponses(
page,
pageSize,
requestedFields: 'profile_image',
reverseOrder,
});
const { data } = await getAuthenticatedHttpClient()
.get(url, { params });
@@ -75,14 +67,11 @@ 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, enableInContextSidebar = false) {
export async function postComment(comment, threadId, parentId = null) {
const { data } = await getAuthenticatedHttpClient()
.post(getCommentsApiUrl(), snakeCaseObject({
threadId, raw_body: comment, parentId, enableInContextSidebar,
}));
.post(getCommentsApiUrl(), snakeCaseObject({ threadId, raw_body: comment, parentId }));
return data;
}

View File

@@ -276,7 +276,8 @@ 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
@@ -300,7 +301,8 @@ 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
@@ -314,14 +316,15 @@ 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-8',
'comment-7',
// our comment was pushed down
'comment-8',
// the newer comment is placed correctly
'comment-9',
]);
});
@@ -353,7 +356,8 @@ 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
@@ -367,14 +371,15 @@ 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-5',
'comment-4',
// our comment was pushed down
'comment-5',
// the newer comment is placed correctly
'comment-6',
]);
});

View File

@@ -36,6 +36,4 @@ export const selectCommentCurrentPage = commentId => (
state => state.comments.responsesPagination[commentId]?.currentPage || null
);
export const selectCommentsStatus = state => state.comments.status;
export const selectCommentSortOrder = state => state.comments.sortOrder;
export const commentsStatus = state => state.comments.status;

View File

@@ -22,7 +22,6 @@ const commentsSlice = createSlice({
postStatus: RequestStatus.SUCCESSFUL,
pagination: {},
responsesPagination: {},
sortOrder: true,
},
reducers: {
fetchCommentsRequest: (state) => {
@@ -57,6 +56,15 @@ 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;
@@ -75,17 +83,19 @@ const commentsSlice = createSlice({
},
fetchCommentResponsesSuccess: (state, { payload }) => {
state.status = RequestStatus.SUCCESSFUL;
if (payload.page === 1) {
state.commentsInComments[payload.commentId] = payload.commentsInComments[payload.commentId] || [];
} else {
state.commentsInComments[payload.commentId] = [
...new Set([
...(state.commentsInComments[payload.commentId] || []),
...(payload.commentsInComments[payload.commentId] || []),
]),
];
}
state.commentsInComments[payload.commentId] = [
...new Set([
...(state.commentsInComments[payload.commentId] || []),
...(payload.commentsInComments[payload.commentId] || []),
]),
];
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,
@@ -171,9 +181,6 @@ const commentsSlice = createSlice({
}
delete state.commentsById[commentId];
},
setCommentSortOrder: (state, { payload }) => {
state.sortOrder = payload;
},
},
});
@@ -199,7 +206,6 @@ export const {
deleteCommentFailed,
deleteCommentRequest,
deleteCommentSuccess,
setCommentSortOrder,
} = commentsSlice.actions;
export const commentsReducer = commentsSlice.reducer;

View File

@@ -74,21 +74,11 @@ function normaliseComments(data) {
};
}
export function fetchThreadComments(
threadId,
{
page = 1,
reverseOrder,
endorsed = EndorsementStatus.DISCUSSION,
enableInContextSidebar,
} = {},
) {
export function fetchThreadComments(threadId, { page = 1, endorsed = EndorsementStatus.DISCUSSION } = {}) {
return async (dispatch) => {
try {
dispatch(fetchCommentsRequest());
const data = await getThreadComments(threadId, {
page, reverseOrder, endorsed, enableInContextSidebar,
});
const data = await getThreadComments(threadId, { page, endorsed });
dispatch(fetchCommentsSuccess({
...normaliseComments(camelCaseObject(data)),
endorsed,
@@ -106,11 +96,11 @@ export function fetchThreadComments(
};
}
export function fetchCommentResponses(commentId, { page = 1, reverseOrder = true } = {}) {
export function fetchCommentResponses(commentId, { page = 1 } = {}) {
return async (dispatch) => {
try {
dispatch(fetchCommentResponsesRequest({ commentId }));
const data = await getCommentResponses(commentId, { page, reverseOrder });
const data = await getCommentResponses(commentId, { page });
dispatch(fetchCommentResponsesSuccess({
...normaliseComments(camelCaseObject(data)),
page,
@@ -147,7 +137,7 @@ export function editComment(commentId, comment, action = null) {
};
}
export function addComment(comment, threadId, parentId = null, enableInContextSidebar = false) {
export function addComment(comment, threadId, parentId = null) {
return async (dispatch) => {
try {
dispatch(postCommentRequest({
@@ -155,7 +145,7 @@ export function addComment(comment, threadId, parentId = null, enableInContextSi
threadId,
parentId,
}));
const data = await postComment(comment, threadId, parentId, enableInContextSidebar);
const data = await postComment(comment, threadId, parentId);
dispatch(postCommentSuccess(camelCaseObject(data)));
} catch (error) {
if (getHttpErrorStatus(error) === 403) {

View File

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

View File

@@ -212,15 +212,6 @@ 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;

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useRef, useState } from 'react';
import React, { useContext, useState } from 'react';
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
@@ -11,11 +11,12 @@ 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,
@@ -25,54 +26,41 @@ function ActionsDropdown({
iconSize,
dropDownIconSize,
}) {
const buttonRef = useRef();
const [isOpen, open, close] = useToggle(false);
const [target, setTarget] = useState(null);
const actions = useActions(commentOrPost);
const handleActions = useCallback((action) => {
const { enableInContextSidebar } = useContext(DiscussionContext);
const handleActions = (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={onClickButton}
onClick={open}
alt={intl.formatMessage(messages.actionsAlt)}
src={MoreHoriz}
iconAs={Icon}
disabled={disabled}
size={iconSize}
ref={buttonRef}
ref={setTarget}
iconClassNames={dropDownIconSize ? 'dropdown-icon-dimentions' : ''}
/>
<div className="actions-dropdown">
<ModalPopup
onClose={onCloseModal}
onClose={close}
positionRef={target}
isOpen={isOpen}
placement="bottom-end"
placement={enableInContextSidebar ? 'left' : 'auto-start'}
>
<div
className="bg-white p-1 shadow d-flex flex-column"

View File

@@ -14,7 +14,7 @@ import messages from '../messages';
import { ACTIONS_LIST } from '../utils';
import ActionsDropdown from './ActionsDropdown';
import '../post-comments/data/__factories__';
import '../comments/data/__factories__';
import '../posts/data/__factories__';
let store;

View File

@@ -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';
@@ -41,13 +41,13 @@ function AlertBanner({
<>
{content.lastEdit?.reason && (
<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">
<div className="d-flex align-items-center flex-wrap text-gray-700 font-family-inter">
{intl.formatMessage(messages.editedBy)}
<span className="ml-1 mr-3">
<AuthorLabel author={content.lastEdit.editorUsername} linkToProfile postOrComment />
</span>
<span
className="mx-1.5 font-size-8 font-style text-light-700"
className="mx-1.5 font-family-inter font-size-8 font-style-normal text-light-700"
style={{ lineHeight: '15px' }}
>
{intl.formatMessage(messages.fullStop)}
@@ -58,13 +58,13 @@ function AlertBanner({
)}
{content.closed && (
<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">
<div className="d-flex align-items-center flex-wrap text-gray-700 font-family-inter">
{intl.formatMessage(messages.closedBy)}
<span className="ml-1 ">
<AuthorLabel author={content.closedBy} linkToProfile postOrComment />
</span>
<span
className="mx-1.5 font-size-8 font-style text-light-700"
className="mx-1.5 font-family-inter font-size-8 font-style-normal text-light-700"
style={{ lineHeight: '15px' }}
>
{intl.formatMessage(messages.fullStop)}

View File

@@ -7,11 +7,11 @@ import { AppProvider } from '@edx/frontend-platform/react';
import { ThreadType } from '../../data/constants';
import { initializeStore } from '../../store';
import messages from '../post-comments/messages';
import messages from '../comments/messages';
import AlertBanner from './AlertBanner';
import { DiscussionContext } from './context';
import '../post-comments/data/__factories__';
import '../comments/data/__factories__';
import '../posts/data/__factories__';
let store;

View File

@@ -51,10 +51,10 @@ function AuthorLabel({
&& linkToProfile && author && author !== intl.formatMessage(messages.anonymous);
const labelContents = (
<div className={className}>
<div className={className} style={{ lineHeight: '24px' }}>
{!alert && (
<span
className={classNames('mr-1.5 font-size-14 font-style font-weight-500', {
className={classNames('mr-1.5 font-size-14 font-style-normal font-family-inter font-weight-500', {
'text-gray-700': isRetiredUser,
'text-primary-500': !authorLabelMessage && !isRetiredUser,
})}
@@ -91,7 +91,7 @@ function AuthorLabel({
</OverlayTrigger>
{authorLabelMessage && (
<span
className={classNames('mr-1.5 font-size-14 font-style font-weight-500', {
className={classNames('mr-1.5 font-size-14 font-style-normal font-family-inter font-weight-500', {
'text-primary-500': showTextPrimary,
'text-gray-700': isRetiredUser,
})}

View File

@@ -8,8 +8,8 @@ import { Alert, Icon } from '@edx/paragon';
import { CheckCircle, Verified } from '@edx/paragon/icons';
import { ThreadType } from '../../data/constants';
import { commentShape } from '../post-comments/comments/comment/proptypes';
import messages from '../post-comments/messages';
import { commentShape } from '../comments/comment/proptypes';
import messages from '../comments/messages';
import AuthorLabel from './AuthorLabel';
import timeLocale from './time-locale';
@@ -39,8 +39,11 @@ function EndorsedAlertBanner({
height: '20px',
}}
/>
<strong className="ml-2 font-family-inter">
{intl.formatMessage(isQuestion ? messages.answer : messages.endorsed)}
<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' }}>

View File

@@ -7,11 +7,11 @@ import { AppProvider } from '@edx/frontend-platform/react';
import { ThreadType } from '../../data/constants';
import { initializeStore } from '../../store';
import messages from '../post-comments/messages';
import messages from '../comments/messages';
import { DiscussionContext } from './context';
import EndorsedAlertBanner from './EndorsedAlertBanner';
import '../post-comments/data/__factories__';
import '../comments/data/__factories__';
import '../posts/data/__factories__';
let store;

View File

@@ -4,13 +4,16 @@ import PropTypes from 'prop-types';
import classNames from 'classnames';
import { injectIntl } from '@edx/frontend-platform/i18n';
import { Button, Icon, IconButton } from '@edx/paragon';
import {
Button,
Icon, IconButton,
} from '@edx/paragon';
import {
StarFilled, StarOutline, ThumbUpFilled, ThumbUpOutline,
} from '../../components/icons';
import { commentShape } from '../comments/comment/proptypes';
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';
@@ -27,26 +30,29 @@ function HoverCard({
}) {
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}`}
className="d-flex flex-fill justify-content-end align-items-center hover-card mr-n4 position-absolute"
data-testid="hover-card"
>
{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',
className={classNames('px-2.5 py-2 border-0 font-style-normal font-family-inter text-gray-700 font-size-12',
{ 'w-100': enableInContextSidebar })}
onClick={() => handleResponseCommentButton()}
disabled={isClosedPost}
style={{ lineHeight: '20px' }}
style={{
lineHeight: '20px',
}}
>
{addResponseCommentButtonMessage}
</Button>
</div>
)}
{endorseIcons && (
<div className="hover-button">
<IconButton
@@ -86,6 +92,7 @@ function HoverCard({
onClick={(e) => {
e.preventDefault();
onFollow();
return true;
}}
/>
</div>

View File

@@ -1,7 +1,7 @@
import {
render, screen, waitFor,
within,
render, screen, waitFor, within,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import MockAdapter from 'axios-mock-adapter';
import { IntlProvider } from 'react-intl';
import { MemoryRouter, Route } from 'react-router';
@@ -13,22 +13,20 @@ import { AppProvider } from '@edx/frontend-platform/react';
import { initializeStore } from '../../store';
import { executeThunk } from '../../test-utils';
import { getCommentsApiUrl } from '../comments/data/api';
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__';
import '../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 = true;
const enableInContextSidebar = false;
let store;
let axiosMock;
let container;
@@ -45,8 +43,6 @@ function mockAxiosReturnPagedComments() {
page_size: undefined,
requested_fields: 'profile_image',
endorsed,
reverse_order: reverseOrder,
enable_in_context_sidebar: enableInContextSidebar,
},
})
.reply(200, Factory.build('commentsResult', { can_delete: true }, {
@@ -154,22 +150,30 @@ describe('HoverCard', () => {
mockAxiosReturnPagedCommentsResponses();
});
test('it should have hover card on post', async () => {
test('it should show hover card when hovered on post', async () => {
renderComponent(discussionPostId);
const post = screen.getByTestId('post-thread-1');
expect(within(post).getByTestId('hover-card-thread-1')).toBeInTheDocument();
userEvent.hover(post);
expect(screen.getByTestId('hover-card')).toBeInTheDocument();
});
test('it should have hover card on comment', async () => {
test('it should show hover card when hovered on comment', async () => {
renderComponent(discussionPostId);
const comment = await waitFor(() => screen.findByTestId('comment-comment-1'));
expect(within(comment).getByTestId('hover-card-comment-1')).toBeInTheDocument();
const comment = await waitFor(() => screen.findByTestId('comment-1'));
userEvent.hover(comment);
expect(screen.getByTestId('hover-card')).toBeInTheDocument();
});
test('it should not show hover card when post and comment not hovered', async () => {
renderComponent(discussionPostId);
expect(screen.queryByTestId('hover-card')).not.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');
userEvent.hover(post);
const view = screen.getByTestId('hover-card');
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();
@@ -178,8 +182,10 @@ describe('HoverCard', () => {
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');
const comment = await waitFor(() => screen.findByTestId('comment-3'));
userEvent.hover(comment);
const view = screen.getByTestId('hover-card');
expect(screen.getByTestId('hover-card')).toBeInTheDocument();
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();

View File

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

View File

@@ -217,31 +217,10 @@ export const useTourConfiguration = (intl) => {
advanceButtonText: intl.formatMessage(messages.advanceButtonText),
dismissButtonText: intl.formatMessage(messages.dismissButtonText),
endButtonText: intl.formatMessage(messages.endButtonText),
enabled: tour && Boolean(tour.enabled && tour.showTour && !enableInContextSidebar),
enabled: tour && Boolean(tour.showTour && !enableInContextSidebar),
onDismiss: () => dispatch(updateTourShowStatus(tour.id)),
onEnd: () => dispatch(updateTourShowStatus(tour.id)),
checkpoints: tourCheckpoints(intl)[camelToConstant(tour.tourName)],
}
));
};
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;
};

View File

@@ -6,7 +6,7 @@ import { Route, Switch } from 'react-router';
import { injectIntl } from '@edx/frontend-platform/i18n';
import { Routes } from '../../data/constants';
import { PostCommentsView } from '../post-comments';
import { CommentsView } from '../comments';
import { PostEditor } from '../posts';
function DiscussionContent() {
@@ -25,7 +25,7 @@ function DiscussionContent() {
<PostEditor editExisting />
</Route>
<Route path={Routes.COMMENTS.PATH}>
<PostCommentsView />
<CommentsView />
</Route>
</Switch>
)}

View File

@@ -87,7 +87,7 @@ export default function DiscussionsHome() {
>
<div
className={classNames('d-flex flex-row justify-content-between navbar fixed-top', {
'pl-4 pr-3 py-0': enableInContextSidebar,
'pl-4 pr-2.5 py-1.5': 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>
{!enableInContextSidebar && <DiscussionsProductTour />}
<DiscussionsProductTour />
</main>
{!enableInContextSidebar && <Footer />}
</DiscussionContext.Provider>

View File

@@ -1,6 +1,6 @@
import React, { useContext, useEffect } from 'react';
import React, { useContext } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useSelector } from 'react-redux';
import { useLocation } from 'react-router-dom';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
@@ -8,7 +8,6 @@ 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';
@@ -16,18 +15,14 @@ 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 provider = useSelector(selectDiscussionProvider);
const topicsStatus = useSelector(selectLoadingStatus);
const topicsInProgress = topicsStatus === RequestStatus.IN_PROGRESS;
const topicsLoadingStatus = useSelector(selectLoadingStatus);
const posts = useSelector(selectTopicThreads([topicId]));
const selectedSubsectionUnits = useSelector(selectSubsectionUnits(category));
const selectedSubsection = useSelector(selectSubsection(category));
@@ -35,12 +30,6 @@ 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 };
@@ -51,14 +40,12 @@ 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)}
/>
@@ -69,7 +56,6 @@ function TopicPostsView({ intl }) {
<PostsList
posts={posts}
topics={[topicId]}
parentIsLoading={topicsInProgress}
/>
) : (
selectedSubsectionUnits?.map((unit) => (
@@ -79,10 +65,10 @@ function TopicPostsView({ intl }) {
/>
))
)}
{(category && selectedSubsectionUnits.length === 0 && topicsStatus === RequestStatus.SUCCESSFUL) && (
{(category && selectedSubsectionUnits.length === 0 && topicsLoadingStatus === RequestStatus.SUCCESSFUL) && (
<NoResults />
)}
{(category && topicsInProgress) && (
{(category && topicsLoadingStatus === RequestStatus.IN_PROGRESS) && (
<div className="d-flex justify-content-center p-4">
<Spinner animation="border" variant="primary" size="lg" />
</div>

View File

@@ -1,255 +0,0 @@
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();
});
});
});
});

View File

@@ -1,233 +0,0 @@
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();
});
});
});
});

View File

@@ -4,14 +4,12 @@ import PropTypes from 'prop-types';
import { useHistory } from 'react-router-dom';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Icon, IconButton, Spinner } from '@edx/paragon';
import { Icon, IconButton } from '@edx/paragon';
import { ArrowBack } from '@edx/paragon/icons';
import messages from '../messages';
function BackButton({
intl, path, title, loading,
}) {
function BackButton({ intl, path, title }) {
const history = useHistory();
return (
@@ -26,7 +24,7 @@ function BackButton({
alt={intl.formatMessage(messages.backAlt)}
/>
<div className="d-flex flex-fill justify-content-center align-items-center mr-4.5">
{loading ? <Spinner animation="border" variant="primary" size="sm" /> : title}
{title}
</div>
</div>
<div className="border-bottom border-light-400" />
@@ -38,11 +36,6 @@ BackButton.propTypes = {
intl: intlShape.isRequired,
path: PropTypes.shape({}).isRequired,
title: PropTypes.string.isRequired,
loading: PropTypes.bool,
};
BackButton.defaultProps = {
loading: false,
};
export default injectIntl(BackButton);

View File

@@ -1,89 +0,0 @@
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,
});
});

View File

@@ -1 +0,0 @@
import './inContextTopics.factory';

View File

@@ -4,8 +4,6 @@ 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);

View File

@@ -1,72 +0,0 @@
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');
});
});

View File

@@ -1,186 +0,0 @@
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);
});
});
});
});

View File

@@ -1,147 +0,0 @@
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');
});
});
});

View File

@@ -6,7 +6,7 @@ import { RequestStatus } from '../../../data/constants';
const topicsSlice = createSlice({
name: 'inContextTopics',
initialState: {
status: RequestStatus.IDLE,
status: RequestStatus.IN_PROGRESS,
topics: [],
coursewareTopics: [],
nonCoursewareTopics: [],
@@ -44,7 +44,6 @@ export const {
fetchCourseTopicsRequest,
fetchCourseTopicsSuccess,
fetchCourseTopicsFailed,
fetchCourseTopicsDenied,
setFilter,
setSortBy,
} = topicsSlice.actions;

View File

@@ -3,11 +3,8 @@ import { reduce } from 'lodash';
import { logError } from '@edx/frontend-platform/logging';
import { getHttpErrorStatus } from '../../utils';
import { getCourseTopicsV3 } from './api';
import {
fetchCourseTopicsDenied, fetchCourseTopicsFailed, fetchCourseTopicsRequest, fetchCourseTopicsSuccess,
} from './slices';
import { fetchCourseTopicsFailed, fetchCourseTopicsRequest, fetchCourseTopicsSuccess } from './slices';
function normalizeTopicsV3(topics) {
const coursewareUnits = reduce(topics, (arrayOfUnits, chapter) => {
@@ -60,11 +57,7 @@ export function fetchCourseTopicsV3(courseId) {
const data = await getCourseTopicsV3(courseId);
dispatch(fetchCourseTopicsSuccess(normalizeTopicsV3(data)));
} catch (error) {
if (getHttpErrorStatus(error) === 403) {
dispatch(fetchCourseTopicsDenied());
} else {
dispatch(fetchCourseTopicsFailed());
}
dispatch(fetchCourseTopicsFailed());
logError(error);
}
};

View File

@@ -50,7 +50,6 @@ function TopicSearchBar({ intl }) {
<Icon
src={SearchIcon}
onClick={() => onSubmit(searchValue)}
data-testid="search-icon"
/>
</span>
</SearchField.Advanced>

View File

@@ -7,7 +7,6 @@ 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';
@@ -50,13 +49,12 @@ function SectionBaseGroup({
aria-current={isSelected(section.id) ? 'page' : undefined}
tabIndex={(isSelected(subsection.id) || index === 0) ? 0 : -1}
>
<div className="d-flex flex-row pt-2.5 pb-2 px-4">
<div className="d-flex flex-row py-3.5 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>

View File

@@ -11,7 +11,6 @@ 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';
@@ -54,11 +53,65 @@ function Topic({
{topic?.name || topic?.displayName || intl.formatMessage(messages.unnamedTopicSubCategories)}
</div>
</div>
<TopicStats
threadCounts={topic?.threadCounts}
activeFlags={topic?.activeFlags}
inactiveFlags={topic?.inactiveFlags}
/>
<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>
</div>
</div>
</Link>

View File

@@ -1,4 +1,4 @@
export * from './comments';
export * from './discussions-home';
export * from './post-comments';
export * from './posts';
export * from './topics';

View File

@@ -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 font-weight-bold py-2.5">
<div className="text-primary-500 font-style-normal font-family-inter font-weight-bold py-2.5">
{intl.formatMessage(messages.activityForLearner, { username: capitalize(username) })}
</div>
<div style={{ padding: '18px' }} />

View File

@@ -1,8 +1,6 @@
import React from 'react';
import {
fireEvent, render, screen, waitFor, within,
} from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import { act } from 'react-dom/test-utils';
import { IntlProvider } from 'react-intl';
@@ -13,13 +11,11 @@ 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 { initializeStore } from '../../store';
import { executeThunk } from '../../test-utils';
import { DiscussionContext } from '../common/context';
import { getDiscussionsConfigUrl } from '../data/api';
import { getCourseConfigApiUrl } from '../data/api';
import { fetchCourseConfig } from '../data/thunks';
import { getUserProfileApiUrl, learnersApiUrl } from './data/api';
import { getCoursesApiUrl, getUserProfileApiUrl } from './data/api';
import { fetchLearners } from './data/thunks';
import LearnersView from './LearnersView';
@@ -27,31 +23,27 @@ import './data/__factories__';
let store;
let axiosMock;
const coursesApiUrl = getCoursesApiUrl();
const courseConfigApiUrl = getCourseConfigApiUrl();
const userProfileApiUrl = getUserProfileApiUrl();
const courseId = 'course-v1:edX+TestX+Test_Course';
let container;
function renderComponent() {
const wrapper = render(
return render(
<IntlProvider locale="en">
<AppProvider store={store}>
<DiscussionContext.Provider value={{
page: 'learners',
}}
>
<MemoryRouter initialEntries={[`/${courseId}/`]}>
<Route path="/:courseId/">
<PostActionsBar />
<LearnersView />
</Route>
</MemoryRouter>
</DiscussionContext.Provider>
<MemoryRouter initialEntries={[`/${courseId}/`]}>
<Route path="/:courseId/">
<LearnersView />
</Route>
</MemoryRouter>
</AppProvider>
</IntlProvider>,
);
container = wrapper.container;
}
describe('LearnersView', () => {
const learnerCount = 3;
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
@@ -61,190 +53,41 @@ describe('LearnersView', () => {
roles: [],
},
});
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
store = initializeStore();
Factory.resetAll();
});
async function setUpLearnerMockResponse(
count = 3,
pageSize = 6,
page = 1,
username = ['learner-1', 'learner-2', 'learner-3'],
searchText,
) {
Factory.resetAll();
const learnersData = Factory.build('learnersResult', {}, {
count,
pageSize,
page,
count: learnerCount,
pageSize: 6,
});
axiosMock.onGet(learnersApiUrl(courseId))
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock.onGet(`${coursesApiUrl}${courseId}/activity_stats/`)
.reply(() => [200, learnersData]);
axiosMock.onGet(`${getUserProfileApiUrl()}?username=${username.join()}`)
.reply(() => [200, Factory.build('learnersProfile', {}, {
username,
}).profiles]);
await executeThunk(fetchLearners(courseId, { usernameSearch: searchText }), store.dispatch, store.getState);
}
async function assignPrivilages() {
axiosMock.onGet(getDiscussionsConfigUrl(courseId)).reply(200, {
learners_tab_enabled: true,
user_is_privileged: true,
const learnersProfile = Factory.build('learnersProfile', {}, {
username: ['leaner-1', 'leaner-2', 'leaner-3'],
});
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
}
test('Learners tab is disabled by default', async () => {
await setUpLearnerMockResponse();
await renderComponent();
expect(screen.queryByText('learner-1')).not.toBeInTheDocument();
axiosMock.onGet(`${userProfileApiUrl}?username=leaner-1,leaner-2,leaner-3`)
.reply(() => [200, learnersProfile.profiles]);
await executeThunk(fetchLearners(courseId), store.dispatch, store.getState);
});
test('Learners tab is enabled', async () => {
await setUpLearnerMockResponse();
await assignPrivilages();
await waitFor(() => {
renderComponent();
});
expect(screen.queryByText('learner-1')).toBeInTheDocument();
});
test('Most activity should be selected by default for the non-moderator role.', async () => {
await setUpLearnerMockResponse();
await renderComponent();
const filterBar = container.querySelector('.collapsible-trigger');
await act(async () => {
fireEvent.click(filterBar);
});
await waitFor(() => {
const mostActivity = screen.getByTestId('activity selected');
expect(mostActivity).toBeInTheDocument();
});
});
it.each([
{ searchBy: 'sort-recency', result: 0 },
{ searchBy: 'sort-activity', result: 3 },
])('successfully display learners by %s.', async ({ searchBy, result }) => {
await setUpLearnerMockResponse();
await assignPrivilages();
await renderComponent();
const filterBar = container.querySelector('.collapsible-trigger');
await act(async () => {
fireEvent.click(filterBar);
});
await waitFor(async () => {
const activity = container.querySelector(`#${searchBy}`);
describe('Basic', () => {
test('Learners tab is disabled by default', async () => {
await act(async () => {
fireEvent.click(activity);
await renderComponent();
});
await waitFor(() => {
const learners = container.querySelectorAll('.discussion-post');
expect(learners).toHaveLength(result);
expect(screen.queryByText(/Last active/i)).toBeFalsy();
});
test('Learners tab is enabled', async () => {
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`).reply(200, {
learners_tab_enabled: true,
user_is_privileged: true,
});
});
});
it('should display a learner\'s list.', async () => {
await setUpLearnerMockResponse();
await assignPrivilages();
await waitFor(() => {
renderComponent();
});
const learners = await container.querySelectorAll('.discussion-post');
const firstLearner = learners.item(0);
const learnerAvatar = firstLearner.querySelector('[alt=learner-1]');
const learnerTitle = within(firstLearner).queryByText('learner-1');
const stats = firstLearner.querySelectorAll('.icon-size');
expect(learners).toHaveLength(3);
expect(learnerAvatar).toBeInTheDocument();
expect(learnerTitle).toBeInTheDocument();
expect(stats).toHaveLength(2);
});
it.each([
{
searchText: 'hello world',
output: 'Showing 0 results for',
learnersCount: 0,
username: [],
},
{
searchText: 'learner',
output: 'Showing 2 results for',
learnersCount: 2,
username:
['learner-1', 'learner-2'],
},
])('should have a search bar with a clear button and \'$output\' results found text.',
async ({
searchText, output, learnersCount, username,
}) => {
await setUpLearnerMockResponse();
await assignPrivilages();
await renderComponent();
const searchField = within(container).getByPlaceholderText('Search learners');
const searchButton = within(container).getByTestId('search-icon');
await fireEvent.change(searchField, { target: { value: searchText } });
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/settings`).reply(200, {});
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
await act(async () => {
fireEvent.click(searchButton);
setUpLearnerMockResponse(learnersCount, learnersCount, 1, username, searchText);
});
await waitFor(() => {
const clearButton = within(container).queryByText('Clear results');
const searchMessage = within(container).queryByText(`${output} "${searchText}"`);
const leaners = container.querySelectorAll('.discussion-post') ?? [];
expect(searchMessage).toBeInTheDocument();
expect(clearButton).toBeInTheDocument();
expect(leaners).toHaveLength(learnersCount);
await renderComponent();
});
});
test('When click on the clear button it should move to a list of all learners.', async () => {
await setUpLearnerMockResponse();
await assignPrivilages();
await renderComponent();
const searchField = within(container).getByPlaceholderText('Search learners');
const searchButton = within(container).getByTestId('search-icon');
let clearButton;
await fireEvent.change(searchField, { target: { value: 'learner' } });
await act(async () => {
fireEvent.click(searchButton);
setUpLearnerMockResponse(2, 2, 1, ['learner-1', 'learner-2'], 'learner');
});
await waitFor(() => {
clearButton = within(container).queryByText('Clear results');
});
await act(async () => fireEvent.click(clearButton));
await waitFor(() => {
setUpLearnerMockResponse();
});
const learners = container.querySelectorAll('.discussion-post');
expect(learners).toHaveLength(3);
});
});

View File

@@ -2,7 +2,7 @@ import { Factory } from 'rosie';
Factory.define('learner')
.sequence('id')
.attr('username', ['id'], (id) => `learner-${id}`)
.attr('username', ['id'], (id) => `leaner-${id}`)
.option('activeFlags', null, null)
.attr('active_flags', ['activeFlags'], (activeFlags) => activeFlags)
.attrs({
@@ -13,9 +13,9 @@ Factory.define('learner')
});
Factory.define('learnersResult')
.option('count', null)
.option('page', null)
.option('pageSize', null)
.option('count', null, 3)
.option('page', null, 1)
.option('pageSize', null, 5)
.option('courseId', null, 'course-v1:Test+TestX+Test_Course')
.option('activeFlags', null, 0)
.attr(
@@ -23,8 +23,14 @@ Factory.define('learnersResult')
['courseId', 'count', 'page', 'pageSize'],
(courseId, count, page, pageSize) => {
const numPages = Math.ceil(count / pageSize);
const next = page < numPages ? page + 1 : null;
const prev = page > 1 ? page - 1 : null;
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;
return {
next,
prev,
@@ -59,7 +65,7 @@ Factory.define('learnersResult')
);
Factory.define('learnersProfile')
.option('usernames', null, ['learner-1', 'learner-2', 'learner-3'])
.option('usernames', null, ['leaner-1', 'leaner-2', 'leaner-3'])
.attr('profiles', ['usernames'], (usernames) => {
const profiles = usernames.map((user) => ({
account_privacy: 'private',

View File

@@ -10,8 +10,6 @@ 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.
@@ -20,7 +18,8 @@ export const learnersApiUrl = (courseId) => `${getCoursesApiUrl()}${courseId}/ac
* @returns {Promise<{}>}
*/
export async function getLearners(courseId, params) {
const { data } = await getAuthenticatedHttpClient().get(learnersApiUrl(courseId), { params });
const url = `${getCoursesApiUrl()}${courseId}/activity_stats/`;
const { data } = await getAuthenticatedHttpClient().get(url, { params });
return data;
}
@@ -66,6 +65,8 @@ export async function getUserPosts(courseId, {
countFlagged,
cohort,
} = {}) {
const learnerPostsApiUrl = `${getCoursesApiUrl()}${courseId}/learner/`;
const params = snakeCaseObject({
page,
pageSize,
@@ -80,6 +81,6 @@ export async function getUserPosts(courseId, {
});
const { data } = await getAuthenticatedHttpClient()
.get(learnerPostsApiUrl(courseId), { params });
.get(learnerPostsApiUrl, { params });
return data;
}

View File

@@ -1,79 +0,0 @@
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');
});
});

View File

@@ -1,120 +0,0 @@
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');
});
});

View File

@@ -1,81 +0,0 @@
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);
});
});

View File

@@ -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"
className="text-truncate font-weight-500 font-size-14 text-primary-500 font-style-normal font-family-inter"
>
{learner.username}
</div>

View File

@@ -24,7 +24,7 @@ const ActionItem = ({
<label
htmlFor={id}
className="focus border-bottom-0 d-flex align-items-center w-100 py-2 m-0 font-weight-500 filter-menu"
data-testid={value === selected ? `${value} selected` : null}
data-testid={value === selected ? 'selected' : null}
style={{ cursor: 'pointer' }}
aria-checked={value === selected}
>

View File

@@ -1,52 +0,0 @@
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;
}

View File

@@ -1,143 +0,0 @@
import React, { useContext, useEffect, useState } from 'react';
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 { 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 messages from './messages';
function PostCommentsView({ intl }) {
const [isLoading, submitDispatch] = useDispatchWithState();
const { postId } = useParams();
const thread = usePost(postId);
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);
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 && <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);

View File

@@ -1,104 +0,0 @@
import React, { useCallback, useEffect, 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 { updateUserDiscussionsTourByName } from '../../tours/data';
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));
};
const enableCommentsSortTour = useCallback((enabled) => {
const data = {
enabled,
tourName: 'response_sort',
};
dispatch(updateUserDiscussionsTourByName(data));
}, []);
useEffect(() => {
enableCommentsSortTour(true);
return () => {
enableCommentsSortTour(false);
};
}, []);
return (
<>
<div className="comments-sort d-flex justify-content-end mx-4 mt-2">
<Button
id="comment-sort"
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);

View File

@@ -1,130 +0,0 @@
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);

View File

@@ -1,81 +0,0 @@
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;
}

View File

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

View File

@@ -23,7 +23,7 @@ import NoResults from './NoResults';
import { PostLink } from './post';
function PostsList({
posts, topics, intl, isTopicTab, parentIsLoading,
posts, topics, intl, isTopicTab,
}) {
const dispatch = useDispatch();
const {
@@ -50,7 +50,8 @@ function PostsList({
topicIds,
isFilterChanged,
};
if (showOwnPosts && filters.search === '') {
if (showOwnPosts) {
dispatch(fetchUserPosts(courseId, params));
} else {
dispatch(fetchThreads(courseId, params));
@@ -85,10 +86,10 @@ function PostsList({
return (
<>
{!parentIsLoading && postInstances(pinnedPosts)}
{!parentIsLoading && postInstances(unpinnedPosts)}
{postInstances(pinnedPosts)}
{postInstances(unpinnedPosts)}
{posts?.length === 0 && loadingStatus === RequestStatus.SUCCESSFUL && <NoResults />}
{loadingStatus === RequestStatus.IN_PROGRESS || parentIsLoading ? (
{loadingStatus === RequestStatus.IN_PROGRESS ? (
<div className="d-flex justify-content-center p-4 mx-auto my-auto">
<Spinner animation="border" variant="primary" size="lg" />
</div>
@@ -110,7 +111,6 @@ PostsList.propTypes = {
})),
topics: PropTypes.arrayOf(PropTypes.string),
isTopicTab: PropTypes.bool,
parentIsLoading: PropTypes.bool,
intl: intlShape.isRequired,
};
@@ -118,7 +118,6 @@ PostsList.defaultProps = {
posts: [],
topics: undefined,
isTopicTab: false,
parentIsLoading: undefined,
};
export default injectIntl(PostsList);

View File

@@ -13,7 +13,10 @@ 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';
@@ -37,7 +40,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(enableInContextSidebar ? selectAllThreads : selectTopicThreads(topicIds));
const posts = useSelector(selectTopicThreads(topicIds));
return <PostsList posts={posts} topics={topicIds} />;
}

View File

@@ -160,9 +160,9 @@ describe('PostsView', () => {
test('displays a list of posts in a topic', async () => {
setupStore();
await act(async () => {
await renderComponent({ topicId: 'test-topic-1' });
await renderComponent({ topicId: 'some-topic-1' });
});
expect(screen.getAllByText(/this is thread-\d+ in topic test-topic-1/i)).toHaveLength(Math.ceil(threadCount / 3));
expect(screen.getAllByText(/this is thread-\d+ in topic some-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: ['test-topic-2', 'test-topic-0'],
topics: ['some-topic-2', 'some-topic-0'],
parent: 'test-seq-key',
},
'test-seq-key': { type: 'sequential', topics: ['test-topic-0', 'test-topic-1', 'test-topic-2'] },
'test-seq-key': { type: 'sequential', topics: ['some-topic-0', 'some-topic-1', 'some-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 test-topic-2/i))
expect(screen.queryAllByText(/this is thread-\d+ in topic some-topic-2/i))
.toHaveLength(topicThreadCount);
expect(screen.queryAllByText(/this is thread-\d+ in topic test-topic-0/i))
expect(screen.queryAllByText(/this is thread-\d+ in topic some-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 test-topic-1/i))
.toHaveLength(grouping ? topicThreadCount : 2);
expect(screen.queryAllByText(/this is thread-\d+ in topic some-topic-1/i))
.toHaveLength(grouping ? topicThreadCount : 0);
},
);
});

View File

@@ -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 => `test-topic-${(idx % 3)}`)
.sequence('topic_id', idx => `some-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({

View File

@@ -87,7 +87,6 @@ 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(
@@ -102,7 +101,6 @@ export async function postThread(
anonymous,
anonymousToPeers,
} = {},
enableInContextSidebar = false,
) {
const postData = snakeCaseObject({
courseId,
@@ -114,8 +112,8 @@ export async function postThread(
anonymous,
anonymousToPeers,
groupId: cohort,
enableInContextSidebar,
});
const { data } = await getAuthenticatedHttpClient()
.post(getThreadsApiUrl(), postData);
return data;

View File

@@ -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('test-topic-1');
.toEqual('some-topic-1');
});
test('successfully handles thread creation', async () => {

View File

@@ -204,7 +204,6 @@ export function createNewThread({
anonymous,
anonymousToPeers,
cohort,
enableInContextSidebar,
}) {
return async (dispatch) => {
try {
@@ -224,7 +223,7 @@ export function createNewThread({
following,
anonymous,
anonymousToPeers,
}, enableInContextSidebar);
});
dispatch(postThreadSuccess(camelCaseObject(data)));
} catch (error) {
if (getHttpErrorStatus(error) === 403) {

View File

@@ -42,17 +42,17 @@ function PostActionsBar({
: <Search />
)}
{enableInContextSidebar && (
<h4 className="d-flex flex-grow-1 font-weight-bold font-style my-0 py-10px align-self-center">
<h4 className="d-flex flex-grow-1 font-weight-bold my-0 py-0 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 font-style border-0 line-height-24',
{ 'px-3 py-10px border-0': enableInContextSidebar })}
className={classNames('my-0', { 'p-0': enableInContextSidebar })}
onClick={() => dispatch(showPostEditor())}
size={enableInContextSidebar ? 'md' : 'sm'}
>
@@ -62,17 +62,13 @@ function PostActionsBar({
)}
{enableInContextSidebar && (
<>
<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 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>

View File

@@ -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,11 +66,12 @@ function DiscussionPostType({
'border-primary': selected,
'border-light-400': !selected,
})}
style={{ cursor: 'pointer', width: `${enableInContextSidebar ? '10.021rem' : '14.25rem'}` }}
style={{ cursor: 'pointer', width: '14.25rem' }}
>
<Card.Section className="px-4 py-3 d-flex flex-column align-items-center">
<Card.Section className="py-3 px-10px d-flex flex-column align-items-center">
<span className="text-primary-300 mb-0.5">{icon}</span>
<span className="text-gray-700">{type}</span>
<span className="text-gray-700 mb-0.5">{type}</span>
<span className="x-small text-gray-500">{description}</span>
</Card.Section>
</Card>
</label>
@@ -81,6 +82,7 @@ DiscussionPostType.propTypes = {
value: PropTypes.string.isRequired,
type: PropTypes.string.isRequired,
selected: PropTypes.bool.isRequired,
description: PropTypes.string.isRequired,
icon: PropTypes.element.isRequired,
};
@@ -106,7 +108,7 @@ function PostEditor({
const nonCoursewareIds = useSelector(enableInContext ? inContextCoursewareIds : selectNonCoursewareIds);
const coursewareTopics = useSelector(enableInContext ? inContextCourseware : selectCoursewareTopics);
const cohorts = useSelector(selectCourseCohorts);
const post = useSelector(editExisting ? selectThread(postId) : () => ({}));
const post = useSelector(selectThread(postId));
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
const userIsGroupTa = useSelector(selectUserIsGroupTa);
const settings = useSelector(selectDivisionSettings);
@@ -187,7 +189,6 @@ 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 */
@@ -269,7 +270,7 @@ function PostEditor({
resetForm,
}) => (
<Form className="m-4 card p-4 post-form" onSubmit={handleSubmit}>
<h4 className="mb-4 font-style font-size-16" style={{ lineHeight: '16px' }}>
<h4 className="mb-4" style={{ lineHeight: '16px' }}>
{editExisting
? intl.formatMessage(messages.editPostHeading)
: intl.formatMessage(messages.addPostHeading)}
@@ -443,7 +444,7 @@ function PostEditor({
<PostPreviewPane htmlNode={values.comment} isPost editExisting={editExisting} />
<div className="d-flex flex-row mt-n4 w-75 text-primary font-style">
<div className="d-flex flex-row mt-n4 w-75 text-primary">
{!editExisting && (
<>
<Form.Group>
@@ -460,18 +461,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>
)}
</>
)}
@@ -497,7 +498,7 @@ function PostEditor({
</div>
</Form>
)
}
}
</Formik>
);
}

View File

@@ -141,16 +141,6 @@ 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', () => {

View File

@@ -71,6 +71,7 @@ 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 => (
@@ -137,7 +138,7 @@ function PostFilterBar({
className="filter-bar collapsible-card-lg border-0"
>
<Collapsible.Trigger className="collapsible-trigger border-0">
<span className="text-primary-500 pr-4 font-style">
<span className="text-primary-700 pr-4">
{intl.formatMessage(messages.sortFilterStatus, {
own: false,
type: currentFilters.postType,

View File

@@ -41,7 +41,7 @@ function LikeButton({
iconClassNames="like-icon-dimentions"
/>
</OverlayTrigger>
<div className="font-style">
<div className="font-family-inter font-style-normal">
{(count && count > 0) ? count : null}
</div>

View File

@@ -1,11 +1,10 @@
import React, { useCallback, useContext, useMemo } from 'react';
import React, { useContext, useState } 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';
@@ -34,7 +33,7 @@ function Post({
const history = useHistory();
const dispatch = useDispatch();
const { enableInContextSidebar } = useContext(DiscussionContext);
const courseId = useSelector((state) => state.config.id);
const { courseId } = useSelector((state) => state.courseTabs);
const topic = useSelector(selectTopic(post.topicId));
const getTopicSubsection = useSelector(selectorForUnitSubsection);
const topicContext = useSelector(selectTopicContext(post.topicId));
@@ -42,14 +41,14 @@ function Post({
const [isDeleting, showDeleteConfirmation, hideDeleteConfirmation] = useToggle(false);
const [isReporting, showReportConfirmation, hideReportConfirmation] = useToggle(false);
const [isClosing, showClosePostModal, hideClosePostModal] = useToggle(false);
const handleAbusedFlag = useCallback(() => {
const [showHoverCard, setShowHoverCard] = useState(false);
const handleAbusedFlag = () => {
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));
@@ -65,7 +64,13 @@ function Post({
hideReportConfirmation();
};
const actionHandlers = useMemo(() => ({
const handleBlurEvent = (e) => {
if (!e.currentTarget.contains(e.relatedTarget)) {
setShowHoverCard(false);
}
};
const actionHandlers = {
[ContentActions.EDIT_CONTENT]: () => history.push({
...location,
pathname: `${location.pathname}/edit`,
@@ -83,34 +88,21 @@ 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 post-card-comment"
aria-level={5}
data-testid={`post-${post.id}`}
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
tabIndex="0"
onMouseEnter={() => setShowHoverCard(true)}
onMouseLeave={() => setShowHoverCard(false)}
onFocus={() => setShowHoverCard(true)}
onBlur={(e) => handleBlurEvent(e)}
>
<Confirmation
isOpen={isDeleting}
@@ -131,32 +123,34 @@ 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}
/>
{showHoverCard && (
<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} />
<div className="d-flex mt-14px text-break font-style text-primary-500">
<div className="d-flex mt-14px text-break font-style-normal font-family-inter text-primary-500">
<HTMLLoader htmlNode={post.renderedBody} componentId="post" cssClassName="html-loader" testId={post.id} />
</div>
{(topicContext || topic) && (
{topicContext && topic && (
<div
className={classNames('mt-14px mb-1 font-style font-size-12',
className={classNames('mt-14px mb-1 font-style-normal font-family-inter font-size-12',
{ 'w-100': enableInContextSidebar })}
style={{ lineHeight: '20px' }}
>
<span className="text-gray-500" style={{ lineHeight: '20px' }}>{intl.formatMessage(messages.relatedTo)}{' '}</span>
<Hyperlink
destination={topicContext ? topicContext.unitLink : `${getConfig().BASE_URL}/${courseId}/topics/${post.topicId}`}
destination={topicContext.unitLink}
target="_top"
>
{(topicContext && !topic)
{enableInContextSidebar
? (
<>
<span className="w-auto">{topicContext.chapterName}</span>
@@ -166,7 +160,7 @@ function Post({
<span className="w-auto">{topicContext.unitName}</span>
</>
)
: getTopicInfo(topic)}
: `${getTopicCategoryName(topic)} / ${topic.name}`}
</Hyperlink>
</div>
)}

View File

@@ -6,9 +6,15 @@ import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import {
Icon, IconButton, OverlayTrigger, Tooltip,
} from '@edx/paragon';
import { Locked, People } from '@edx/paragon/icons';
import {
Locked,
} from '@edx/paragon/icons';
import { StarFilled, StarOutline } from '../../../components/icons';
import {
People,
StarFilled,
StarOutline,
} from '../../../components/icons';
import { selectUserHasModerationPrivileges } from '../../data/selectors';
import { updateExistingThread } from '../data/thunks';
import LikeButton from './LikeButton';
@@ -54,22 +60,23 @@ function PostFooter({
)}
<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">
<Icon
src={People}
style={{
width: '22px',
height: '20px',
}}
className="text-gray-500"
/>
<>
<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' }}
>
·
</span>
</OverlayTrigger>
</>
)}
{post.closed
@@ -86,8 +93,8 @@ function PostFooter({
style={{
width: '1rem',
height: '1rem',
marginLeft: '19.5px',
}}
className="ml-3"
/>
</OverlayTrigger>
)}

View File

@@ -108,17 +108,7 @@ function PostHeader({
&& <Badge variant="success">{intl.formatMessage(messages.answered)}</Badge>}
</div>
)
: (
<h5
className="mb-0 font-style text-primary-500"
style={{ lineHeight: '21px' }}
aria-level="1"
tabIndex="-1"
accessKey="h"
>
{post.title}
</h5>
)}
: <h5 className="mb-0 font-style-normal font-family-inter 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}

View File

@@ -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 align-bottom',
{ 'font-weight-bolder': !read })
}
classNames('font-weight-500 font-size-14 text-primary-500 font-style-normal font-family-inter 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 align-bottom"
class="text-gray-700 font-weight-normal font-size-14 font-style-normal font-family-inter align-bottom"
>
{isPostPreviewAvailable(post.previewBody)
? post.previewBody
@@ -107,11 +107,10 @@ 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>

View File

@@ -9,10 +9,16 @@ import {
Badge, Icon, OverlayTrigger, Tooltip,
} from '@edx/paragon';
import {
StarFilled, StarOutline, ThumbUpFilled, ThumbUpOutline,
Locked,
} from '@edx/paragon/icons';
import { People, QuestionAnswer, QuestionAnswerOutline } from '../../../components/icons';
import {
People,
QuestionAnswer,
QuestionAnswerOutline,
StarFilled,
StarOutline, ThumbUpFilled, ThumbUpOutline,
} from '../../../components/icons';
import timeLocale from '../../common/time-locale';
import { selectUserHasModerationPrivileges } from '../../data/selectors';
import messages from './messages';
@@ -26,8 +32,9 @@ function PostSummaryFooter({
}) {
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
timeago.register('time-locale', timeLocale);
return (
<div className="d-flex align-items-center text-gray-700" style={{ height: '24px' }}>
<div className="d-flex align-items-center text-gray-700">
<div className="d-flex align-items-center mr-4.5">
<OverlayTrigger
overlay={(
@@ -36,11 +43,11 @@ function PostSummaryFooter({
</Tooltip>
)}
>
<Icon src={post.voted ? ThumbUpFilled : ThumbUpOutline} className="post-summary-like-dimensions mr-0.5">
<Icon src={post.voted ? ThumbUpFilled : ThumbUpOutline} className="post-summary-icons-dimensions mr-0.5">
<span className="sr-only">{' '}{intl.formatMessage(post.voted ? messages.likedPost : messages.postLikes)}</span>
</Icon>
</OverlayTrigger>
<div className="font-style">
<div className="font-family-inter font-style-normal">
{(post.voteCount && post.voteCount > 0) ? post.voteCount : null}
</div>
</div>
@@ -53,14 +60,12 @@ 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 text-gray-700 font-style font-size-12">
<div className="d-flex align-items-center ml-4.5">
<OverlayTrigger
overlay={(
<Tooltip id={`follow-${post.id}-tooltip`}>
@@ -68,10 +73,7 @@ function PostSummaryFooter({
</Tooltip>
)}
>
<Icon
src={post.unreadCommentCount ? QuestionAnswer : QuestionAnswerOutline}
className="post-summary-comment-count-dimensions mr-0.5"
>
<Icon src={post.unreadCommentCount ? QuestionAnswer : QuestionAnswerOutline} className="post-summary-icons-dimensions mr-0.5">
<span className="sr-only">{' '} {intl.formatMessage(messages.activity)}</span>
</Icon>
</OverlayTrigger>
@@ -85,22 +87,46 @@ 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="mr-2">
<Icon
src={People}
className="text-gray-500 post-summary-icons-dimensions"
/>
<>
<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' }}
>
·
</span>
</OverlayTrigger>
</>
)}
<span title={post.createdAt} className="text-gray-700 post-summary-timestamp ml-0.5">
<span title={post.createdAt} className="text-gray-700 post-summary-timestamp">
{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>
);

View File

@@ -10,13 +10,5 @@ export default function tourCheckpoints(intl) {
title: intl.formatMessage(messages.notRespondedFilterTourTitle),
},
],
RESPONSE_SORT: [
{
body: intl.formatMessage(messages.responseSortTourBody),
placement: 'left',
target: '#comment-sort',
title: intl.formatMessage(messages.responseSortTourTitle),
},
],
};
}

View File

@@ -5,25 +5,29 @@ import { initializeMockApp } from '@edx/frontend-platform/testing';
import { RequestStatus } from '../../../data/constants';
import { initializeStore } from '../../../store';
import { executeThunk } from '../../../test-utils';
import { getDiscussionTourUrl } from './api';
import { selectTours } from './selectors';
import {
discussionsTourRequest,
discussionsToursRequestError,
fetchUserDiscussionsToursSuccess,
updateUserDiscussionsTourByName,
toursReducer,
updateUserDiscussionsTourSuccess,
} from './slices';
import { fetchDiscussionTours, updateTourShowStatus } from './thunks';
import discussionTourFactory from './tours.factory';
const url = getDiscussionTourUrl();
let actualActions;
let mockAxios;
// eslint-disable-next-line no-unused-vars
let store;
const url = getDiscussionTourUrl();
describe('DiscussionToursThunk', () => {
let actualActions;
const dispatch = (action) => {
actualActions.push(action);
};
describe('DiscussionTours data layer', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
@@ -42,147 +46,168 @@ describe('DiscussionTours data layer', () => {
mockAxios.reset();
});
describe('DiscussionToursThunk', () => {
const dispatch = (action) => {
actualActions.push(action);
};
it('dispatches get request, success actions', async () => {
const mockData = discussionTourFactory.buildList(2);
mockAxios.onGet(url)
.reply(200, mockData);
const getExpectedAction = (mockData) => ({
request: {
const expectedActions = [
{
payload: undefined,
type: 'userDiscussionsTours/discussionsTourRequest',
},
fetch: {
{
type: 'userDiscussionsTours/fetchUserDiscussionsToursSuccess',
payload: mockData,
},
update: {
];
await fetchDiscussionTours()(dispatch);
expect(actualActions)
.toEqual(expectedActions);
});
it('dispatches request, and error actions', async () => {
mockAxios.onGet('/api/discussion-tours/')
.reply(500);
const errorAction = [{
payload: undefined,
type: 'userDiscussionsTours/discussionsTourRequest',
}, {
payload: undefined,
type: 'userDiscussionsTours/discussionsToursRequestError',
}];
await fetchDiscussionTours()(dispatch);
expect(actualActions)
.toEqual(errorAction);
});
it('dispatches put request, success actions', async () => {
const mockData = discussionTourFactory.build();
mockAxios.onPut(`${url}${1}`)
.reply(200, mockData);
const expectedActions = [
{
payload: undefined,
type: 'userDiscussionsTours/discussionsTourRequest',
},
{
type: 'userDiscussionsTours/updateUserDiscussionsTourSuccess',
payload: mockData,
},
error: {
payload: undefined,
type: 'userDiscussionsTours/discussionsToursRequestError',
},
});
it('dispatches get request, success actions', async () => {
const mockData = discussionTourFactory.buildList(2);
mockAxios.onGet(url).reply(200, mockData);
const expectedActions = [getExpectedAction().request, getExpectedAction(mockData).fetch];
await fetchDiscussionTours()(dispatch);
expect(actualActions).toEqual(expectedActions);
});
it('dispatches request, and error actions', async () => {
mockAxios.onGet('/api/discussion-tours/').reply(500);
const expectedActions = [getExpectedAction().request, getExpectedAction().error];
await fetchDiscussionTours()(dispatch);
expect(actualActions).toEqual(expectedActions);
});
it('dispatches put request, success actions', async () => {
const mockData = discussionTourFactory.build();
mockAxios.onPut(`${url}${1}`).reply(200, mockData);
const expectedActions = [getExpectedAction().request, getExpectedAction(mockData).update];
await updateTourShowStatus(1)(dispatch);
expect(actualActions).toEqual(expectedActions);
});
it('dispatches update request, and error actions', async () => {
mockAxios.onPut(`${url}${1}`).reply(500);
const expectedActions = [getExpectedAction().request, getExpectedAction().error];
await updateTourShowStatus(1)(dispatch);
expect(actualActions).toEqual(expectedActions);
});
];
await updateTourShowStatus(1)(dispatch);
expect(actualActions)
.toEqual(expectedActions);
});
describe('toursReducer', () => {
it('handles the discussionsToursRequest action', async () => {
store.dispatch(discussionsTourRequest());
const { tours } = store.getState();
it('dispatches update request, and error actions', async () => {
mockAxios.onPut(`${url}${1}`)
.reply(500);
const errorAction = [{
payload: undefined,
type: 'userDiscussionsTours/discussionsTourRequest',
}, {
payload: undefined,
type: 'userDiscussionsTours/discussionsToursRequestError',
}];
expect(tours.tours).toEqual([]);
expect(tours.error).toBeNull();
expect(tours.loading).toEqual(RequestStatus.IN_PROGRESS);
});
await updateTourShowStatus(1)(dispatch);
expect(actualActions)
.toEqual(errorAction);
});
});
it('handles the fetchUserDiscussionsToursSuccess action', async () => {
const mockData = [{ id: 1 }, { id: 2 }];
await store.dispatch(fetchUserDiscussionsToursSuccess(mockData));
const { tours } = store.getState();
describe('toursReducer', () => {
it('handles the discussionsToursRequest action', () => {
const initialState = {
tours: [],
loading: false,
error: null,
};
const state = toursReducer(initialState, discussionsTourRequest());
expect(state)
.toEqual({
tours: [],
loading: RequestStatus.IN_PROGRESS,
error: null,
});
});
expect(tours).toEqual({
it('handles the fetchUserDiscussionsToursSuccess action', () => {
const initialState = {
tours: [],
loading: true,
error: null,
};
const mockData = [{ id: 1 }, { id: 2 }];
const state = toursReducer(initialState, fetchUserDiscussionsToursSuccess(mockData));
expect(state)
.toEqual({
tours: mockData,
loading: RequestStatus.SUCCESSFUL,
error: null,
});
});
});
it('handles the updateUserDiscussionsTourSuccess action', async () => {
const updatedTour = { id: 2, name: 'Updated Tour' };
await store.dispatch(fetchUserDiscussionsToursSuccess([{ id: 1 }, { id: 2 }]));
await store.dispatch(updateUserDiscussionsTourSuccess(updatedTour));
const { tours } = store.getState();
it('handles the updateUserDiscussionsTourSuccess action', () => {
const initialState = {
tours: [
{ id: 1 },
{ id: 2 },
],
};
const updatedTour = {
id: 2,
name: 'Updated Tour',
};
const state = toursReducer(initialState, updateUserDiscussionsTourSuccess(updatedTour));
expect(state.tours)
.toEqual([{ id: 1 }, updatedTour]);
});
expect(tours.tours).toEqual([{ id: 1 }, updatedTour]);
});
it('handles the discussionsToursRequestError action', async () => {
const errorMessage = 'Something went wrong';
await store.dispatch(discussionsToursRequestError(errorMessage));
const { tours } = store.getState();
expect(tours).toEqual({
it('handles the discussionsToursRequestError action', () => {
const initialState = {
tours: [],
loading: true,
error: null,
};
const mockError = new Error('Something went wrong');
const state = toursReducer(initialState, discussionsToursRequestError(mockError));
expect(state)
.toEqual({
tours: [],
loading: RequestStatus.FAILED,
error: errorMessage,
error: mockError,
});
});
it('handles the updateUserDiscussionsTourByName action', async () => {
const tourName = 'response_sort';
const updatedTour = {
tourName: 'response_sort',
enabled: false,
};
await mockAxios.onGet(getDiscussionTourUrl(), {}).reply(200, [discussionTourFactory.build({ tourName })]);
await executeThunk(fetchDiscussionTours(), store.dispatch, store.getState);
store.dispatch(updateUserDiscussionsTourByName(updatedTour));
expect(store.getState().tours.tours).toEqual([{
id: 4,
tourName: 'response_sort',
enabled: false,
description: 'This is the description for Discussion Tour 4.',
}]);
});
});
describe('tourSelector', () => {
it('returns the tours list from state', async () => {
await mockAxios.onGet(getDiscussionTourUrl(), {}).reply(200, [
discussionTourFactory.build({ tourName: 'other_filter' }),
]);
await executeThunk(fetchDiscussionTours(), store.dispatch, store.getState);
expect(selectTours(store.getState())).toEqual([{
id: 5,
tourName: 'other_filter',
description: 'This is the description for Discussion Tour 5.',
enabled: true,
}]);
});
it('returns an empty list if the tours state is not defined', async () => {
await executeThunk(fetchDiscussionTours(), store.dispatch, store.getState);
expect(selectTours(store.getState())).toEqual([]);
});
});
});
describe('tourSelector', () => {
it('returns the tours list from state', () => {
const state = {
tours: {
tours: [
{ id: 1, tourName: 'not_responded_filter' },
{ id: 2, tourName: 'other_filter' },
],
},
};
const expectedResult = [
{ id: 1, tourName: 'not_responded_filter' },
{ id: 2, tourName: 'other_filter' },
];
expect(selectTours(state)).toEqual(expectedResult);
});
it('returns an empty list if the tours state is not defined', () => {
const state = {
tours: {
tours: [],
},
};
expect(selectTours(state))
.toEqual([]);
});
});

View File

@@ -31,12 +31,6 @@ const userDiscussionsToursSlice = createSlice({
state.loading = RequestStatus.SUCCESSFUL;
state.error = null;
},
updateUserDiscussionsTourByName: (state, action) => {
const tourIndex = state.tours.findIndex(tour => tour.tourName === action.payload.tourName);
state.tours[tourIndex] = { ...state.tours[tourIndex], ...action.payload };
state.loading = RequestStatus.SUCCESSFUL;
state.error = null;
},
},
});
@@ -45,7 +39,6 @@ export const {
fetchUserDiscussionsToursSuccess,
discussionsToursRequestError,
updateUserDiscussionsTourSuccess,
updateUserDiscussionsTourByName,
} = userDiscussionsToursSlice.actions;
export const toursReducer = userDiscussionsToursSlice.reducer;

View File

@@ -9,10 +9,6 @@ import {
updateUserDiscussionsTourSuccess,
} from './slices';
function normaliseTourData(data) {
return data.map(tour => ({ ...tour, enabled: true }));
}
/**
* Action thunk to fetch the list of discussion tours for the current user.
* @returns {function} - Thunk that dispatches the request, success, and error actions.
@@ -22,7 +18,7 @@ export function fetchDiscussionTours() {
try {
dispatch(discussionsTourRequest());
const data = await getDiscssionTours();
dispatch(fetchUserDiscussionsToursSuccess(camelCaseObject(normaliseTourData(data))));
dispatch(fetchUserDiscussionsToursSuccess(camelCaseObject(data)));
} catch (error) {
dispatch(discussionsToursRequestError());
logError(error);

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