Compare commits
11 Commits
dependabot
...
inf-706
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71d04a5353 | ||
|
|
d2b2a2aff9 | ||
|
|
569ce49801 | ||
|
|
c67bc3e080 | ||
|
|
bcde4f5f87 | ||
|
|
eaa3ce16ea | ||
|
|
af5bc1a664 | ||
|
|
2fa0900a65 | ||
|
|
afbd894154 | ||
|
|
bfcb1282f0 | ||
|
|
f081e8dc77 |
44
README.rst
44
README.rst
@@ -1,16 +1,15 @@
|
|||||||
|Build Status| |Codecov| |license|
|
|
||||||
|
|
||||||
frontend-app-discussions
|
frontend-app-discussions
|
||||||
========================
|
========================
|
||||||
|
|
||||||
Please tag **@edx/fedx-team** on any PRs or issues. Thanks.
|
|Build Status| |Codecov| |license|
|
||||||
|
|
||||||
Introduction
|
Purpose
|
||||||
------------
|
-------
|
||||||
|
|
||||||
This repository is a React-based micro frontend for the Open edX discussion forums.
|
This repository is a React-based micro frontend for the Open edX discussion forums.
|
||||||
|
|
||||||
**Installation and Startup**
|
Getting Started
|
||||||
|
---------------
|
||||||
|
|
||||||
1. Clone your new repo:
|
1. Clone your new repo:
|
||||||
|
|
||||||
@@ -26,6 +25,39 @@ This repository is a React-based micro frontend for the Open edX discussion foru
|
|||||||
|
|
||||||
The dev server is running at `http://localhost:2002 <http://localhost:2002>`_.
|
The dev server is running at `http://localhost:2002 <http://localhost:2002>`_.
|
||||||
|
|
||||||
|
Getting Help
|
||||||
|
------------
|
||||||
|
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.
|
||||||
|
|
||||||
|
https://github.com/openedx/frontend-app-discussions/issues
|
||||||
|
|
||||||
|
For more information about these options, see the `Getting Help`_ page.
|
||||||
|
|
||||||
|
.. _Getting Help: https://openedx.org/getting-help
|
||||||
|
|
||||||
|
How to Contribute
|
||||||
|
-----------------
|
||||||
|
Details about how to become a contributor to the Open edX project may be found in the wiki at `How to contribute`_
|
||||||
|
|
||||||
|
.. _How to contribute: https://openedx.org/r/how-to-contribute
|
||||||
|
|
||||||
|
|
||||||
|
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 groked from inspecting catalog-info.yaml.
|
||||||
|
|
||||||
|
Reporting Security Issues
|
||||||
|
-------------------------
|
||||||
|
Please do not report security issues in public. Please email security@edx.org.
|
||||||
|
|
||||||
Project Structure
|
Project Structure
|
||||||
-----------------
|
-----------------
|
||||||
|
|
||||||
|
|||||||
38
catalog-info.yaml
Normal file
38
catalog-info.yaml
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# This file records information about this repo. Its use is described in OEP-55:
|
||||||
|
# https://open-edx-proposals.readthedocs.io/en/latest/processes/oep-0055-proc-project-maintainers.html
|
||||||
|
|
||||||
|
apiVersion: backstage.io/v1alpha1
|
||||||
|
# (Required) Acceptable Values: Component, Resource, System
|
||||||
|
# A repo will almost certainly be a Component.
|
||||||
|
kind: Component
|
||||||
|
metadata:
|
||||||
|
name: 'frontend-app-discussions'
|
||||||
|
description: "The discussion forum for openEdx discussions"
|
||||||
|
links:
|
||||||
|
- url: "https://github.com/openedx/frontend-app-discussions"
|
||||||
|
title: "Frontend app discussions"
|
||||||
|
# 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:
|
||||||
|
|
||||||
|
# (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'
|
||||||
|
|
||||||
|
# (Required) Acceptable Lifecycle Values: experimental, production, deprecated
|
||||||
|
lifecycle: 'production'
|
||||||
@@ -10,14 +10,17 @@ const defaultSanitizeOptions = {
|
|||||||
ADD_ATTR: ['columnalign'],
|
ADD_ATTR: ['columnalign'],
|
||||||
};
|
};
|
||||||
|
|
||||||
function HTMLLoader({ htmlNode, componentId, cssClassName }) {
|
function HTMLLoader({
|
||||||
|
htmlNode, componentId, cssClassName, testId,
|
||||||
|
}) {
|
||||||
const sanitizedMath = DOMPurify.sanitize(htmlNode, { ...defaultSanitizeOptions });
|
const sanitizedMath = DOMPurify.sanitize(htmlNode, { ...defaultSanitizeOptions });
|
||||||
const previewRef = useRef();
|
const previewRef = useRef();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let promise = Promise.resolve(); // Used to hold chain of typesetting calls
|
let promise = Promise.resolve(); // Used to hold chain of typesetting calls
|
||||||
|
|
||||||
function typeset(code) {
|
function typeset(code) {
|
||||||
promise = promise.then(() => window.MathJax.typesetPromise(code()))
|
promise = promise.then(() => window.MathJax?.typesetPromise(code()))
|
||||||
.catch((err) => logError(`Typeset failed: ${err.message}`));
|
.catch((err) => logError(`Typeset failed: ${err.message}`));
|
||||||
return promise;
|
return promise;
|
||||||
}
|
}
|
||||||
@@ -25,10 +28,10 @@ function HTMLLoader({ htmlNode, componentId, cssClassName }) {
|
|||||||
typeset(() => {
|
typeset(() => {
|
||||||
previewRef.current.innerHTML = sanitizedMath;
|
previewRef.current.innerHTML = sanitizedMath;
|
||||||
});
|
});
|
||||||
}, [sanitizedMath]);
|
}, [htmlNode]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={previewRef} className={cssClassName} id={componentId} />
|
<div ref={previewRef} className={cssClassName} id={componentId} data-testid={testId} />
|
||||||
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -37,12 +40,14 @@ HTMLLoader.propTypes = {
|
|||||||
htmlNode: PropTypes.node,
|
htmlNode: PropTypes.node,
|
||||||
componentId: PropTypes.string,
|
componentId: PropTypes.string,
|
||||||
cssClassName: PropTypes.string,
|
cssClassName: PropTypes.string,
|
||||||
|
testId: PropTypes.string,
|
||||||
};
|
};
|
||||||
|
|
||||||
HTMLLoader.defaultProps = {
|
HTMLLoader.defaultProps = {
|
||||||
htmlNode: '',
|
htmlNode: '',
|
||||||
componentId: null,
|
componentId: null,
|
||||||
cssClassName: '',
|
cssClassName: '',
|
||||||
|
testId: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default HTMLLoader;
|
export default HTMLLoader;
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ function PostPreviewPane({
|
|||||||
className="float-right p-3"
|
className="float-right p-3"
|
||||||
iconClassNames="icon-size"
|
iconClassNames="icon-size"
|
||||||
/>
|
/>
|
||||||
<HTMLLoader htmlNode={htmlNode} cssClassName="text-primary" componentId="post-preview" />
|
<HTMLLoader htmlNode={htmlNode} cssClassName="text-primary" componentId="post-preview" testId="post-preview" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="d-flex justify-content-end">
|
<div className="d-flex justify-content-end">
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import React, { useContext, useEffect, useMemo } from 'react';
|
import React, {
|
||||||
|
useContext, useEffect, useMemo, useState,
|
||||||
|
} from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
@@ -8,7 +10,8 @@ import { useHistory, useLocation } from 'react-router-dom';
|
|||||||
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||||
import {
|
import {
|
||||||
Button, Icon, IconButton, Spinner,
|
Button, Icon, IconButton,
|
||||||
|
Spinner,
|
||||||
} from '@edx/paragon';
|
} from '@edx/paragon';
|
||||||
import { ArrowBack } from '@edx/paragon/icons';
|
import { ArrowBack } from '@edx/paragon/icons';
|
||||||
|
|
||||||
@@ -17,12 +20,12 @@ import {
|
|||||||
} from '../../data/constants';
|
} from '../../data/constants';
|
||||||
import { useDispatchWithState } from '../../data/hooks';
|
import { useDispatchWithState } from '../../data/hooks';
|
||||||
import { DiscussionContext } from '../common/context';
|
import { DiscussionContext } from '../common/context';
|
||||||
import { useIsOnDesktop } from '../data/hooks';
|
import { useIsOnDesktop, useUserCanAddThreadInBlackoutDate } from '../data/hooks';
|
||||||
import { EmptyPage } from '../empty-posts';
|
import { EmptyPage } from '../empty-posts';
|
||||||
import { Post } from '../posts';
|
import { Post } from '../posts';
|
||||||
import { selectThread } from '../posts/data/selectors';
|
import { selectThread } from '../posts/data/selectors';
|
||||||
import { fetchThread, markThreadAsRead } from '../posts/data/thunks';
|
import { fetchThread, markThreadAsRead } from '../posts/data/thunks';
|
||||||
import { discussionsPath, filterPosts } from '../utils';
|
import { discussionsPath, filterPosts, isLastElementOfList } from '../utils';
|
||||||
import { selectThreadComments, selectThreadCurrentPage, selectThreadHasMorePages } from './data/selectors';
|
import { selectThreadComments, selectThreadCurrentPage, selectThreadHasMorePages } from './data/selectors';
|
||||||
import { fetchThreadComments } from './data/thunks';
|
import { fetchThreadComments } from './data/thunks';
|
||||||
import { Comment, ResponseEditor } from './comment';
|
import { Comment, ResponseEditor } from './comment';
|
||||||
@@ -80,26 +83,41 @@ function DiscussionCommentsView({
|
|||||||
|
|
||||||
const endorsedComments = useMemo(() => [...filterPosts(comments, 'endorsed')], [comments]);
|
const endorsedComments = useMemo(() => [...filterPosts(comments, 'endorsed')], [comments]);
|
||||||
const unEndorsedComments = useMemo(() => [...filterPosts(comments, 'unendorsed')], [comments]);
|
const unEndorsedComments = useMemo(() => [...filterPosts(comments, 'unendorsed')], [comments]);
|
||||||
|
const userCanAddThreadInBlackoutDate = useUserCanAddThreadInBlackoutDate();
|
||||||
|
const [addingResponse, setAddingResponse] = useState(false);
|
||||||
|
|
||||||
const handleDefinition = (message, commentsLength) => (
|
const handleDefinition = (message, commentsLength) => (
|
||||||
<div className="mx-4 text-primary-700" role="heading" aria-level="2" style={{ lineHeight: '28px' }}>
|
<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 })}
|
{intl.formatMessage(message, { num: commentsLength })}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleComments = (postComments, showAddResponse = false, showLoadMoreResponses = false) => (
|
const handleComments = (postComments, showLoadMoreResponses = false) => (
|
||||||
<div className="mx-4" role="list">
|
<div className="mx-4" role="list">
|
||||||
{postComments.map(comment => (
|
{postComments.map((comment) => (
|
||||||
<Comment comment={comment} key={comment.id} postType={postType} isClosedPost={isClosed} />
|
<Comment
|
||||||
|
comment={comment}
|
||||||
|
key={comment.id}
|
||||||
|
postType={postType}
|
||||||
|
isClosedPost={isClosed}
|
||||||
|
marginBottom={isLastElementOfList(postComments, comment)}
|
||||||
|
/>
|
||||||
|
|
||||||
))}
|
))}
|
||||||
{hasMorePages && !isLoading && !showLoadMoreResponses && (
|
{hasMorePages && !isLoading && !showLoadMoreResponses && (
|
||||||
<Button
|
<Button
|
||||||
onClick={handleLoadMoreResponses}
|
onClick={handleLoadMoreResponses}
|
||||||
variant="link"
|
variant="link"
|
||||||
block="true"
|
block="true"
|
||||||
className="card p-4 mb-4 font-weight-500 font-size-14"
|
className="px-4 mt-3 py-0 mb-2 font-style-normal font-family-inter font-weight-500 font-size-14"
|
||||||
style={{
|
style={{
|
||||||
lineHeight: '20px',
|
lineHeight: '24px',
|
||||||
|
border: '0px',
|
||||||
}}
|
}}
|
||||||
data-testid="load-more-comments"
|
data-testid="load-more-comments"
|
||||||
>
|
>
|
||||||
@@ -107,12 +125,10 @@ function DiscussionCommentsView({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{isLoading && !showLoadMoreResponses && (
|
{isLoading && !showLoadMoreResponses && (
|
||||||
<div className="card my-4 p-4 d-flex align-items-center">
|
<div className="mb-2 mt-3 d-flex justify-content-center">
|
||||||
<Spinner animation="border" variant="primary" />
|
<Spinner animation="border" variant="primary" className="spinner-dimentions" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!!postComments.length && !isClosed && showAddResponse
|
|
||||||
&& <ResponseEditor postId={postId} addWrappingDiv />}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
@@ -123,15 +139,40 @@ function DiscussionCommentsView({
|
|||||||
<>
|
<>
|
||||||
{handleDefinition(messages.endorsedResponseCount, endorsedComments.length)}
|
{handleDefinition(messages.endorsedResponseCount, endorsedComments.length)}
|
||||||
{endorsed === EndorsementStatus.DISCUSSION
|
{endorsed === EndorsementStatus.DISCUSSION
|
||||||
? handleComments(endorsedComments, false, true)
|
? handleComments(endorsedComments, true)
|
||||||
: handleComments(endorsedComments)}
|
: handleComments(endorsedComments, false)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{endorsed !== EndorsementStatus.ENDORSED && (
|
{endorsed !== EndorsementStatus.ENDORSED && (
|
||||||
<>
|
<>
|
||||||
{handleDefinition(messages.responseCount, unEndorsedComments.length)}
|
{handleDefinition(messages.responseCount, unEndorsedComments.length)}
|
||||||
{unEndorsedComments.length === 0 && <br />}
|
{unEndorsedComments.length === 0 && <br />}
|
||||||
{handleComments(unEndorsedComments, true)}
|
{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>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -158,12 +199,14 @@ function CommentsView({ intl }) {
|
|||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const isOnDesktop = useIsOnDesktop();
|
const isOnDesktop = useIsOnDesktop();
|
||||||
|
const [addingResponse, setAddingResponse] = useState(false);
|
||||||
const {
|
const {
|
||||||
courseId, learnerUsername, category, topicId, page, enableInContextSidebar,
|
courseId, learnerUsername, category, topicId, page, enableInContextSidebar,
|
||||||
} = useContext(DiscussionContext);
|
} = useContext(DiscussionContext);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!thread) { submitDispatch(fetchThread(postId, courseId, true)); }
|
if (!thread) { submitDispatch(fetchThread(postId, courseId, true)); }
|
||||||
|
setAddingResponse(false);
|
||||||
}, [postId]);
|
}, [postId]);
|
||||||
|
|
||||||
if (!thread) {
|
if (!thread) {
|
||||||
@@ -173,7 +216,13 @@ function CommentsView({ intl }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Spinner animation="border" variant="primary" data-testid="loading-indicator" />
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '50%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Spinner animation="border" variant="primary" data-testid="loading-indicator" />
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,41 +260,52 @@ function CommentsView({ intl }) {
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
<div className={classNames('discussion-comments d-flex flex-column card', {
|
<div
|
||||||
'm-4 p-4.5': !enableInContextSidebar,
|
className={classNames('discussion-comments d-flex flex-column card border-0', {
|
||||||
'p-4 rounded-0 border-0 mb-4': enableInContextSidebar,
|
'post-card-margin post-card-padding': !enableInContextSidebar,
|
||||||
})}
|
'post-card-padding rounded-0 border-0 mb-4': enableInContextSidebar,
|
||||||
|
})}
|
||||||
>
|
>
|
||||||
<Post post={thread} />
|
<Post post={thread} handleAddResponseButton={() => setAddingResponse(true)} />
|
||||||
{!thread.closed && <ResponseEditor postId={postId} />}
|
{!thread.closed && (
|
||||||
|
<ResponseEditor
|
||||||
|
postId={postId}
|
||||||
|
handleCloseEditor={() => setAddingResponse(false)}
|
||||||
|
addingResponse={addingResponse}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{thread.type === ThreadType.DISCUSSION && (
|
{
|
||||||
<DiscussionCommentsView
|
thread.type === ThreadType.DISCUSSION && (
|
||||||
postId={postId}
|
|
||||||
intl={intl}
|
|
||||||
postType={thread.type}
|
|
||||||
endorsed={EndorsementStatus.DISCUSSION}
|
|
||||||
isClosed={thread.closed}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{thread.type === ThreadType.QUESTION && (
|
|
||||||
<>
|
|
||||||
<DiscussionCommentsView
|
<DiscussionCommentsView
|
||||||
postId={postId}
|
postId={postId}
|
||||||
intl={intl}
|
intl={intl}
|
||||||
postType={thread.type}
|
postType={thread.type}
|
||||||
endorsed={EndorsementStatus.ENDORSED}
|
endorsed={EndorsementStatus.DISCUSSION}
|
||||||
isClosed={thread.closed}
|
isClosed={thread.closed}
|
||||||
/>
|
/>
|
||||||
<DiscussionCommentsView
|
)
|
||||||
postId={postId}
|
}
|
||||||
intl={intl}
|
{
|
||||||
postType={thread.type}
|
thread.type === ThreadType.QUESTION && (
|
||||||
endorsed={EndorsementStatus.UNENDORSED}
|
<>
|
||||||
isClosed={thread.closed}
|
<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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
act, fireEvent, render, screen, waitFor, /* within, */
|
act, fireEvent, render, screen, waitFor, within,
|
||||||
} from '@testing-library/react';
|
} from '@testing-library/react';
|
||||||
import MockAdapter from 'axios-mock-adapter';
|
import MockAdapter from 'axios-mock-adapter';
|
||||||
import { IntlProvider } from 'react-intl';
|
import { IntlProvider } from 'react-intl';
|
||||||
@@ -32,7 +32,7 @@ const closedPostId = 'thread-2';
|
|||||||
const courseId = 'course-v1:edX+TestX+Test_Course';
|
const courseId = 'course-v1:edX+TestX+Test_Course';
|
||||||
let store;
|
let store;
|
||||||
let axiosMock;
|
let axiosMock;
|
||||||
// let testLocation;
|
let testLocation;
|
||||||
|
|
||||||
function mockAxiosReturnPagedComments() {
|
function mockAxiosReturnPagedComments() {
|
||||||
[null, false, true].forEach(endorsed => {
|
[null, false, true].forEach(endorsed => {
|
||||||
@@ -92,7 +92,10 @@ function renderComponent(postId) {
|
|||||||
<DiscussionContent />
|
<DiscussionContent />
|
||||||
<Route
|
<Route
|
||||||
path="*"
|
path="*"
|
||||||
render={() => null}
|
render={({ location }) => {
|
||||||
|
testLocation = location;
|
||||||
|
return null;
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</MemoryRouter>
|
</MemoryRouter>
|
||||||
</DiscussionContext.Provider>
|
</DiscussionContext.Provider>
|
||||||
@@ -157,104 +160,112 @@ describe('CommentsView', () => {
|
|||||||
expect(JSON.parse(axiosMock.history.patch[axiosMock.history.patch.length - 1].data)).toMatchObject(data);
|
expect(JSON.parse(axiosMock.history.patch[axiosMock.history.patch.length - 1].data)).toMatchObject(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
// it('should show and hide the editor', async () => {
|
it('should show and hide the editor', async () => {
|
||||||
// renderComponent(discussionPostId);
|
renderComponent(discussionPostId);
|
||||||
// await waitFor(() => screen.findByText('comment number 1', { exact: false }));
|
await screen.findByTestId('thread-1');
|
||||||
// const addResponseButtons = screen.getAllByRole('button', { name: /add a response/i });
|
const addResponseButtons = screen.getAllByRole('button', { name: /add a response/i });
|
||||||
// await act(async () => {
|
await act(async () => {
|
||||||
// fireEvent.click(
|
fireEvent.click(
|
||||||
// addResponseButtons[0],
|
addResponseButtons[0],
|
||||||
// );
|
);
|
||||||
// });
|
});
|
||||||
// expect(screen.queryByTestId('tinymce-editor')).toBeInTheDocument();
|
expect(screen.queryByTestId('tinymce-editor')).toBeInTheDocument();
|
||||||
// await act(async () => {
|
await act(async () => {
|
||||||
// fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
|
fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
|
||||||
// });
|
});
|
||||||
// expect(screen.queryByTestId('tinymce-editor')).not.toBeInTheDocument();
|
expect(screen.queryByTestId('tinymce-editor')).not.toBeInTheDocument();
|
||||||
// });
|
});
|
||||||
|
|
||||||
// it('should allow posting a response', async () => {
|
it('should allow posting a response', async () => {
|
||||||
// renderComponent(discussionPostId);
|
renderComponent(discussionPostId);
|
||||||
// await waitFor(() => screen.findByText('comment number 1', { exact: false }));
|
await screen.findByTestId('thread-1');
|
||||||
// const responseButtons = screen.getAllByRole('button', { name: /add a response/i });
|
const responseButtons = screen.getAllByRole('button', { name: /add a response/i });
|
||||||
// await act(async () => {
|
await act(async () => {
|
||||||
// fireEvent.click(
|
fireEvent.click(
|
||||||
// responseButtons[0],
|
responseButtons[0],
|
||||||
// );
|
);
|
||||||
// });
|
});
|
||||||
// await act(() => {
|
await act(() => {
|
||||||
// fireEvent.change(screen.getByTestId('tinymce-editor'), { target: { value: 'testing123' } });
|
fireEvent.change(screen.getByTestId('tinymce-editor'), { target: { value: 'testing123' } });
|
||||||
// });
|
});
|
||||||
//
|
|
||||||
// await act(async () => {
|
await act(async () => {
|
||||||
// fireEvent.click(
|
fireEvent.click(
|
||||||
// screen.getByText(/submit/i),
|
screen.getByText(/submit/i),
|
||||||
// );
|
);
|
||||||
// });
|
});
|
||||||
// expect(screen.queryByTestId('tinymce-editor')).not.toBeInTheDocument();
|
expect(screen.queryByTestId('tinymce-editor')).not.toBeInTheDocument();
|
||||||
// await waitFor(async () => expect(await screen.findByText('testing123', { exact: false })).toBeInTheDocument());
|
await waitFor(async () => expect(await screen.findByTestId('comment-1')).toBeInTheDocument());
|
||||||
// });
|
});
|
||||||
|
|
||||||
it('should not allow posting a response on a closed post', async () => {
|
it('should not allow posting a response on a closed post', async () => {
|
||||||
renderComponent(closedPostId);
|
renderComponent(closedPostId);
|
||||||
await waitFor(() => screen.findByText('Thread-2', { exact: false }));
|
await act(async () => {
|
||||||
expect(screen.queryByRole('button', { name: /add a response/i })).not.toBeInTheDocument();
|
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 () => {
|
it('should allow posting a comment', async () => {
|
||||||
// renderComponent(discussionPostId);
|
renderComponent(discussionPostId);
|
||||||
// await waitFor(() => screen.findByText('comment number 1', { exact: false }));
|
await act(async () => {
|
||||||
// await act(async () => {
|
fireEvent.mouseOver(await waitFor(() => screen.findByTestId('comment-1')));
|
||||||
// fireEvent.click(
|
});
|
||||||
// screen.getAllByRole('button', { name: /add a comment/i })[0],
|
await act(async () => {
|
||||||
// );
|
fireEvent.click(
|
||||||
// });
|
screen.getAllByRole('button', { name: /add comment/i })[0],
|
||||||
// act(() => {
|
);
|
||||||
// fireEvent.change(screen.getByTestId('tinymce-editor'), { target: { value: 'testing123' } });
|
});
|
||||||
// });
|
act(() => {
|
||||||
//
|
fireEvent.change(screen.getByTestId('tinymce-editor'), { target: { value: 'testing123' } });
|
||||||
// await act(async () => {
|
});
|
||||||
// fireEvent.click(
|
|
||||||
// screen.getByText(/submit/i),
|
await act(async () => {
|
||||||
// );
|
fireEvent.click(
|
||||||
// });
|
screen.getByText(/submit/i),
|
||||||
// expect(screen.queryByTestId('tinymce-editor')).not.toBeInTheDocument();
|
);
|
||||||
// await waitFor(async () => expect(await screen.findByText('testing123', { exact: false })).toBeInTheDocument());
|
});
|
||||||
// });
|
expect(screen.queryByTestId('tinymce-editor')).not.toBeInTheDocument();
|
||||||
|
await waitFor(async () => expect(await screen.findByTestId('comment-comment-1')).toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
it('should not allow posting a comment on a closed post', async () => {
|
it('should not allow posting a comment on a closed post', async () => {
|
||||||
renderComponent(closedPostId);
|
renderComponent(closedPostId);
|
||||||
await waitFor(() => screen.findByText('thread-2', { exact: false }));
|
await screen.findByTestId('thread-2');
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
expect(
|
fireEvent.mouseOver(await waitFor(() => screen.findByTestId('comment-3')));
|
||||||
screen.queryByRole('button', { name: /add a comment/i }),
|
});
|
||||||
).not.toBeInTheDocument();
|
|
||||||
|
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);
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.mouseOver(await waitFor(() => screen.findByTestId('comment-1')));
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(
|
||||||
|
// The first edit menu is for the post, the second will be for the first comment.
|
||||||
|
screen.getAllByRole('button', { name: /actions menu/i })[1],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
|
||||||
|
});
|
||||||
|
act(() => {
|
||||||
|
fireEvent.change(screen.getByTestId('tinymce-editor'), { target: { value: 'testing123' } });
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /submit/i }));
|
||||||
|
});
|
||||||
|
await waitFor(async () => {
|
||||||
|
expect(await screen.findByTestId('comment-1')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// it('should allow editing an existing comment', async () => {
|
|
||||||
// renderComponent(discussionPostId);
|
|
||||||
// await waitFor(() => screen.findByText('comment number 1', { exact: false }));
|
|
||||||
// await act(async () => {
|
|
||||||
// fireEvent.click(
|
|
||||||
// // The first edit menu is for the post, the second will be for the first comment.
|
|
||||||
// screen.getAllByRole('button', { name: /actions menu/i })[1],
|
|
||||||
// );
|
|
||||||
// });
|
|
||||||
// await act(async () => {
|
|
||||||
// fireEvent.click(screen.getByRole('button', { name: /edit/i }));
|
|
||||||
// });
|
|
||||||
// act(() => {
|
|
||||||
// fireEvent.change(screen.getByTestId('tinymce-editor'), { target: { value: 'testing123' } });
|
|
||||||
// });
|
|
||||||
// await act(async () => {
|
|
||||||
// fireEvent.click(screen.getByRole('button', { name: /submit/i }));
|
|
||||||
// });
|
|
||||||
// await waitFor(async () => {
|
|
||||||
// expect(await screen.findByText('testing123', { exact: false })).toBeInTheDocument();
|
|
||||||
// });
|
|
||||||
// });
|
|
||||||
//
|
|
||||||
async function setupCourseConfig(reasonCodesEnabled = true) {
|
async function setupCourseConfig(reasonCodesEnabled = true) {
|
||||||
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`).reply(200, {
|
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`).reply(200, {
|
||||||
has_moderation_privileges: true,
|
has_moderation_privileges: true,
|
||||||
@@ -271,89 +282,100 @@ describe('CommentsView', () => {
|
|||||||
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/settings`).reply(200, {});
|
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/settings`).reply(200, {});
|
||||||
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
|
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
|
||||||
}
|
}
|
||||||
//
|
|
||||||
// it('should show reason codes when editing an existing comment', async () => {
|
|
||||||
// setupCourseConfig();
|
|
||||||
// renderComponent(discussionPostId);
|
|
||||||
// await waitFor(() => screen.findByText('comment number 1', { exact: false }));
|
|
||||||
// await act(async () => {
|
|
||||||
// fireEvent.click(
|
|
||||||
// // The first edit menu is for the post, the second will be for the first comment.
|
|
||||||
// screen.getAllByRole('button', { name: /actions menu/i })[1],
|
|
||||||
// );
|
|
||||||
// });
|
|
||||||
// await act(async () => {
|
|
||||||
// fireEvent.click(screen.getByRole('button', { name: /edit/i }));
|
|
||||||
// });
|
|
||||||
// expect(screen.queryByRole('combobox', { name: /reason for editing/i })).toBeInTheDocument();
|
|
||||||
// expect(screen.getAllByRole('option', { name: /reason \d/i })).toHaveLength(2);
|
|
||||||
// await act(async () => {
|
|
||||||
// fireEvent.change(screen.queryByRole('combobox', { name: /reason for editing/i }),
|
|
||||||
// { target: { value: null } });
|
|
||||||
// });
|
|
||||||
// await act(async () => {
|
|
||||||
// fireEvent.change(screen.queryByRole('combobox',
|
|
||||||
// { name: /reason for editing/i }), { target: { value: 'reason-1' } });
|
|
||||||
// });
|
|
||||||
// await act(async () => {
|
|
||||||
// fireEvent.change(screen.getByTestId('tinymce-editor'), { target: { value: 'testing123' } });
|
|
||||||
// });
|
|
||||||
// await act(async () => {
|
|
||||||
// fireEvent.click(screen.getByRole('button', { name: /submit/i }));
|
|
||||||
// });
|
|
||||||
// assertLastUpdateData({ edit_reason_code: 'reason-1' });
|
|
||||||
// });
|
|
||||||
//
|
|
||||||
// it('should show reason codes when closing a post', async () => {
|
|
||||||
// setupCourseConfig();
|
|
||||||
// renderComponent(discussionPostId);
|
|
||||||
// await act(async () => {
|
|
||||||
// fireEvent.click(
|
|
||||||
// // The first edit menu is for the post
|
|
||||||
// screen.getAllByRole('button', {
|
|
||||||
// name: /actions menu/i,
|
|
||||||
// })[0],
|
|
||||||
// );
|
|
||||||
// });
|
|
||||||
// expect(screen.queryByRole('dialog', { name: /close post/i })).not.toBeInTheDocument();
|
|
||||||
// await act(async () => {
|
|
||||||
// fireEvent.click(screen.getByRole('button', { name: /close/i }));
|
|
||||||
// });
|
|
||||||
// expect(screen.queryByRole('dialog', { name: /close post/i })).toBeInTheDocument();
|
|
||||||
// expect(screen.queryByRole('combobox', { name: /reason/i })).toBeInTheDocument();
|
|
||||||
// expect(screen.getAllByRole('option', { name: /reason \d/i })).toHaveLength(2);
|
|
||||||
// await act(async () => {
|
|
||||||
// fireEvent.change(screen.queryByRole('combobox', { name: /reason/i }), { target: { value: 'reason-1' } });
|
|
||||||
// });
|
|
||||||
// await act(async () => {
|
|
||||||
// fireEvent.click(screen.getByRole('button', { name: /close post/i }));
|
|
||||||
// });
|
|
||||||
// expect(screen.queryByRole('dialog', { name: /close post/i })).not.toBeInTheDocument();
|
|
||||||
// assertLastUpdateData({ closed: true, close_reason_code: 'reason-1' });
|
|
||||||
// });
|
|
||||||
|
|
||||||
// it('should close the post directly if reason codes are not enabled', async () => {
|
it('should show reason codes when editing an existing comment', async () => {
|
||||||
// setupCourseConfig(false);
|
setupCourseConfig();
|
||||||
// renderComponent(discussionPostId);
|
renderComponent(discussionPostId);
|
||||||
// await act(async () => {
|
await act(async () => {
|
||||||
// fireEvent.click(
|
fireEvent.mouseOver(await waitFor(() => screen.findByTestId('comment-1')));
|
||||||
// // The first edit menu is for the post
|
});
|
||||||
// screen.getAllByRole('button', { name: /actions menu/i })[0],
|
await act(async () => {
|
||||||
// );
|
fireEvent.click(
|
||||||
// });
|
// The first edit menu is for the post, the second will be for the first comment.
|
||||||
// expect(screen.queryByRole('dialog', { name: /close post/i })).not.toBeInTheDocument();
|
screen.getAllByRole('button', { name: /actions menu/i })[1],
|
||||||
// await act(async () => {
|
);
|
||||||
// fireEvent.click(screen.getByRole('button', { name: /close/i }));
|
});
|
||||||
// });
|
await act(async () => {
|
||||||
// expect(screen.queryByRole('dialog', { name: /close post/i })).not.toBeInTheDocument();
|
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
|
||||||
// assertLastUpdateData({ closed: true });
|
});
|
||||||
// });
|
expect(screen.queryByRole('combobox', { name: /reason for editing/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByRole('option', { name: /reason \d/i })).toHaveLength(2);
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.change(screen.queryByRole('combobox', { name: /reason for editing/i }),
|
||||||
|
{ target: { value: null } });
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.change(screen.queryByRole('combobox',
|
||||||
|
{ name: /reason for editing/i }), { target: { value: 'reason-1' } });
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.change(screen.getByTestId('tinymce-editor'), { target: { value: 'testing123' } });
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /submit/i }));
|
||||||
|
});
|
||||||
|
assertLastUpdateData({ edit_reason_code: 'reason-1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show reason codes when closing a post', async () => {
|
||||||
|
setupCourseConfig();
|
||||||
|
renderComponent(discussionPostId);
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.mouseOver(await waitFor(() => screen.findByTestId('post-thread-1')));
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(
|
||||||
|
// The first edit menu is for the post
|
||||||
|
screen.getAllByRole('button', {
|
||||||
|
name: /actions menu/i,
|
||||||
|
})[0],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(screen.queryByRole('dialog', { name: /close post/i })).not.toBeInTheDocument();
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /close/i }));
|
||||||
|
});
|
||||||
|
expect(screen.queryByRole('dialog', { name: /close post/i })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('combobox', { name: /reason/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByRole('option', { name: /reason \d/i })).toHaveLength(2);
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.change(screen.queryByRole('combobox', { name: /reason/i }), { target: { value: 'reason-1' } });
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /close post/i }));
|
||||||
|
});
|
||||||
|
expect(screen.queryByRole('dialog', { name: /close post/i })).not.toBeInTheDocument();
|
||||||
|
assertLastUpdateData({ closed: true, close_reason_code: 'reason-1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should close the post directly if reason codes are not enabled', async () => {
|
||||||
|
setupCourseConfig(false);
|
||||||
|
renderComponent(discussionPostId);
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.mouseOver(await waitFor(() => screen.findByTestId('post-thread-1')));
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(
|
||||||
|
// The first edit menu is for the post
|
||||||
|
screen.getAllByRole('button', { name: /actions menu/i })[0],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(screen.queryByRole('dialog', { name: /close post/i })).not.toBeInTheDocument();
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /close/i }));
|
||||||
|
});
|
||||||
|
expect(screen.queryByRole('dialog', { name: /close post/i })).not.toBeInTheDocument();
|
||||||
|
assertLastUpdateData({ closed: true });
|
||||||
|
});
|
||||||
|
|
||||||
it.each([true, false])(
|
it.each([true, false])(
|
||||||
'should reopen the post directly when reason codes enabled=%s',
|
'should reopen the post directly when reason codes enabled=%s',
|
||||||
async (reasonCodesEnabled) => {
|
async (reasonCodesEnabled) => {
|
||||||
setupCourseConfig(reasonCodesEnabled);
|
setupCourseConfig(reasonCodesEnabled);
|
||||||
renderComponent(closedPostId);
|
renderComponent(closedPostId);
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.mouseOver(await waitFor(() => screen.findByText('Thread-2', { exact: false })));
|
||||||
|
});
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
// The first edit menu is for the post
|
// The first edit menu is for the post
|
||||||
@@ -369,23 +391,29 @@ describe('CommentsView', () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// it('should show the editor if the post is edited', async () => {
|
it('should show the editor if the post is edited', async () => {
|
||||||
// setupCourseConfig(false);
|
setupCourseConfig(false);
|
||||||
// renderComponent(discussionPostId);
|
renderComponent(discussionPostId);
|
||||||
// await act(async () => {
|
await act(async () => {
|
||||||
// fireEvent.click(
|
fireEvent.mouseOver(await waitFor(() => screen.findByTestId('post-thread-1')));
|
||||||
// // The first edit menu is for the post
|
});
|
||||||
// screen.getAllByRole('button', { name: /actions menu/i })[0],
|
await act(async () => {
|
||||||
// );
|
fireEvent.click(
|
||||||
// });
|
// The first edit menu is for the post
|
||||||
// await act(async () => {
|
screen.getAllByRole('button', { name: /actions menu/i })[0],
|
||||||
// fireEvent.click(screen.getByRole('button', { name: /edit/i }));
|
);
|
||||||
// });
|
});
|
||||||
// expect(testLocation.pathname).toBe(`/${courseId}/posts/${discussionPostId}/edit`);
|
await act(async () => {
|
||||||
// });
|
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
|
||||||
|
});
|
||||||
|
expect(testLocation.pathname).toBe(`/${courseId}/posts/${discussionPostId}/edit`);
|
||||||
|
});
|
||||||
|
|
||||||
it('should allow pinning the post', async () => {
|
it('should allow pinning the post', async () => {
|
||||||
renderComponent(discussionPostId);
|
renderComponent(discussionPostId);
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.mouseOver(await waitFor(() => screen.findByTestId('post-thread-1')));
|
||||||
|
});
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
// The first edit menu is for the post
|
// The first edit menu is for the post
|
||||||
@@ -400,6 +428,9 @@ describe('CommentsView', () => {
|
|||||||
|
|
||||||
it('should allow reporting the post', async () => {
|
it('should allow reporting the post', async () => {
|
||||||
renderComponent(discussionPostId);
|
renderComponent(discussionPostId);
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.mouseOver(await waitFor(() => screen.findByTestId('post-thread-1')));
|
||||||
|
});
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
// The first edit menu is for the post
|
// The first edit menu is for the post
|
||||||
@@ -417,314 +448,313 @@ describe('CommentsView', () => {
|
|||||||
assertLastUpdateData({ abuse_flagged: true });
|
assertLastUpdateData({ abuse_flagged: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// it('handles liking a comment', async () => {
|
it('handles liking a comment', async () => {
|
||||||
// renderComponent(discussionPostId);
|
renderComponent(discussionPostId);
|
||||||
//
|
|
||||||
// // Wait for the content to load
|
// Wait for the content to load
|
||||||
// await screen.findByText('comment number 7', { exact: false });
|
await act(async () => {
|
||||||
// const view = screen.getByTestId('comment-comment-1');
|
fireEvent.mouseOver(await waitFor(() => screen.findByTestId('comment-7')));
|
||||||
//
|
});
|
||||||
// const likeButton = within(view).getByRole('button', { name: /like/i });
|
const view = screen.getByTestId('comment-comment-1');
|
||||||
// await act(async () => {
|
|
||||||
// fireEvent.click(likeButton);
|
const likeButton = within(view).getByRole('button', { name: /like/i });
|
||||||
// });
|
await act(async () => {
|
||||||
// expect(axiosMock.history.patch).toHaveLength(2);
|
fireEvent.click(likeButton);
|
||||||
// expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject({ voted: true });
|
});
|
||||||
// });
|
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
|
it('handles endorsing comments', async () => {
|
||||||
// await screen.findByText('comment number 7', { exact: false });
|
renderComponent(discussionPostId);
|
||||||
//
|
// Wait for the content to load
|
||||||
// // There should be three buttons, one for the post, the second for the
|
await act(async () => {
|
||||||
// // comment and the third for a response to that comment
|
fireEvent.mouseOver(await waitFor(() => screen.findByTestId('comment-7')));
|
||||||
// const actionButtons = screen.queryAllByRole('button', { name: /actions menu/i });
|
});
|
||||||
// await act(async () => {
|
|
||||||
// fireEvent.click(actionButtons[1]);
|
await act(async () => {
|
||||||
// });
|
fireEvent.click(screen.getByRole('button', { name: /Endorse/i }));
|
||||||
//
|
});
|
||||||
// await act(async () => {
|
expect(axiosMock.history.patch).toHaveLength(2);
|
||||||
// fireEvent.click(screen.getByRole('button', { name: /Endorse/i }));
|
expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject({ endorsed: true });
|
||||||
// });
|
});
|
||||||
// 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
|
||||||
// it('handles reporting comments', async () => {
|
await act(async () => {
|
||||||
// renderComponent(discussionPostId);
|
fireEvent.mouseOver(await waitFor(() => screen.findByTestId('comment-7')));
|
||||||
// // Wait for the content to load
|
});
|
||||||
// await screen.findByText('comment number 7', { exact: false });
|
const actionButtons = screen.queryAllByRole('button', { name: /actions menu/i });
|
||||||
//
|
await act(async () => {
|
||||||
// // There should be three buttons, one for the post, the second for the
|
fireEvent.click(actionButtons[0]);
|
||||||
// // comment and the third for a response to that comment
|
});
|
||||||
// const actionButtons = screen.queryAllByRole('button', { name: /actions menu/i });
|
|
||||||
// await act(async () => {
|
await act(async () => {
|
||||||
// fireEvent.click(actionButtons[1]);
|
fireEvent.click(screen.getByRole('button', { name: /Report/i }));
|
||||||
// });
|
});
|
||||||
//
|
expect(screen.queryByRole('dialog', { name: /Report \w+/i, exact: false })).toBeInTheDocument();
|
||||||
// await act(async () => {
|
await act(async () => {
|
||||||
// fireEvent.click(screen.getByRole('button', { name: /Report/i }));
|
fireEvent.click(screen.queryByRole('button', { name: /Confirm/i }));
|
||||||
// });
|
});
|
||||||
// expect(screen.queryByRole('dialog', { name: /Report \w+/i, exact: false })).toBeInTheDocument();
|
expect(screen.queryByRole('dialog', { name: /Report \w+/i, exact: false })).not.toBeInTheDocument();
|
||||||
// await act(async () => {
|
expect(axiosMock.history.patch).toHaveLength(2);
|
||||||
// fireEvent.click(screen.queryByRole('button', { name: /Confirm/i }));
|
expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject({ abuse_flagged: true });
|
||||||
// });
|
});
|
||||||
// expect(screen.queryByRole('dialog', { name: /Report \w+/i, exact: false })).not.toBeInTheDocument();
|
});
|
||||||
// expect(axiosMock.history.patch).toHaveLength(2);
|
|
||||||
// expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject({ abuse_flagged: true });
|
describe('for discussion thread', () => {
|
||||||
// });
|
const findLoadMoreCommentsButton = () => screen.findByTestId('load-more-comments');
|
||||||
// });
|
|
||||||
//
|
it('shown post not found when post id does not belong to course', async () => {
|
||||||
// describe('for discussion thread', () => {
|
renderComponent('unloaded-id');
|
||||||
// const findLoadMoreCommentsButton = () => screen.findByTestId('load-more-comments');
|
expect(await screen.findByText('Thread not found', { exact: true }))
|
||||||
//
|
.toBeInTheDocument();
|
||||||
// it('shown post not found when post id does not belong to course', async () => {
|
});
|
||||||
// renderComponent('unloaded-id');
|
|
||||||
// expect(await screen.findByText('Thread not found', { exact: true }))
|
it('initially loads only the first page', async () => {
|
||||||
// .toBeInTheDocument();
|
renderComponent(discussionPostId);
|
||||||
// });
|
expect(await screen.findByTestId('comment-1'))
|
||||||
//
|
.toBeInTheDocument();
|
||||||
// it('initially loads only the first page', async () => {
|
expect(screen.queryByTestId('comment-2'))
|
||||||
// renderComponent(discussionPostId);
|
.not
|
||||||
// expect(await screen.findByText('comment number 1', { exact: false }))
|
.toBeInTheDocument();
|
||||||
// .toBeInTheDocument();
|
});
|
||||||
// expect(screen.queryByText('comment number 2', { exact: false }))
|
|
||||||
// .not
|
it('pressing load more button will load next page of comments', async () => {
|
||||||
// .toBeInTheDocument();
|
renderComponent(discussionPostId);
|
||||||
// });
|
|
||||||
//
|
const loadMoreButton = await findLoadMoreCommentsButton();
|
||||||
// it('pressing load more button will load next page of comments', async () => {
|
fireEvent.click(loadMoreButton);
|
||||||
// renderComponent(discussionPostId);
|
|
||||||
//
|
await screen.findByTestId('comment-1');
|
||||||
// const loadMoreButton = await findLoadMoreCommentsButton();
|
await screen.findByTestId('comment-2');
|
||||||
// fireEvent.click(loadMoreButton);
|
});
|
||||||
//
|
|
||||||
// await screen.findByText('comment number 1', { exact: false });
|
it('newly loaded comments are appended to the old ones', async () => {
|
||||||
// await screen.findByText('comment number 2', { exact: false });
|
renderComponent(discussionPostId);
|
||||||
// });
|
|
||||||
//
|
const loadMoreButton = await findLoadMoreCommentsButton();
|
||||||
// it('newly loaded comments are appended to the old ones', async () => {
|
fireEvent.click(loadMoreButton);
|
||||||
// renderComponent(discussionPostId);
|
|
||||||
//
|
await screen.findByTestId('comment-1');
|
||||||
// const loadMoreButton = await findLoadMoreCommentsButton();
|
// check that comments from the first page are also displayed
|
||||||
// fireEvent.click(loadMoreButton);
|
expect(screen.queryByTestId('comment-2'))
|
||||||
//
|
.toBeInTheDocument();
|
||||||
// await screen.findByText('comment number 1', { exact: false });
|
});
|
||||||
// // check that comments from the first page are also displayed
|
|
||||||
// expect(screen.queryByText('comment number 2', { exact: false }))
|
it('load more button is hidden when no more comments pages to load', async () => {
|
||||||
// .toBeInTheDocument();
|
const totalPages = 2;
|
||||||
// });
|
renderComponent(discussionPostId);
|
||||||
//
|
|
||||||
// it('load more button is hidden when no more comments pages to load', async () => {
|
const loadMoreButton = await findLoadMoreCommentsButton();
|
||||||
// const totalPages = 2;
|
for (let page = 1; page < totalPages; page++) {
|
||||||
// renderComponent(discussionPostId);
|
fireEvent.click(loadMoreButton);
|
||||||
//
|
}
|
||||||
// const loadMoreButton = await findLoadMoreCommentsButton();
|
|
||||||
// for (let page = 1; page < totalPages; page++) {
|
await screen.findByTestId('comment-2');
|
||||||
// fireEvent.click(loadMoreButton);
|
await expect(findLoadMoreCommentsButton())
|
||||||
// }
|
.rejects
|
||||||
//
|
.toThrow();
|
||||||
// await screen.findByText('comment number 2', { exact: false });
|
});
|
||||||
// await expect(findLoadMoreCommentsButton())
|
});
|
||||||
// .rejects
|
|
||||||
// .toThrow();
|
describe('for question thread', () => {
|
||||||
// });
|
const findLoadMoreCommentsButtons = () => screen.findAllByTestId('load-more-comments');
|
||||||
// });
|
|
||||||
//
|
it('initially loads only the first page', async () => {
|
||||||
// describe('for question thread', () => {
|
act(() => renderComponent(questionPostId));
|
||||||
// const findLoadMoreCommentsButtons = () => screen.findAllByTestId('load-more-comments');
|
expect(await screen.findByTestId('comment-3'))
|
||||||
//
|
.toBeInTheDocument();
|
||||||
// it('initially loads only the first page', async () => {
|
expect(await screen.findByTestId('comment-5'))
|
||||||
// act(() => renderComponent(questionPostId));
|
.toBeInTheDocument();
|
||||||
// expect(await screen.findByText('comment number 3', { exact: false }))
|
expect(screen.queryByTestId('comment-4'))
|
||||||
// .toBeInTheDocument();
|
.not
|
||||||
// expect(await screen.findByText('endorsed comment number 5', { exact: false }))
|
.toBeInTheDocument();
|
||||||
// .toBeInTheDocument();
|
});
|
||||||
// expect(screen.queryByText('comment number 4', { exact: false }))
|
|
||||||
// .not
|
it('pressing load more button will load next page of comments', async () => {
|
||||||
// .toBeInTheDocument();
|
act(() => {
|
||||||
// });
|
renderComponent(questionPostId);
|
||||||
//
|
});
|
||||||
// it('pressing load more button will load next page of comments', async () => {
|
|
||||||
// act(() => {
|
const [loadMoreButtonEndorsed, loadMoreButtonUnendorsed] = await findLoadMoreCommentsButtons();
|
||||||
// renderComponent(questionPostId);
|
// Both load more buttons should show
|
||||||
// });
|
expect(await findLoadMoreCommentsButtons()).toHaveLength(2);
|
||||||
//
|
expect(await screen.findByTestId('comment-3'))
|
||||||
// const [loadMoreButtonEndorsed, loadMoreButtonUnendorsed] = await findLoadMoreCommentsButtons();
|
.toBeInTheDocument();
|
||||||
// // Both load more buttons should show
|
expect(await screen.findByTestId('comment-5'))
|
||||||
// expect(await findLoadMoreCommentsButtons()).toHaveLength(2);
|
.toBeInTheDocument();
|
||||||
// expect(await screen.findByText('unendorsed comment number 3', { exact: false }))
|
// Comments from next page should not be loaded yet.
|
||||||
// .toBeInTheDocument();
|
expect(await screen.queryByTestId('comment-6'))
|
||||||
// expect(await screen.findByText('endorsed comment number 5', { exact: false }))
|
.not
|
||||||
// .toBeInTheDocument();
|
.toBeInTheDocument();
|
||||||
// // Comments from next page should not be loaded yet.
|
expect(await screen.queryByTestId('comment-4'))
|
||||||
// expect(await screen.queryByText('endorsed comment number 6', { exact: false }))
|
.not
|
||||||
// .not
|
.toBeInTheDocument();
|
||||||
// .toBeInTheDocument();
|
|
||||||
// expect(await screen.queryByText('unendorsed comment number 4', { exact: false }))
|
await act(async () => {
|
||||||
// .not
|
fireEvent.click(loadMoreButtonEndorsed);
|
||||||
// .toBeInTheDocument();
|
});
|
||||||
//
|
// Endorsed comment from next page should be loaded now.
|
||||||
// await act(async () => {
|
await waitFor(() => expect(screen.queryByTestId('comment-6'))
|
||||||
// fireEvent.click(loadMoreButtonEndorsed);
|
.toBeInTheDocument());
|
||||||
// });
|
// Unendorsed comment from next page should not be loaded yet.
|
||||||
// // Endorsed comment from next page should be loaded now.
|
expect(await screen.queryByTestId('comment-4'))
|
||||||
// await waitFor(() => expect(screen.queryByText('endorsed comment number 6', { exact: false }))
|
.not
|
||||||
// .toBeInTheDocument());
|
.toBeInTheDocument();
|
||||||
// // Unendorsed comment from next page should not be loaded yet.
|
// Now only one load more buttons should show, for unendorsed comments
|
||||||
// expect(await screen.queryByText('unendorsed comment number 4', { exact: false }))
|
expect(await findLoadMoreCommentsButtons()).toHaveLength(1);
|
||||||
// .not
|
await act(async () => {
|
||||||
// .toBeInTheDocument();
|
fireEvent.click(loadMoreButtonUnendorsed);
|
||||||
// // Now only one load more buttons should show, for unendorsed comments
|
});
|
||||||
// expect(await findLoadMoreCommentsButtons()).toHaveLength(1);
|
// Unendorsed comment from next page should be loaded now.
|
||||||
// await act(async () => {
|
await waitFor(() => expect(screen.queryByTestId('comment-4'))
|
||||||
// fireEvent.click(loadMoreButtonUnendorsed);
|
.toBeInTheDocument());
|
||||||
// });
|
await expect(findLoadMoreCommentsButtons()).rejects.toThrow();
|
||||||
// // Unendorsed comment from next page should be loaded now.
|
});
|
||||||
// await waitFor(() => expect(screen.queryByText('unendorsed comment number 4', { exact: false }))
|
});
|
||||||
// .toBeInTheDocument());
|
|
||||||
// await expect(findLoadMoreCommentsButtons()).rejects.toThrow();
|
describe('comments responses', () => {
|
||||||
// });
|
const findLoadMoreCommentsResponsesButton = () => screen.findByTestId('load-more-comments-responses');
|
||||||
// });
|
|
||||||
//
|
it('initially loads only the first page', async () => {
|
||||||
// describe('comments responses', () => {
|
renderComponent(discussionPostId);
|
||||||
// const findLoadMoreCommentsResponsesButton = () => screen.findByTestId('load-more-comments-responses');
|
|
||||||
//
|
await waitFor(() => screen.findByTestId('comment-7'));
|
||||||
// it('initially loads only the first page', async () => {
|
expect(screen.queryByTestId('comment-8')).not.toBeInTheDocument();
|
||||||
// renderComponent(discussionPostId);
|
});
|
||||||
//
|
|
||||||
// await waitFor(() => screen.findByText('comment number 7', { exact: false }));
|
it('pressing load more button will load next page of responses', async () => {
|
||||||
// expect(screen.queryByText('comment number 8', { exact: false })).not.toBeInTheDocument();
|
renderComponent(discussionPostId);
|
||||||
// });
|
|
||||||
//
|
const loadMoreButton = await findLoadMoreCommentsResponsesButton();
|
||||||
// it('pressing load more button will load next page of responses', async () => {
|
await act(async () => {
|
||||||
// renderComponent(discussionPostId);
|
fireEvent.click(loadMoreButton);
|
||||||
//
|
});
|
||||||
// const loadMoreButton = await findLoadMoreCommentsResponsesButton();
|
|
||||||
// await act(async () => {
|
await screen.findByTestId('comment-8');
|
||||||
// fireEvent.click(loadMoreButton);
|
});
|
||||||
// });
|
|
||||||
//
|
it('newly loaded responses are appended to the old ones', async () => {
|
||||||
// await screen.findByText('comment number 8', { exact: false });
|
renderComponent(discussionPostId);
|
||||||
// });
|
|
||||||
//
|
const loadMoreButton = await findLoadMoreCommentsResponsesButton();
|
||||||
// it('newly loaded responses are appended to the old ones', async () => {
|
await act(async () => {
|
||||||
// renderComponent(discussionPostId);
|
fireEvent.click(loadMoreButton);
|
||||||
//
|
});
|
||||||
// const loadMoreButton = await findLoadMoreCommentsResponsesButton();
|
|
||||||
// await act(async () => {
|
await screen.findByTestId('comment-8');
|
||||||
// fireEvent.click(loadMoreButton);
|
// check that comments from the first page are also displayed
|
||||||
// });
|
expect(screen.queryByTestId('comment-7')).toBeInTheDocument();
|
||||||
//
|
});
|
||||||
// await screen.findByText('comment number 8', { exact: false });
|
|
||||||
// // check that comments from the first page are also displayed
|
it('load more button is hidden when no more responses pages to load', async () => {
|
||||||
// expect(screen.queryByText('comment number 7', { exact: false })).toBeInTheDocument();
|
const totalPages = 2;
|
||||||
// });
|
renderComponent(discussionPostId);
|
||||||
//
|
|
||||||
// it('load more button is hidden when no more responses pages to load', async () => {
|
const loadMoreButton = await findLoadMoreCommentsResponsesButton();
|
||||||
// const totalPages = 2;
|
for (let page = 1; page < totalPages; page++) {
|
||||||
// renderComponent(discussionPostId);
|
act(() => {
|
||||||
//
|
fireEvent.click(loadMoreButton);
|
||||||
// const loadMoreButton = await findLoadMoreCommentsResponsesButton();
|
});
|
||||||
// for (let page = 1; page < totalPages; page++) {
|
}
|
||||||
// act(() => {
|
|
||||||
// fireEvent.click(loadMoreButton);
|
await screen.findByTestId('comment-8');
|
||||||
// });
|
await expect(findLoadMoreCommentsResponsesButton())
|
||||||
// }
|
.rejects
|
||||||
//
|
.toThrow();
|
||||||
// await screen.findByText('comment number 8', { exact: false });
|
});
|
||||||
// await expect(findLoadMoreCommentsResponsesButton())
|
|
||||||
// .rejects
|
it('handles liking a comment', async () => {
|
||||||
// .toThrow();
|
renderComponent(discussionPostId);
|
||||||
// });
|
|
||||||
//
|
// Wait for the content to load
|
||||||
// it('handles liking a comment', async () => {
|
await act(async () => {
|
||||||
// renderComponent(discussionPostId);
|
fireEvent.mouseOver(await waitFor(() => screen.findByTestId('comment-7')));
|
||||||
//
|
});
|
||||||
// // Wait for the content to load
|
const view = screen.getByTestId('comment-comment-1');
|
||||||
// await screen.findByText('comment number 7', { exact: false });
|
|
||||||
// const view = screen.getByTestId('comment-comment-1');
|
const likeButton = within(view).getByRole('button', { name: /like/i });
|
||||||
//
|
await act(async () => {
|
||||||
// const likeButton = within(view).getByRole('button', { name: /like/i });
|
fireEvent.click(likeButton);
|
||||||
// await act(async () => {
|
});
|
||||||
// fireEvent.click(likeButton);
|
expect(axiosMock.history.patch).toHaveLength(2);
|
||||||
// });
|
expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject({ voted: true });
|
||||||
// expect(axiosMock.history.patch).toHaveLength(2);
|
});
|
||||||
// expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject({ voted: true });
|
|
||||||
// });
|
it('handles endorsing comments', async () => {
|
||||||
//
|
renderComponent(discussionPostId);
|
||||||
// it('handles endorsing comments', async () => {
|
// Wait for the content to load
|
||||||
// renderComponent(discussionPostId);
|
await act(async () => {
|
||||||
// // Wait for the content to load
|
fireEvent.mouseOver(await waitFor(() => screen.findByTestId('comment-7')));
|
||||||
// await screen.findByText('comment number 7', { exact: false });
|
});
|
||||||
//
|
|
||||||
// // There should be three buttons, one for the post, the second for the
|
await act(async () => {
|
||||||
// // comment and the third for a response to that comment
|
fireEvent.click(screen.getByRole('button', { name: /Endorse/i }));
|
||||||
// const actionButtons = screen.queryAllByRole('button', { name: /actions menu/i });
|
});
|
||||||
// await act(async () => {
|
expect(axiosMock.history.patch).toHaveLength(2);
|
||||||
// fireEvent.click(actionButtons[1]);
|
expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject({ endorsed: true });
|
||||||
// });
|
});
|
||||||
//
|
|
||||||
// await act(async () => {
|
it('handles reporting comments', async () => {
|
||||||
// fireEvent.click(screen.getByRole('button', { name: /Endorse/i }));
|
renderComponent(discussionPostId);
|
||||||
// });
|
// Wait for the content to load
|
||||||
// expect(axiosMock.history.patch).toHaveLength(2);
|
await act(async () => {
|
||||||
// expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject({ endorsed: true });
|
fireEvent.mouseOver(await waitFor(() => screen.findByTestId('comment-7')));
|
||||||
// });
|
});
|
||||||
//
|
|
||||||
// it('handles reporting comments', async () => {
|
// There should be three buttons, one for the post, the second for the
|
||||||
// renderComponent(discussionPostId);
|
// comment and the third for a response to that comment
|
||||||
// // Wait for the content to load
|
const actionButtons = screen.queryAllByRole('button', { name: /actions menu/i });
|
||||||
// await screen.findByText('comment number 7', { exact: false });
|
await act(async () => {
|
||||||
//
|
fireEvent.click(actionButtons[1]);
|
||||||
// // 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 () => {
|
||||||
// await act(async () => {
|
fireEvent.click(screen.getByRole('button', { name: /Report/i }));
|
||||||
// fireEvent.click(actionButtons[1]);
|
});
|
||||||
// });
|
expect(screen.queryByRole('dialog', { name: /Report \w+/i, exact: false })).toBeInTheDocument();
|
||||||
//
|
await act(async () => {
|
||||||
// await act(async () => {
|
fireEvent.click(screen.queryByRole('button', { name: /Confirm/i }));
|
||||||
// fireEvent.click(screen.getByRole('button', { name: /Report/i }));
|
});
|
||||||
// });
|
expect(screen.queryByRole('dialog', { name: /Report \w+/i, exact: false })).not.toBeInTheDocument();
|
||||||
// expect(screen.queryByRole('dialog', { name: /Report \w+/i, exact: false })).toBeInTheDocument();
|
expect(axiosMock.history.patch).toHaveLength(2);
|
||||||
// await act(async () => {
|
expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject({ abuse_flagged: true });
|
||||||
// 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);
|
describe.each([
|
||||||
// expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject({ abuse_flagged: true });
|
{ component: 'post', testId: 'post-thread-1' },
|
||||||
// });
|
{ component: 'comment', testId: 'comment-comment-1' },
|
||||||
// });
|
{ component: 'reply', testId: 'reply-comment-7' },
|
||||||
//
|
])('delete confirmation modal', ({
|
||||||
// describe.each([
|
component,
|
||||||
// { component: 'post', testId: 'post-thread-1' },
|
testId,
|
||||||
// { component: 'comment', testId: 'comment-comment-1' },
|
}) => {
|
||||||
// { component: 'reply', testId: 'reply-comment-7' },
|
test(`for ${component}`, async () => {
|
||||||
// ])('delete confirmation modal', ({
|
renderComponent(discussionPostId);
|
||||||
// component,
|
// Wait for the content to load
|
||||||
// testId,
|
// await waitFor(() => expect(screen.findByTestId('post-thread-1')).toBeInTheDocument());
|
||||||
// }) => {
|
await waitFor(() => expect(screen.queryByText('This is Thread-1', { exact: false })).toBeInTheDocument());
|
||||||
// test(`for ${component}`, async () => {
|
const content = screen.getByTestId(testId);
|
||||||
// renderComponent(discussionPostId);
|
await act(async () => {
|
||||||
// // Wait for the content to load
|
fireEvent.mouseOver(content);
|
||||||
// await waitFor(() => expect(screen.queryByText('comment number 7', { exact: false })).toBeInTheDocument());
|
});
|
||||||
// const content = screen.getByTestId(testId);
|
const actionsButton = within(content).getAllByRole('button', { name: /actions menu/i })[0];
|
||||||
// const actionsButton = within(content).getAllByRole('button', { name: /actions menu/i })[0];
|
await act(async () => {
|
||||||
// await act(async () => {
|
fireEvent.click(actionsButton);
|
||||||
// fireEvent.click(actionsButton);
|
});
|
||||||
// });
|
expect(screen.queryByRole('dialog', { name: /delete \w+/i, exact: false })).not.toBeInTheDocument();
|
||||||
// expect(screen.queryByRole('dialog', { name: /delete \w+/i, exact: false })).not.toBeInTheDocument();
|
const deleteButton = within(content).queryByRole('button', { name: /delete/i });
|
||||||
// const deleteButton = within(content).queryByRole('button', { name: /delete/i });
|
await act(async () => {
|
||||||
// await act(async () => {
|
fireEvent.click(deleteButton);
|
||||||
// fireEvent.click(deleteButton);
|
});
|
||||||
// });
|
expect(screen.queryByRole('dialog', { name: /delete \w+/i, exact: false })).toBeInTheDocument();
|
||||||
// expect(screen.queryByRole('dialog', { name: /delete \w+/i, exact: false })).toBeInTheDocument();
|
await act(async () => {
|
||||||
// await act(async () => {
|
fireEvent.click(screen.queryByRole('button', { name: /delete/i }));
|
||||||
// fireEvent.click(screen.queryByRole('button', { name: /delete/i }));
|
});
|
||||||
// });
|
expect(screen.queryByRole('dialog', { name: /delete \w+/i, exact: false })).not.toBeInTheDocument();
|
||||||
// expect(screen.queryByRole('dialog', { name: /delete \w+/i, exact: false })).not.toBeInTheDocument();
|
});
|
||||||
// });
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,16 +17,16 @@ function CommentIcons({
|
|||||||
timeago.register('time-locale', timeLocale);
|
timeago.register('time-locale', timeLocale);
|
||||||
|
|
||||||
const handleLike = () => dispatch(editComment(comment.id, { voted: !comment.voted }));
|
const handleLike = () => dispatch(editComment(comment.id, { voted: !comment.voted }));
|
||||||
|
if (comment.voteCount === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div className="d-flex flex-row align-items-center">
|
<div className="ml-n1.5 mt-10px">
|
||||||
<LikeButton
|
<LikeButton
|
||||||
count={comment.voteCount}
|
count={comment.voteCount}
|
||||||
onClick={handleLike}
|
onClick={handleLike}
|
||||||
voted={comment.voted}
|
voted={comment.voted}
|
||||||
/>
|
/>
|
||||||
<div className="d-flex flex-fill text-gray-500 justify-content-end" title={comment.createdAt}>
|
|
||||||
{timeago.format(comment.createdAt, 'time-locale')}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
|||||||
import { Button, useToggle } from '@edx/paragon';
|
import { Button, useToggle } from '@edx/paragon';
|
||||||
|
|
||||||
import HTMLLoader from '../../../components/HTMLLoader';
|
import HTMLLoader from '../../../components/HTMLLoader';
|
||||||
import { ContentActions } from '../../../data/constants';
|
import { ContentActions, EndorsementStatus } from '../../../data/constants';
|
||||||
import { AlertBanner, Confirmation, EndorsedAlertBanner } from '../../common';
|
import { AlertBanner, Confirmation, EndorsedAlertBanner } from '../../common';
|
||||||
import { DiscussionContext } from '../../common/context';
|
import { DiscussionContext } from '../../common/context';
|
||||||
|
import HoverCard from '../../common/HoverCard';
|
||||||
import { useUserCanAddThreadInBlackoutDate } from '../../data/hooks';
|
import { useUserCanAddThreadInBlackoutDate } from '../../data/hooks';
|
||||||
import { fetchThread } from '../../posts/data/thunks';
|
import { fetchThread } from '../../posts/data/thunks';
|
||||||
|
import { useActions } from '../../utils';
|
||||||
import CommentIcons from '../comment-icons/CommentIcons';
|
import CommentIcons from '../comment-icons/CommentIcons';
|
||||||
import { selectCommentCurrentPage, selectCommentHasMorePages, selectCommentResponses } from '../data/selectors';
|
import { selectCommentCurrentPage, selectCommentHasMorePages, selectCommentResponses } from '../data/selectors';
|
||||||
import { editComment, fetchCommentResponses, removeComment } from '../data/thunks';
|
import { editComment, fetchCommentResponses, removeComment } from '../data/thunks';
|
||||||
@@ -28,6 +30,7 @@ function Comment({
|
|||||||
showFullThread = true,
|
showFullThread = true,
|
||||||
isClosedPost,
|
isClosedPost,
|
||||||
intl,
|
intl,
|
||||||
|
marginBottom,
|
||||||
}) {
|
}) {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const hasChildren = comment.childCount > 0;
|
const hasChildren = comment.childCount > 0;
|
||||||
@@ -40,6 +43,7 @@ function Comment({
|
|||||||
const hasMorePages = useSelector(selectCommentHasMorePages(comment.id));
|
const hasMorePages = useSelector(selectCommentHasMorePages(comment.id));
|
||||||
const currentPage = useSelector(selectCommentCurrentPage(comment.id));
|
const currentPage = useSelector(selectCommentCurrentPage(comment.id));
|
||||||
const userCanAddThreadInBlackoutDate = useUserCanAddThreadInBlackoutDate();
|
const userCanAddThreadInBlackoutDate = useUserCanAddThreadInBlackoutDate();
|
||||||
|
const [showHoverCard, setShowHoverCard] = useState(false);
|
||||||
const {
|
const {
|
||||||
courseId,
|
courseId,
|
||||||
} = useContext(DiscussionContext);
|
} = useContext(DiscussionContext);
|
||||||
@@ -49,6 +53,11 @@ function Comment({
|
|||||||
dispatch(fetchCommentResponses(comment.id, { page: 1 }));
|
dispatch(fetchCommentResponses(comment.id, { page: 1 }));
|
||||||
}
|
}
|
||||||
}, [comment.id]);
|
}, [comment.id]);
|
||||||
|
const actions = useActions({
|
||||||
|
...comment,
|
||||||
|
postType,
|
||||||
|
});
|
||||||
|
const endorseIcons = actions.find(({ action }) => action === EndorsementStatus.ENDORSED);
|
||||||
|
|
||||||
const handleAbusedFlag = () => {
|
const handleAbusedFlag = () => {
|
||||||
if (comment.abuseFlagged) {
|
if (comment.abuseFlagged) {
|
||||||
@@ -81,10 +90,15 @@ function Comment({
|
|||||||
const handleLoadMoreComments = () => (
|
const handleLoadMoreComments = () => (
|
||||||
dispatch(fetchCommentResponses(comment.id, { page: currentPage + 1 }))
|
dispatch(fetchCommentResponses(comment.id, { page: currentPage + 1 }))
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classNames({ 'py-2 my-3': showFullThread })}>
|
<div className={classNames({ 'mb-3': (showFullThread && !marginBottom) })}>
|
||||||
<div className="d-flex flex-column card" data-testid={`comment-${comment.id}`} role="listitem">
|
{/* eslint-disable jsx-a11y/no-noninteractive-tabindex */}
|
||||||
|
<div
|
||||||
|
tabIndex="0"
|
||||||
|
className="d-flex flex-column card on-focus"
|
||||||
|
data-testid={`comment-${comment.id}`}
|
||||||
|
role="listitem"
|
||||||
|
>
|
||||||
<Confirmation
|
<Confirmation
|
||||||
isOpen={isDeleting}
|
isOpen={isDeleting}
|
||||||
title={intl.formatMessage(messages.deleteResponseTitle)}
|
title={intl.formatMessage(messages.deleteResponseTitle)}
|
||||||
@@ -105,38 +119,64 @@ function Comment({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<EndorsedAlertBanner postType={postType} content={comment} />
|
<EndorsedAlertBanner postType={postType} content={comment} />
|
||||||
<div className="d-flex flex-column p-4.5">
|
<div
|
||||||
|
className="d-flex flex-column post-card-comment px-4 pt-3.5 pb-10px"
|
||||||
|
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} />
|
<AlertBanner content={comment} />
|
||||||
<CommentHeader comment={comment} actionHandlers={actionHandlers} postType={postType} />
|
<CommentHeader comment={comment} />
|
||||||
{isEditing
|
{isEditing
|
||||||
? (
|
? (
|
||||||
<CommentEditor comment={comment} onCloseEditor={() => setEditing(false)} formClasses="pt-3" />
|
<CommentEditor comment={comment} onCloseEditor={() => setEditing(false)} formClasses="pt-3" />
|
||||||
)
|
)
|
||||||
: <HTMLLoader cssClassName="comment-body text-break pt-4 text-primary-500" componentId="comment" htmlNode={comment.renderedBody} />}
|
: (
|
||||||
|
<HTMLLoader
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<CommentIcons
|
<CommentIcons
|
||||||
comment={comment}
|
comment={comment}
|
||||||
following={comment.following}
|
following={comment.following}
|
||||||
onLike={() => dispatch(editComment(comment.id, { voted: !comment.voted }))}
|
onLike={() => dispatch(editComment(comment.id, { voted: !comment.voted }))}
|
||||||
createdAt={comment.createdAt}
|
createdAt={comment.createdAt}
|
||||||
/>
|
/>
|
||||||
<div className="sr-only" role="heading" aria-level="3"> {intl.formatMessage(messages.replies, { count: inlineReplies.length })}</div>
|
{inlineReplies.length > 0 && (
|
||||||
<div className="d-flex flex-column" role="list">
|
<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` */}
|
{/* Pass along intl since component used here is the one before it's injected with `injectIntl` */}
|
||||||
{inlineReplies.map(inlineReply => (
|
{inlineReplies.map(inlineReply => (
|
||||||
<Reply
|
<Reply
|
||||||
reply={inlineReply}
|
reply={inlineReply}
|
||||||
postType={postType}
|
postType={postType}
|
||||||
key={inlineReply.id}
|
key={inlineReply.id}
|
||||||
intl={intl}
|
intl={intl}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
{hasMorePages && (
|
{hasMorePages && (
|
||||||
<Button
|
<Button
|
||||||
onClick={handleLoadMoreComments}
|
onClick={handleLoadMoreComments}
|
||||||
variant="link"
|
variant="link"
|
||||||
block="true"
|
block="true"
|
||||||
className="mt-4.5 font-size-14 font-style-normal font-family-inter font-weight-500 px-2.5 py-2"
|
className="font-size-14 font-style-normal font-family-inter pt-10px border-0 font-weight-500 pb-0"
|
||||||
data-testid="load-more-comments-responses"
|
data-testid="load-more-comments-responses"
|
||||||
style={{
|
style={{
|
||||||
lineHeight: '20px',
|
lineHeight: '20px',
|
||||||
@@ -147,23 +187,26 @@ function Comment({
|
|||||||
)}
|
)}
|
||||||
{!isNested && showFullThread && (
|
{!isNested && showFullThread && (
|
||||||
isReplying ? (
|
isReplying ? (
|
||||||
<CommentEditor
|
<div className="mt-2.5">
|
||||||
comment={{
|
<CommentEditor
|
||||||
threadId: comment.threadId,
|
comment={{
|
||||||
parentId: comment.id,
|
threadId: comment.threadId,
|
||||||
}}
|
parentId: comment.id,
|
||||||
edit={false}
|
}}
|
||||||
onCloseEditor={() => setReplying(false)}
|
edit={false}
|
||||||
/>
|
onCloseEditor={() => setReplying(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{!isClosedPost && userCanAddThreadInBlackoutDate
|
{!isClosedPost && userCanAddThreadInBlackoutDate && (inlineReplies.length >= 5)
|
||||||
&& (
|
&& (
|
||||||
<Button
|
<Button
|
||||||
className="d-flex flex-grow mt-3 py-2 font-size-14"
|
className="d-flex flex-grow mt-2 font-size-14 font-style-normal font-family-inter font-weight-500 text-primary-500"
|
||||||
variant="outline-primary"
|
variant="plain"
|
||||||
style={{
|
style={{
|
||||||
lineHeight: '20px',
|
lineHeight: '24px',
|
||||||
|
height: '36px',
|
||||||
}}
|
}}
|
||||||
onClick={() => setReplying(true)}
|
onClick={() => setReplying(true)}
|
||||||
>
|
>
|
||||||
@@ -171,7 +214,6 @@ function Comment({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -186,11 +228,13 @@ Comment.propTypes = {
|
|||||||
showFullThread: PropTypes.bool,
|
showFullThread: PropTypes.bool,
|
||||||
isClosedPost: PropTypes.bool,
|
isClosedPost: PropTypes.bool,
|
||||||
intl: intlShape.isRequired,
|
intl: intlShape.isRequired,
|
||||||
|
marginBottom: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
Comment.defaultProps = {
|
Comment.defaultProps = {
|
||||||
showFullThread: true,
|
showFullThread: true,
|
||||||
isClosedPost: false,
|
isClosedPost: false,
|
||||||
|
marginBottom: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(Comment);
|
export default injectIntl(Comment);
|
||||||
|
|||||||
@@ -1,46 +1,24 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
|
|
||||||
import { injectIntl } from '@edx/frontend-platform/i18n';
|
import { injectIntl } from '@edx/frontend-platform/i18n';
|
||||||
import { logError } from '@edx/frontend-platform/logging';
|
import { Avatar } from '@edx/paragon';
|
||||||
import {
|
|
||||||
Avatar, Icon,
|
|
||||||
} from '@edx/paragon';
|
|
||||||
|
|
||||||
import { AvatarOutlineAndLabelColors, EndorsementStatus, ThreadType } from '../../../data/constants';
|
import { AvatarOutlineAndLabelColors } from '../../../data/constants';
|
||||||
import { AuthorLabel } from '../../common';
|
import { AuthorLabel } from '../../common';
|
||||||
import ActionsDropdown from '../../common/ActionsDropdown';
|
|
||||||
import { useAlertBannerVisible } from '../../data/hooks';
|
import { useAlertBannerVisible } from '../../data/hooks';
|
||||||
import { selectAuthorAvatars } from '../../posts/data/selectors';
|
import { selectAuthorAvatars } from '../../posts/data/selectors';
|
||||||
import { useActions } from '../../utils';
|
|
||||||
import { commentShape } from './proptypes';
|
import { commentShape } from './proptypes';
|
||||||
|
|
||||||
function CommentHeader({
|
function CommentHeader({
|
||||||
comment,
|
comment,
|
||||||
postType,
|
|
||||||
actionHandlers,
|
|
||||||
}) {
|
}) {
|
||||||
const authorAvatars = useSelector(selectAuthorAvatars(comment.author));
|
const authorAvatars = useSelector(selectAuthorAvatars(comment.author));
|
||||||
const colorClass = AvatarOutlineAndLabelColors[comment.authorLabel];
|
const colorClass = AvatarOutlineAndLabelColors[comment.authorLabel];
|
||||||
const hasAnyAlert = useAlertBannerVisible(comment);
|
const hasAnyAlert = useAlertBannerVisible(comment);
|
||||||
|
|
||||||
const actions = useActions({
|
|
||||||
...comment,
|
|
||||||
postType,
|
|
||||||
});
|
|
||||||
const actionIcons = actions.find(({ action }) => action === EndorsementStatus.ENDORSED);
|
|
||||||
|
|
||||||
const handleIcons = (action) => {
|
|
||||||
const actionFunction = actionHandlers[action];
|
|
||||||
if (actionFunction) {
|
|
||||||
actionFunction();
|
|
||||||
} else {
|
|
||||||
logError(`Unknown or unimplemented action ${action}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return (
|
return (
|
||||||
<div className={classNames('d-flex flex-row justify-content-between', {
|
<div className={classNames('d-flex flex-row justify-content-between', {
|
||||||
'mt-2': hasAnyAlert,
|
'mt-2': hasAnyAlert,
|
||||||
@@ -61,28 +39,8 @@ function CommentHeader({
|
|||||||
authorLabel={comment.authorLabel}
|
authorLabel={comment.authorLabel}
|
||||||
labelColor={colorClass && `text-${colorClass}`}
|
labelColor={colorClass && `text-${colorClass}`}
|
||||||
linkToProfile
|
linkToProfile
|
||||||
/>
|
postCreatedAt={comment.createdAt}
|
||||||
</div>
|
postOrComment
|
||||||
<div className="d-flex align-items-center">
|
|
||||||
|
|
||||||
{actionIcons && (
|
|
||||||
<span className="btn-icon btn-icon-sm mr-1 align-items-center pointer-cursor-hover">
|
|
||||||
<Icon
|
|
||||||
data-testid="check-icon"
|
|
||||||
onClick={() => handleIcons(actionIcons.action)}
|
|
||||||
src={actionIcons.icon}
|
|
||||||
className={['endorse', 'unendorse'].includes(actionIcons.id) ? 'text-dark-500' : 'text-success-500'}
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ActionsDropdown
|
|
||||||
commentOrPost={{
|
|
||||||
...comment,
|
|
||||||
postType,
|
|
||||||
}}
|
|
||||||
actionHandlers={actionHandlers}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -91,8 +49,6 @@ function CommentHeader({
|
|||||||
|
|
||||||
CommentHeader.propTypes = {
|
CommentHeader.propTypes = {
|
||||||
comment: commentShape.isRequired,
|
comment: commentShape.isRequired,
|
||||||
actionHandlers: PropTypes.objectOf(PropTypes.func).isRequired,
|
|
||||||
postType: PropTypes.oneOf([ThreadType.QUESTION, ThreadType.DISCUSSION]).isRequired,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(CommentHeader);
|
export default injectIntl(CommentHeader);
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
import { render, screen } from '@testing-library/react';
|
|
||||||
import { IntlProvider } from 'react-intl';
|
|
||||||
|
|
||||||
import { initializeMockApp } from '@edx/frontend-platform';
|
|
||||||
import { AppProvider } from '@edx/frontend-platform/react';
|
|
||||||
|
|
||||||
import { initializeStore } from '../../../store';
|
|
||||||
import { DiscussionContext } from '../../common/context';
|
|
||||||
import CommentHeader from './CommentHeader';
|
|
||||||
|
|
||||||
let store;
|
|
||||||
|
|
||||||
function renderComponent(comment, postType, actionHandlers) {
|
|
||||||
return render(
|
|
||||||
<IntlProvider locale="en">
|
|
||||||
<AppProvider store={store}>
|
|
||||||
<DiscussionContext.Provider
|
|
||||||
value={{ courseId: 'course-v1:edX+TestX+Test_Course' }}
|
|
||||||
>
|
|
||||||
<CommentHeader comment={comment} postType={postType} actionHandlers={actionHandlers} />
|
|
||||||
</DiscussionContext.Provider>
|
|
||||||
</AppProvider>
|
|
||||||
</IntlProvider>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const mockComment = {
|
|
||||||
author: 'abc123',
|
|
||||||
authorLabel: 'ABC 123',
|
|
||||||
endorsed: true,
|
|
||||||
editableFields: ['endorsed'],
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('Comment Header', () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
initializeMockApp({
|
|
||||||
authenticatedUser: {
|
|
||||||
userId: 3,
|
|
||||||
username: 'abc123',
|
|
||||||
administrator: true,
|
|
||||||
roles: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
store = initializeStore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render verified icon for endorsed discussion posts', () => {
|
|
||||||
renderComponent(mockComment, 'discussion', {});
|
|
||||||
expect(screen.queryAllByTestId('check-icon')).toHaveLength(1);
|
|
||||||
});
|
|
||||||
it('should render check icon for endorsed question posts', () => {
|
|
||||||
renderComponent(mockComment, 'question', {});
|
|
||||||
expect(screen.queryAllByTestId('check-icon')).toHaveLength(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -64,7 +64,7 @@ function Reply({
|
|||||||
const hasAnyAlert = useAlertBannerVisible(reply);
|
const hasAnyAlert = useAlertBannerVisible(reply);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="d-flex flex-column mt-4.5" data-testid={`reply-${reply.id}`} role="listitem">
|
<div className="d-flex flex-column mt-2.5 " data-testid={`reply-${reply.id}`} role="listitem">
|
||||||
<Confirmation
|
<Confirmation
|
||||||
isOpen={isDeleting}
|
isOpen={isDeleting}
|
||||||
title={intl.formatMessage(messages.deleteCommentTitle)}
|
title={intl.formatMessage(messages.deleteCommentTitle)}
|
||||||
@@ -108,29 +108,41 @@ function Reply({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="bg-light-300 px-4 pb-2 pt-2.5 flex-fill"
|
className="bg-light-300 pl-4 pt-2.5 pr-2.5 pb-10px flex-fill"
|
||||||
style={{ borderRadius: '0rem 0.375rem 0.375rem' }}
|
style={{ borderRadius: '0rem 0.375rem 0.375rem' }}
|
||||||
>
|
>
|
||||||
<div className="d-flex flex-row justify-content-between align-items-center mb-0.5">
|
<div className="d-flex flex-row justify-content-between" style={{ height: '24px' }}>
|
||||||
<AuthorLabel author={reply.author} authorLabel={reply.authorLabel} labelColor={colorClass && `text-${colorClass}`} linkToProfile />
|
<AuthorLabel
|
||||||
<div className="ml-auto d-flex">
|
author={reply.author}
|
||||||
|
authorLabel={reply.authorLabel}
|
||||||
|
labelColor={colorClass && `text-${colorClass}`}
|
||||||
|
linkToProfile
|
||||||
|
postCreatedAt={reply.createdAt}
|
||||||
|
postOrComment
|
||||||
|
/>
|
||||||
|
<div className="ml-auto d-flex" style={{ lineHeight: '24px' }}>
|
||||||
<ActionsDropdown
|
<ActionsDropdown
|
||||||
commentOrPost={{
|
commentOrPost={{
|
||||||
...reply,
|
...reply,
|
||||||
postType,
|
postType,
|
||||||
}}
|
}}
|
||||||
actionHandlers={actionHandlers}
|
actionHandlers={actionHandlers}
|
||||||
|
iconSize="inline"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{isEditing
|
{isEditing
|
||||||
? <CommentEditor comment={reply} onCloseEditor={() => setEditing(false)} />
|
? <CommentEditor comment={reply} onCloseEditor={() => setEditing(false)} />
|
||||||
: <HTMLLoader componentId="reply" htmlNode={reply.renderedBody} cssClassName="text-break text-primary-500" />}
|
: (
|
||||||
|
<HTMLLoader
|
||||||
|
componentId="reply"
|
||||||
|
htmlNode={reply.renderedBody}
|
||||||
|
cssClassName="html-loader text-break font-style-normal pb-1 font-family-inter text-primary-500"
|
||||||
|
testId={reply.id}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-gray-500 align-self-end mt-2" title={reply.createdAt}>
|
|
||||||
{timeago.format(reply.createdAt, 'time-locale')}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,59 +1,39 @@
|
|||||||
import React, { useContext, useEffect, useState } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { injectIntl } from '@edx/frontend-platform/i18n';
|
||||||
import { Button } from '@edx/paragon';
|
|
||||||
|
|
||||||
import { DiscussionContext } from '../../common/context';
|
|
||||||
import { useUserCanAddThreadInBlackoutDate } from '../../data/hooks';
|
|
||||||
import messages from '../messages';
|
|
||||||
import CommentEditor from './CommentEditor';
|
import CommentEditor from './CommentEditor';
|
||||||
|
|
||||||
function ResponseEditor({
|
function ResponseEditor({
|
||||||
postId,
|
postId,
|
||||||
intl,
|
|
||||||
addWrappingDiv,
|
addWrappingDiv,
|
||||||
|
handleCloseEditor,
|
||||||
|
addingResponse,
|
||||||
}) {
|
}) {
|
||||||
const { enableInContextSidebar } = useContext(DiscussionContext);
|
|
||||||
const [addingResponse, setAddingResponse] = useState(false);
|
|
||||||
const userCanAddThreadInBlackoutDate = useUserCanAddThreadInBlackoutDate();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setAddingResponse(false);
|
handleCloseEditor();
|
||||||
}, [postId]);
|
}, [postId]);
|
||||||
|
|
||||||
return addingResponse
|
return addingResponse
|
||||||
? (
|
&& (
|
||||||
<div className={classNames({ 'bg-white p-4 mb-4 rounded': addWrappingDiv })}>
|
<div className={classNames({ 'bg-white p-4 mb-4 rounded': addWrappingDiv })}>
|
||||||
<CommentEditor
|
<CommentEditor
|
||||||
comment={{ threadId: postId }}
|
comment={{ threadId: postId }}
|
||||||
edit={false}
|
edit={false}
|
||||||
onCloseEditor={() => setAddingResponse(false)}
|
onCloseEditor={handleCloseEditor}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
|
||||||
: userCanAddThreadInBlackoutDate && (
|
|
||||||
<div className={classNames({ 'mb-4': addWrappingDiv }, 'actions d-flex')}>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
className={classNames('px-2.5 py-2 font-size-14', { 'w-100': enableInContextSidebar })}
|
|
||||||
onClick={() => setAddingResponse(true)}
|
|
||||||
style={{
|
|
||||||
lineHeight: '20px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{intl.formatMessage(messages.addResponse)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
ResponseEditor.propTypes = {
|
ResponseEditor.propTypes = {
|
||||||
postId: PropTypes.string.isRequired,
|
postId: PropTypes.string.isRequired,
|
||||||
intl: intlShape.isRequired,
|
|
||||||
addWrappingDiv: PropTypes.bool,
|
addWrappingDiv: PropTypes.bool,
|
||||||
|
handleCloseEditor: PropTypes.func.isRequired,
|
||||||
|
addingResponse: PropTypes.bool.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
ResponseEditor.defaultProps = {
|
ResponseEditor.defaultProps = {
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
|
addComment: {
|
||||||
|
id: 'discussions.comments.comment.addComment',
|
||||||
|
defaultMessage: 'Add comment',
|
||||||
|
description: 'Button to add a comment to a response',
|
||||||
|
},
|
||||||
addResponse: {
|
addResponse: {
|
||||||
id: 'discussions.comments.comment.addResponse',
|
id: 'discussions.comments.comment.addResponse',
|
||||||
defaultMessage: 'Add a response',
|
defaultMessage: 'Add a response',
|
||||||
description: 'Button to add a response in a thread of forum posts',
|
description: 'Button to add a response to a response',
|
||||||
},
|
|
||||||
addComment: {
|
|
||||||
id: 'discussions.comments.comment.addComment',
|
|
||||||
defaultMessage: 'Add a comment',
|
|
||||||
description: 'Button to add a comment to a response',
|
|
||||||
},
|
},
|
||||||
abuseFlaggedMessage: {
|
abuseFlaggedMessage: {
|
||||||
id: 'discussions.comments.comment.abuseFlaggedMessage',
|
id: 'discussions.comments.comment.abuseFlaggedMessage',
|
||||||
@@ -188,6 +188,11 @@ const messages = defineMessages({
|
|||||||
defaultMessage: 'Edited by',
|
defaultMessage: 'Edited by',
|
||||||
description: 'Text shown to users to indicate who edited a post. Followed by the username of editor.',
|
description: 'Text shown to users to indicate who edited a post. Followed by the username of editor.',
|
||||||
},
|
},
|
||||||
|
fullStop: {
|
||||||
|
id: 'discussions.comment.comments.fullStop',
|
||||||
|
defaultMessage: '•',
|
||||||
|
description: 'Fullstop shown to users to indicate who edited a post. Followed by a reason.',
|
||||||
|
},
|
||||||
reason: {
|
reason: {
|
||||||
id: 'discussions.comment.comments.reason',
|
id: 'discussions.comment.comments.reason',
|
||||||
defaultMessage: 'Reason',
|
defaultMessage: 'Reason',
|
||||||
@@ -197,11 +202,6 @@ const messages = defineMessages({
|
|||||||
id: 'discussions.post.closedBy',
|
id: 'discussions.post.closedBy',
|
||||||
defaultMessage: 'Post closed by',
|
defaultMessage: 'Post closed by',
|
||||||
},
|
},
|
||||||
replies: {
|
|
||||||
id: 'discussion.comment.repliesHeading',
|
|
||||||
defaultMessage: '{count} replies for the response added',
|
|
||||||
description: 'Text added for screen reader to understand nesting replies.',
|
|
||||||
},
|
|
||||||
time: {
|
time: {
|
||||||
id: 'discussion.comment.time',
|
id: 'discussion.comment.time',
|
||||||
defaultMessage: '{time} ago',
|
defaultMessage: '{time} ago',
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ function ActionsDropdown({
|
|||||||
commentOrPost,
|
commentOrPost,
|
||||||
disabled,
|
disabled,
|
||||||
actionHandlers,
|
actionHandlers,
|
||||||
|
iconSize,
|
||||||
|
dropDownIconSize,
|
||||||
}) {
|
}) {
|
||||||
const [isOpen, open, close] = useToggle(false);
|
const [isOpen, open, close] = useToggle(false);
|
||||||
const [target, setTarget] = useState(null);
|
const [target, setTarget] = useState(null);
|
||||||
@@ -49,8 +51,9 @@ function ActionsDropdown({
|
|||||||
src={MoreHoriz}
|
src={MoreHoriz}
|
||||||
iconAs={Icon}
|
iconAs={Icon}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
size="sm"
|
size={iconSize}
|
||||||
ref={setTarget}
|
ref={setTarget}
|
||||||
|
iconClassNames={dropDownIconSize ? 'dropdown-icon-dimentions' : ''}
|
||||||
/>
|
/>
|
||||||
<div className="actions-dropdown">
|
<div className="actions-dropdown">
|
||||||
<ModalPopup
|
<ModalPopup
|
||||||
@@ -66,7 +69,7 @@ function ActionsDropdown({
|
|||||||
{actions.map(action => (
|
{actions.map(action => (
|
||||||
<React.Fragment key={action.id}>
|
<React.Fragment key={action.id}>
|
||||||
{(action.action === ContentActions.DELETE)
|
{(action.action === ContentActions.DELETE)
|
||||||
&& <Dropdown.Divider />}
|
&& <Dropdown.Divider />}
|
||||||
|
|
||||||
<Dropdown.Item
|
<Dropdown.Item
|
||||||
as={Button}
|
as={Button}
|
||||||
@@ -94,10 +97,14 @@ ActionsDropdown.propTypes = {
|
|||||||
commentOrPost: PropTypes.oneOfType([commentShape, postShape]).isRequired,
|
commentOrPost: PropTypes.oneOfType([commentShape, postShape]).isRequired,
|
||||||
disabled: PropTypes.bool,
|
disabled: PropTypes.bool,
|
||||||
actionHandlers: PropTypes.objectOf(PropTypes.func).isRequired,
|
actionHandlers: PropTypes.objectOf(PropTypes.func).isRequired,
|
||||||
|
iconSize: PropTypes.string,
|
||||||
|
dropDownIconSize: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
ActionsDropdown.defaultProps = {
|
ActionsDropdown.defaultProps = {
|
||||||
disabled: false,
|
disabled: false,
|
||||||
|
iconSize: 'sm',
|
||||||
|
dropDownIconSize: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(ActionsDropdown);
|
export default injectIntl(ActionsDropdown);
|
||||||
|
|||||||
@@ -33,32 +33,45 @@ function AlertBanner({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{canSeeReportedBanner && (
|
{canSeeReportedBanner && (
|
||||||
<Alert icon={Report} variant="danger" className="px-3 mb-2 py-10px shadow-none flex-fill">
|
<Alert icon={Report} variant="danger" className="px-3 mb-1 py-10px shadow-none flex-fill">
|
||||||
{intl.formatMessage(messages.abuseFlaggedMessage)}
|
{intl.formatMessage(messages.abuseFlaggedMessage)}
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
{reasonCodesEnabled && canSeeLastEditOrClosedAlert && (
|
{reasonCodesEnabled && canSeeLastEditOrClosedAlert && (
|
||||||
<>
|
<>
|
||||||
{content.lastEdit?.reason && (
|
{content.lastEdit?.reason && (
|
||||||
<Alert variant="info" className="px-3 shadow-none mb-2 py-10px bg-light-200">
|
<Alert variant="info" className="px-3 shadow-none mb-1 py-10px bg-light-200">
|
||||||
<div className="d-flex align-items-center flex-wrap">
|
<div className="d-flex align-items-center flex-wrap text-gray-700 font-family-inter">
|
||||||
{intl.formatMessage(messages.editedBy)}
|
{intl.formatMessage(messages.editedBy)}
|
||||||
<span className="ml-1 mr-3">
|
<span className="ml-1 mr-3">
|
||||||
<AuthorLabel author={content.lastEdit.editorUsername} linkToProfile />
|
<AuthorLabel author={content.lastEdit.editorUsername} linkToProfile postOrComment />
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="mx-1.5 font-family-inter font-size-8 font-style-normal text-light-700"
|
||||||
|
style={{ lineHeight: '15px' }}
|
||||||
|
>
|
||||||
|
{intl.formatMessage(messages.fullStop)}
|
||||||
</span>
|
</span>
|
||||||
{intl.formatMessage(messages.reason)}: {content.lastEdit.reason}
|
{intl.formatMessage(messages.reason)}: {content.lastEdit.reason}
|
||||||
</div>
|
</div>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
{content.closed && (
|
{content.closed && (
|
||||||
<Alert variant="info" className="px-3 shadow-none mb-2 py-10px bg-light-200">
|
<Alert variant="info" className="px-3 shadow-none mb-1 py-10px bg-light-200">
|
||||||
<div className="d-flex align-items-center flex-wrap">
|
<div className="d-flex align-items-center flex-wrap text-gray-700 font-family-inter">
|
||||||
{intl.formatMessage(messages.closedBy)}
|
{intl.formatMessage(messages.closedBy)}
|
||||||
<span className="ml-1 ">
|
<span className="ml-1 ">
|
||||||
<AuthorLabel author={content.closedBy} linkToProfile />
|
<AuthorLabel author={content.closedBy} linkToProfile postOrComment />
|
||||||
</span>
|
</span>
|
||||||
<span className="mx-1" />
|
<span
|
||||||
|
className="mx-1.5 font-family-inter font-size-8 font-style-normal text-light-700"
|
||||||
|
style={{ lineHeight: '15px' }}
|
||||||
|
>
|
||||||
|
{intl.formatMessage(messages.fullStop)}
|
||||||
|
</span>
|
||||||
|
|
||||||
{content.closeReason && (`${intl.formatMessage(messages.reason)}: ${content.closeReason}`)}
|
{content.closeReason && (`${intl.formatMessage(messages.reason)}: ${content.closeReason}`)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import PropTypes from 'prop-types';
|
|||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
|
import * as timeago from 'timeago.js';
|
||||||
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||||
import { Icon } from '@edx/paragon';
|
import { Icon, OverlayTrigger, Tooltip } from '@edx/paragon';
|
||||||
import { Institution, School } from '@edx/paragon/icons';
|
import { Institution, School } from '@edx/paragon/icons';
|
||||||
|
|
||||||
import { Routes } from '../../data/constants';
|
import { Routes } from '../../data/constants';
|
||||||
@@ -13,6 +14,7 @@ import { useShowLearnersTab } from '../data/hooks';
|
|||||||
import messages from '../messages';
|
import messages from '../messages';
|
||||||
import { discussionsPath } from '../utils';
|
import { discussionsPath } from '../utils';
|
||||||
import { DiscussionContext } from './context';
|
import { DiscussionContext } from './context';
|
||||||
|
import timeLocale from './time-locale';
|
||||||
|
|
||||||
function AuthorLabel({
|
function AuthorLabel({
|
||||||
intl,
|
intl,
|
||||||
@@ -21,11 +23,15 @@ function AuthorLabel({
|
|||||||
linkToProfile,
|
linkToProfile,
|
||||||
labelColor,
|
labelColor,
|
||||||
alert,
|
alert,
|
||||||
|
postCreatedAt,
|
||||||
|
authorToolTip,
|
||||||
|
postOrComment,
|
||||||
}) {
|
}) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { courseId } = useContext(DiscussionContext);
|
const { courseId } = useContext(DiscussionContext);
|
||||||
let icon = null;
|
let icon = null;
|
||||||
let authorLabelMessage = null;
|
let authorLabelMessage = null;
|
||||||
|
timeago.register('time-locale', timeLocale);
|
||||||
|
|
||||||
if (authorLabel === 'Staff') {
|
if (authorLabel === 'Staff') {
|
||||||
icon = Institution;
|
icon = Institution;
|
||||||
@@ -37,37 +43,56 @@ function AuthorLabel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isRetiredUser = author ? author.startsWith('retired__user') : false;
|
const isRetiredUser = author ? author.startsWith('retired__user') : false;
|
||||||
|
const showTextPrimary = !authorLabelMessage && !isRetiredUser && !alert;
|
||||||
|
|
||||||
const className = classNames('d-flex align-items-center mb-0.5', labelColor);
|
const className = classNames('d-flex align-items-center', { 'mb-0.5': !postOrComment }, labelColor);
|
||||||
|
|
||||||
const showUserNameAsLink = useShowLearnersTab()
|
const showUserNameAsLink = useShowLearnersTab()
|
||||||
&& linkToProfile && author && author !== intl.formatMessage(messages.anonymous);
|
&& linkToProfile && author && author !== intl.formatMessage(messages.anonymous);
|
||||||
|
|
||||||
const labelContents = (
|
const labelContents = (
|
||||||
<div className={className}>
|
<div className={className} style={{ lineHeight: '24px' }}>
|
||||||
<span
|
{!alert && (
|
||||||
className={classNames('mr-1 font-size-14 font-style-normal font-family-inter font-weight-500', {
|
<span
|
||||||
'text-gray-700': isRetiredUser,
|
className={classNames('mr-1.5 font-size-14 font-style-normal font-family-inter font-weight-500', {
|
||||||
'text-primary-500': !authorLabelMessage && !isRetiredUser && !alert,
|
'text-gray-700': isRetiredUser,
|
||||||
})}
|
'text-primary-500': !authorLabelMessage && !isRetiredUser,
|
||||||
role="heading"
|
})}
|
||||||
aria-level="2"
|
role="heading"
|
||||||
>
|
aria-level="2"
|
||||||
{isRetiredUser ? '[Deactivated]' : author }
|
>
|
||||||
</span>
|
{isRetiredUser ? '[Deactivated]' : author}
|
||||||
{icon && (
|
</span>
|
||||||
<Icon
|
|
||||||
style={{
|
|
||||||
width: '1rem',
|
|
||||||
height: '1rem',
|
|
||||||
}}
|
|
||||||
src={icon}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<OverlayTrigger
|
||||||
|
overlay={(
|
||||||
|
<Tooltip id={`endorsed-by-${author}-tooltip`}>
|
||||||
|
{author}
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
trigger={['hover', 'focus']}
|
||||||
|
>
|
||||||
|
<div className={classNames('d-flex flex-row align-items-center', {
|
||||||
|
'disable-div': !authorToolTip,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
style={{
|
||||||
|
width: '1rem',
|
||||||
|
height: '1rem',
|
||||||
|
}}
|
||||||
|
src={icon}
|
||||||
|
data-testid="author-icon"
|
||||||
|
/>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</OverlayTrigger>
|
||||||
{authorLabelMessage && (
|
{authorLabelMessage && (
|
||||||
<span
|
<span
|
||||||
className={classNames('mr-1 font-size-14 font-style-normal font-family-inter font-weight-500', {
|
className={classNames('mr-1.5 font-size-14 font-style-normal font-family-inter font-weight-500', {
|
||||||
'text-primary-500': !authorLabelMessage && !isRetiredUser && !alert,
|
'text-primary-500': showTextPrimary,
|
||||||
'text-gray-700': isRetiredUser,
|
'text-gray-700': isRetiredUser,
|
||||||
})}
|
})}
|
||||||
style={{ marginLeft: '2px' }}
|
style={{ marginLeft: '2px' }}
|
||||||
@@ -75,6 +100,19 @@ function AuthorLabel({
|
|||||||
{authorLabelMessage}
|
{authorLabelMessage}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{postCreatedAt && (
|
||||||
|
<span
|
||||||
|
title={postCreatedAt}
|
||||||
|
className={classNames('font-family-inter align-content-center', {
|
||||||
|
'text-white': alert,
|
||||||
|
'text-gray-500': !alert,
|
||||||
|
})}
|
||||||
|
style={{ lineHeight: '20px', fontSize: '12px', marginBottom: '-2.3px' }}
|
||||||
|
>
|
||||||
|
{timeago.format(postCreatedAt, 'time-locale')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -100,6 +138,9 @@ AuthorLabel.propTypes = {
|
|||||||
linkToProfile: PropTypes.bool,
|
linkToProfile: PropTypes.bool,
|
||||||
labelColor: PropTypes.string,
|
labelColor: PropTypes.string,
|
||||||
alert: PropTypes.bool,
|
alert: PropTypes.bool,
|
||||||
|
postCreatedAt: PropTypes.string,
|
||||||
|
authorToolTip: PropTypes.bool,
|
||||||
|
postOrComment: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
AuthorLabel.defaultProps = {
|
AuthorLabel.defaultProps = {
|
||||||
@@ -107,6 +148,9 @@ AuthorLabel.defaultProps = {
|
|||||||
authorLabel: null,
|
authorLabel: null,
|
||||||
labelColor: '',
|
labelColor: '',
|
||||||
alert: false,
|
alert: false,
|
||||||
|
postCreatedAt: null,
|
||||||
|
authorToolTip: false,
|
||||||
|
postOrComment: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(AuthorLabel);
|
export default injectIntl(AuthorLabel);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
|
|||||||
import * as timeago from 'timeago.js';
|
import * as timeago from 'timeago.js';
|
||||||
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||||
import { Alert } from '@edx/paragon';
|
import { Alert, Icon } from '@edx/paragon';
|
||||||
import { CheckCircle, Verified } from '@edx/paragon/icons';
|
import { CheckCircle, Verified } from '@edx/paragon/icons';
|
||||||
|
|
||||||
import { ThreadType } from '../../data/constants';
|
import { ThreadType } from '../../data/constants';
|
||||||
@@ -27,32 +27,35 @@ function EndorsedAlertBanner({
|
|||||||
content.endorsed && (
|
content.endorsed && (
|
||||||
<Alert
|
<Alert
|
||||||
variant="plain"
|
variant="plain"
|
||||||
className={`px-3 mb-0 py-10px align-items-center shadow-none ${classes}`}
|
className={`px-2.5 mb-0 py-8px align-items-center shadow-none ${classes}`}
|
||||||
style={{ borderRadius: '0.375rem 0.375rem 0 0' }}
|
style={{ borderRadius: '0.375rem 0.375rem 0 0' }}
|
||||||
icon={iconClass}
|
|
||||||
>
|
>
|
||||||
<div className="d-flex justify-content-between flex-wrap">
|
<div className="d-flex justify-content-between flex-wrap">
|
||||||
<strong className="lead">{intl.formatMessage(
|
<div className="d-flex align-items-center">
|
||||||
isQuestion
|
<Icon
|
||||||
? messages.answer
|
src={iconClass}
|
||||||
: messages.endorsed,
|
style={{
|
||||||
)}
|
width: '21px',
|
||||||
</strong>
|
height: '20px',
|
||||||
<span className="d-flex align-items-center mr-1 flex-wrap">
|
}}
|
||||||
<span className="mr-1">
|
/>
|
||||||
{intl.formatMessage(
|
<strong className="ml-2 font-family-inter">{intl.formatMessage(
|
||||||
isQuestion
|
isQuestion
|
||||||
? messages.answeredLabel
|
? messages.answer
|
||||||
: messages.endorsedLabel,
|
: messages.endorsed,
|
||||||
)}
|
)}
|
||||||
</span>
|
</strong>
|
||||||
|
</div>
|
||||||
|
<span className="d-flex align-items-center align-items-center flex-wrap" style={{ marginRight: '-1px' }}>
|
||||||
<AuthorLabel
|
<AuthorLabel
|
||||||
author={content.endorsedBy}
|
author={content.endorsedBy}
|
||||||
authorLabel={content.endorsedByLabel}
|
authorLabel={content.endorsedByLabel}
|
||||||
linkToProfile
|
linkToProfile
|
||||||
alert={content.endorsed}
|
alert={content.endorsed}
|
||||||
|
postCreatedAt={content.endorsedAt}
|
||||||
|
authorToolTip
|
||||||
|
postOrComment
|
||||||
/>
|
/>
|
||||||
{intl.formatMessage(messages.time, { time: timeago.format(content.endorsedAt, 'time-locale') })}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|||||||
@@ -46,21 +46,21 @@ describe.each([
|
|||||||
type: 'comment',
|
type: 'comment',
|
||||||
postType: ThreadType.QUESTION,
|
postType: ThreadType.QUESTION,
|
||||||
props: { endorsed: true, endorsedBy: 'test-user', endorsedByLabel: 'Staff' },
|
props: { endorsed: true, endorsedBy: 'test-user', endorsedByLabel: 'Staff' },
|
||||||
expectText: [messages.answer.defaultMessage, messages.answeredLabel.defaultMessage, 'test-user', 'Staff'],
|
expectText: [messages.answer.defaultMessage, 'Staff'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'TA endorsed comment in a question thread',
|
label: 'TA endorsed comment in a question thread',
|
||||||
type: 'comment',
|
type: 'comment',
|
||||||
postType: ThreadType.QUESTION,
|
postType: ThreadType.QUESTION,
|
||||||
props: { endorsed: true, endorsedBy: 'test-user', endorsedByLabel: 'Community TA' },
|
props: { endorsed: true, endorsedBy: 'test-user', endorsedByLabel: 'Community TA' },
|
||||||
expectText: [messages.answer.defaultMessage, messages.answeredLabel.defaultMessage, 'test-user', 'TA'],
|
expectText: [messages.answer.defaultMessage, 'TA'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'endorsed comment in a discussion thread',
|
label: 'endorsed comment in a discussion thread',
|
||||||
type: 'comment',
|
type: 'comment',
|
||||||
postType: ThreadType.DISCUSSION,
|
postType: ThreadType.DISCUSSION,
|
||||||
props: { endorsed: true, endorsedBy: 'test-user' },
|
props: { endorsed: true, endorsedBy: 'test-user' },
|
||||||
expectText: [messages.endorsed.defaultMessage, messages.endorsedLabel.defaultMessage, 'test-user'],
|
expectText: [messages.endorsed.defaultMessage],
|
||||||
},
|
},
|
||||||
])('EndorsedAlertBanner', ({
|
])('EndorsedAlertBanner', ({
|
||||||
label, type, postType, props, expectText,
|
label, type, postType, props, expectText,
|
||||||
|
|||||||
123
src/discussions/common/HoverCard.jsx
Normal file
123
src/discussions/common/HoverCard.jsx
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import React, { useContext } from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
import { injectIntl } from '@edx/frontend-platform/i18n';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Icon, IconButton,
|
||||||
|
} from '@edx/paragon';
|
||||||
|
|
||||||
|
import {
|
||||||
|
StarFilled, StarOutline, ThumbUpFilled, ThumbUpOutline,
|
||||||
|
} from '../../components/icons';
|
||||||
|
import { commentShape } from '../comments/comment/proptypes';
|
||||||
|
import { useUserCanAddThreadInBlackoutDate } from '../data/hooks';
|
||||||
|
import { postShape } from '../posts/post/proptypes';
|
||||||
|
import ActionsDropdown from './ActionsDropdown';
|
||||||
|
import { DiscussionContext } from './context';
|
||||||
|
|
||||||
|
function HoverCard({
|
||||||
|
commentOrPost,
|
||||||
|
actionHandlers,
|
||||||
|
handleResponseCommentButton,
|
||||||
|
addResponseCommentButtonMessage,
|
||||||
|
onLike,
|
||||||
|
onFollow,
|
||||||
|
isClosedPost,
|
||||||
|
endorseIcons,
|
||||||
|
}) {
|
||||||
|
const { enableInContextSidebar } = useContext(DiscussionContext);
|
||||||
|
const userCanAddThreadInBlackoutDate = useUserCanAddThreadInBlackoutDate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="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-normal font-family-inter text-gray-700 font-size-12',
|
||||||
|
{ 'w-100': enableInContextSidebar })}
|
||||||
|
onClick={() => handleResponseCommentButton()}
|
||||||
|
disabled={isClosedPost}
|
||||||
|
style={{
|
||||||
|
lineHeight: '20px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{addResponseCommentButtonMessage}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{endorseIcons && (
|
||||||
|
<div className="hover-button">
|
||||||
|
<IconButton
|
||||||
|
src={endorseIcons.icon}
|
||||||
|
iconAs={Icon}
|
||||||
|
onClick={() => {
|
||||||
|
const actionFunction = actionHandlers[endorseIcons.action];
|
||||||
|
actionFunction();
|
||||||
|
}}
|
||||||
|
className={['endorse', 'unendorse'].includes(endorseIcons.id) ? 'text-dark-500' : 'text-success-500'}
|
||||||
|
size="sm"
|
||||||
|
alt="Endorse"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="hover-button">
|
||||||
|
<IconButton
|
||||||
|
src={commentOrPost.voted ? ThumbUpFilled : ThumbUpOutline}
|
||||||
|
iconAs={Icon}
|
||||||
|
size="sm"
|
||||||
|
alt="Like"
|
||||||
|
iconClassNames="like-icon-dimentions"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onLike();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{commentOrPost.following !== undefined && (
|
||||||
|
<div className="hover-button">
|
||||||
|
<IconButton
|
||||||
|
src={commentOrPost.following ? StarFilled : StarOutline}
|
||||||
|
iconAs={Icon}
|
||||||
|
size="sm"
|
||||||
|
alt="Follow"
|
||||||
|
iconClassNames="follow-icon-dimentions"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onFollow();
|
||||||
|
return true;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="hover-button ml-auto">
|
||||||
|
<ActionsDropdown commentOrPost={commentOrPost} actionHandlers={actionHandlers} dropDownIconSize />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
HoverCard.propTypes = {
|
||||||
|
commentOrPost: PropTypes.oneOfType([commentShape, postShape]).isRequired,
|
||||||
|
actionHandlers: PropTypes.objectOf(PropTypes.func).isRequired,
|
||||||
|
handleResponseCommentButton: PropTypes.func.isRequired,
|
||||||
|
onLike: PropTypes.func.isRequired,
|
||||||
|
onFollow: PropTypes.func,
|
||||||
|
addResponseCommentButtonMessage: PropTypes.string.isRequired,
|
||||||
|
isClosedPost: PropTypes.bool.isRequired,
|
||||||
|
endorseIcons: PropTypes.objectOf(PropTypes.any),
|
||||||
|
};
|
||||||
|
|
||||||
|
HoverCard.defaultProps = {
|
||||||
|
onFollow: () => null,
|
||||||
|
endorseIcons: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default injectIntl(HoverCard);
|
||||||
194
src/discussions/common/HoverCard.test.jsx
Normal file
194
src/discussions/common/HoverCard.test.jsx
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import {
|
||||||
|
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';
|
||||||
|
import { Factory } from 'rosie';
|
||||||
|
|
||||||
|
import { camelCaseObject, initializeMockApp } from '@edx/frontend-platform';
|
||||||
|
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||||
|
import { AppProvider } from '@edx/frontend-platform/react';
|
||||||
|
|
||||||
|
import { initializeStore } from '../../store';
|
||||||
|
import { executeThunk } from '../../test-utils';
|
||||||
|
import { getCommentsApiUrl } from '../comments/data/api';
|
||||||
|
import DiscussionContent from '../discussions-home/DiscussionContent';
|
||||||
|
import { getThreadsApiUrl } from '../posts/data/api';
|
||||||
|
import { fetchThreads } from '../posts/data/thunks';
|
||||||
|
import { DiscussionContext } from './context';
|
||||||
|
|
||||||
|
import '../posts/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';
|
||||||
|
let store;
|
||||||
|
let axiosMock;
|
||||||
|
let container;
|
||||||
|
|
||||||
|
function mockAxiosReturnPagedComments() {
|
||||||
|
[null, false, true].forEach(endorsed => {
|
||||||
|
const postId = endorsed === null ? discussionPostId : questionPostId;
|
||||||
|
[1, 2].forEach(page => {
|
||||||
|
axiosMock
|
||||||
|
.onGet(commentsApiUrl, {
|
||||||
|
params: {
|
||||||
|
thread_id: postId,
|
||||||
|
page,
|
||||||
|
page_size: undefined,
|
||||||
|
requested_fields: 'profile_image',
|
||||||
|
endorsed,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.reply(200, Factory.build('commentsResult', { can_delete: true }, {
|
||||||
|
threadId: postId,
|
||||||
|
page,
|
||||||
|
pageSize: 1,
|
||||||
|
count: 2,
|
||||||
|
endorsed,
|
||||||
|
childCount: page === 1 ? 2 : 0,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockAxiosReturnPagedCommentsResponses() {
|
||||||
|
const parentId = 'comment-1';
|
||||||
|
const commentsResponsesApiUrl = `${commentsApiUrl}${parentId}/`;
|
||||||
|
const paramsTemplate = {
|
||||||
|
page: undefined,
|
||||||
|
page_size: undefined,
|
||||||
|
requested_fields: 'profile_image',
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let page = 1; page <= 2; page++) {
|
||||||
|
axiosMock
|
||||||
|
.onGet(commentsResponsesApiUrl, { params: { ...paramsTemplate, page } })
|
||||||
|
.reply(200, Factory.build('commentsResult', null, {
|
||||||
|
parentId,
|
||||||
|
page,
|
||||||
|
pageSize: 1,
|
||||||
|
count: 2,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderComponent(postId) {
|
||||||
|
const wrapper = render(
|
||||||
|
<IntlProvider locale="en">
|
||||||
|
<AppProvider store={store}>
|
||||||
|
<DiscussionContext.Provider
|
||||||
|
value={{ courseId }}
|
||||||
|
>
|
||||||
|
<MemoryRouter initialEntries={[`/${courseId}/posts/${postId}`]}>
|
||||||
|
<DiscussionContent />
|
||||||
|
<Route
|
||||||
|
path="*"
|
||||||
|
/>
|
||||||
|
</MemoryRouter>
|
||||||
|
</DiscussionContext.Provider>
|
||||||
|
</AppProvider>
|
||||||
|
</IntlProvider>,
|
||||||
|
);
|
||||||
|
container = wrapper.container;
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('HoverCard', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
initializeMockApp({
|
||||||
|
authenticatedUser: {
|
||||||
|
userId: 3,
|
||||||
|
username: 'abc123',
|
||||||
|
administrator: true,
|
||||||
|
roles: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
store = initializeStore();
|
||||||
|
Factory.resetAll();
|
||||||
|
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||||
|
axiosMock.onGet(threadsApiUrl)
|
||||||
|
.reply(200, Factory.build('threadsResult'));
|
||||||
|
axiosMock.onPatch(new RegExp(`${commentsApiUrl}*`)).reply(({
|
||||||
|
url,
|
||||||
|
data,
|
||||||
|
}) => {
|
||||||
|
const commentId = url.match(/comments\/(?<id>[a-z1-9-]+)\//).groups.id;
|
||||||
|
const {
|
||||||
|
rawBody,
|
||||||
|
} = camelCaseObject(JSON.parse(data));
|
||||||
|
return [200, Factory.build('comment', {
|
||||||
|
id: commentId,
|
||||||
|
rendered_body: rawBody,
|
||||||
|
raw_body: rawBody,
|
||||||
|
})];
|
||||||
|
});
|
||||||
|
axiosMock.onPost(commentsApiUrl)
|
||||||
|
.reply(({ data }) => {
|
||||||
|
const {
|
||||||
|
rawBody,
|
||||||
|
threadId,
|
||||||
|
} = camelCaseObject(JSON.parse(data));
|
||||||
|
return [200, Factory.build(
|
||||||
|
'comment',
|
||||||
|
{
|
||||||
|
rendered_body: rawBody,
|
||||||
|
raw_body: rawBody,
|
||||||
|
thread_id: threadId,
|
||||||
|
},
|
||||||
|
)];
|
||||||
|
});
|
||||||
|
|
||||||
|
executeThunk(fetchThreads(courseId), store.dispatch, store.getState);
|
||||||
|
mockAxiosReturnPagedComments();
|
||||||
|
mockAxiosReturnPagedCommentsResponses();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it should show hover card when hovered on post', async () => {
|
||||||
|
renderComponent(discussionPostId);
|
||||||
|
const post = screen.getByTestId('post-thread-1');
|
||||||
|
userEvent.hover(post);
|
||||||
|
expect(screen.getByTestId('hover-card')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it should show hover card when hovered on comment', async () => {
|
||||||
|
renderComponent(discussionPostId);
|
||||||
|
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');
|
||||||
|
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();
|
||||||
|
expect(within(view).queryByRole('button', { name: /actions menu/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it should show add comment, Endorse, like and actions menu Buttons for hovered comment', async () => {
|
||||||
|
renderComponent(questionPostId);
|
||||||
|
const comment = await waitFor(() => screen.findByTestId('comment-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();
|
||||||
|
expect(within(view).queryByRole('button', { name: /actions menu/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -113,7 +113,7 @@ const messages = defineMessages({
|
|||||||
},
|
},
|
||||||
showPreviewButton: {
|
showPreviewButton: {
|
||||||
id: 'discussions.editor.posts.showPreview.button',
|
id: 'discussions.editor.posts.showPreview.button',
|
||||||
defaultMessage: 'Show Preview',
|
defaultMessage: 'Show preview',
|
||||||
description: 'show preview button text to allow user to see their post content.',
|
description: 'show preview button text to allow user to see their post content.',
|
||||||
},
|
},
|
||||||
actionsAlt: {
|
actionsAlt: {
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import React from 'react';
|
|||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||||
import { Icon, IconButtonWithTooltip } from '@edx/paragon';
|
import {
|
||||||
|
Icon, IconButton, OverlayTrigger, Tooltip,
|
||||||
|
} from '@edx/paragon';
|
||||||
|
|
||||||
import { ThumbUpFilled, ThumbUpOutline } from '../../../components/icons';
|
import { ThumbUpFilled, ThumbUpOutline } from '../../../components/icons';
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
@@ -12,7 +14,6 @@ function LikeButton({
|
|||||||
intl,
|
intl,
|
||||||
onClick,
|
onClick,
|
||||||
voted,
|
voted,
|
||||||
preview,
|
|
||||||
}) {
|
}) {
|
||||||
const handleClick = (e) => {
|
const handleClick = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -23,20 +24,27 @@ function LikeButton({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="d-flex align-items-center mr-4 text-primary-500">
|
<div className="d-flex align-items-center mr-36px text-primary-500">
|
||||||
<IconButtonWithTooltip
|
<OverlayTrigger
|
||||||
id={`like-${count}-tooltip`}
|
overlay={(
|
||||||
tooltipPlacement="top"
|
<Tooltip id={`liked-${count}-tooltip`}>
|
||||||
tooltipContent={intl.formatMessage(voted ? messages.removeLike : messages.like)}
|
{intl.formatMessage(voted ? messages.removeLike : messages.like)}
|
||||||
src={voted ? ThumbUpFilled : ThumbUpOutline}
|
</Tooltip>
|
||||||
iconAs={Icon}
|
)}
|
||||||
alt="Like"
|
>
|
||||||
onClick={handleClick}
|
<IconButton
|
||||||
size={preview ? 'inline' : 'sm'}
|
src={voted ? ThumbUpFilled : ThumbUpOutline}
|
||||||
className={`mr-0.5 ${preview && 'p-3'}`}
|
onClick={handleClick}
|
||||||
iconClassNames={preview && 'icon-size'}
|
className="post-footer-icon-dimentions"
|
||||||
/>
|
alt="Like"
|
||||||
{(count && count > 0) ? count : null}
|
iconAs={Icon}
|
||||||
|
iconClassNames="like-icon-dimentions"
|
||||||
|
/>
|
||||||
|
</OverlayTrigger>
|
||||||
|
<div className="font-family-inter font-style-normal">
|
||||||
|
{(count && count > 0) ? count : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -46,13 +54,11 @@ LikeButton.propTypes = {
|
|||||||
intl: intlShape.isRequired,
|
intl: intlShape.isRequired,
|
||||||
onClick: PropTypes.func,
|
onClick: PropTypes.func,
|
||||||
voted: PropTypes.bool,
|
voted: PropTypes.bool,
|
||||||
preview: PropTypes.bool,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
LikeButton.defaultProps = {
|
LikeButton.defaultProps = {
|
||||||
voted: false,
|
voted: false,
|
||||||
onClick: undefined,
|
onClick: undefined,
|
||||||
preview: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(LikeButton);
|
export default injectIntl(LikeButton);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useContext } from 'react';
|
import React, { useContext, useState } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
@@ -13,6 +13,7 @@ import { ContentActions } from '../../../data/constants';
|
|||||||
import { selectorForUnitSubsection, selectTopicContext } from '../../../data/selectors';
|
import { selectorForUnitSubsection, selectTopicContext } from '../../../data/selectors';
|
||||||
import { AlertBanner, Confirmation } from '../../common';
|
import { AlertBanner, Confirmation } from '../../common';
|
||||||
import { DiscussionContext } from '../../common/context';
|
import { DiscussionContext } from '../../common/context';
|
||||||
|
import HoverCard from '../../common/HoverCard';
|
||||||
import { selectModerationSettings } from '../../data/selectors';
|
import { selectModerationSettings } from '../../data/selectors';
|
||||||
import { selectTopic } from '../../topics/data/selectors';
|
import { selectTopic } from '../../topics/data/selectors';
|
||||||
import { removeThread, updateExistingThread } from '../data/thunks';
|
import { removeThread, updateExistingThread } from '../data/thunks';
|
||||||
@@ -26,6 +27,7 @@ function Post({
|
|||||||
post,
|
post,
|
||||||
preview,
|
preview,
|
||||||
intl,
|
intl,
|
||||||
|
handleAddResponseButton,
|
||||||
}) {
|
}) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
@@ -39,7 +41,7 @@ function Post({
|
|||||||
const [isDeleting, showDeleteConfirmation, hideDeleteConfirmation] = useToggle(false);
|
const [isDeleting, showDeleteConfirmation, hideDeleteConfirmation] = useToggle(false);
|
||||||
const [isReporting, showReportConfirmation, hideReportConfirmation] = useToggle(false);
|
const [isReporting, showReportConfirmation, hideReportConfirmation] = useToggle(false);
|
||||||
const [isClosing, showClosePostModal, hideClosePostModal] = useToggle(false);
|
const [isClosing, showClosePostModal, hideClosePostModal] = useToggle(false);
|
||||||
|
const [showHoverCard, setShowHoverCard] = useState(false);
|
||||||
const handleAbusedFlag = () => {
|
const handleAbusedFlag = () => {
|
||||||
if (post.abuseFlagged) {
|
if (post.abuseFlagged) {
|
||||||
dispatch(updateExistingThread(post.id, { flagged: !post.abuseFlagged }));
|
dispatch(updateExistingThread(post.id, { flagged: !post.abuseFlagged }));
|
||||||
@@ -62,6 +64,12 @@ function Post({
|
|||||||
hideReportConfirmation();
|
hideReportConfirmation();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleBlurEvent = (e) => {
|
||||||
|
if (!e.currentTarget.contains(e.relatedTarget)) {
|
||||||
|
setShowHoverCard(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const actionHandlers = {
|
const actionHandlers = {
|
||||||
[ContentActions.EDIT_CONTENT]: () => history.push({
|
[ContentActions.EDIT_CONTENT]: () => history.push({
|
||||||
...location,
|
...location,
|
||||||
@@ -87,7 +95,15 @@ function Post({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="d-flex flex-column w-100 mw-100" data-testid={`post-${post.id}`}>
|
<div
|
||||||
|
className="d-flex flex-column w-100 mw-100 post-card-comment"
|
||||||
|
aria-level={5}
|
||||||
|
data-testid={`post-${post.id}`}
|
||||||
|
onMouseEnter={() => setShowHoverCard(true)}
|
||||||
|
onMouseLeave={() => setShowHoverCard(false)}
|
||||||
|
onFocus={() => setShowHoverCard(true)}
|
||||||
|
onBlur={(e) => handleBlurEvent(e)}
|
||||||
|
>
|
||||||
<Confirmation
|
<Confirmation
|
||||||
isOpen={isDeleting}
|
isOpen={isDeleting}
|
||||||
title={intl.formatMessage(messages.deletePostTitle)}
|
title={intl.formatMessage(messages.deletePostTitle)}
|
||||||
@@ -107,16 +123,29 @@ function Post({
|
|||||||
confirmButtonVariant="danger"
|
confirmButtonVariant="danger"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{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} />
|
<AlertBanner content={post} />
|
||||||
<PostHeader post={post} actionHandlers={actionHandlers} />
|
<PostHeader post={post} />
|
||||||
<div className="d-flex mt-4 mb-2 text-break font-style-normal 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" />
|
<HTMLLoader htmlNode={post.renderedBody} componentId="post" cssClassName="html-loader" testId={post.id} />
|
||||||
</div>
|
</div>
|
||||||
{topicContext && topic && (
|
{topicContext && topic && (
|
||||||
<div className={classNames('border px-3 rounded mb-4 border-light-400 align-self-start py-2.5',
|
<div
|
||||||
{ 'w-100': enableInContextSidebar })}
|
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">{intl.formatMessage(messages.relatedTo)}{' '}</span>
|
<span className="text-gray-500" style={{ lineHeight: '20px' }}>{intl.formatMessage(messages.relatedTo)}{' '}</span>
|
||||||
<Hyperlink
|
<Hyperlink
|
||||||
destination={topicContext.unitLink}
|
destination={topicContext.unitLink}
|
||||||
target="_top"
|
target="_top"
|
||||||
@@ -135,9 +164,7 @@ function Post({
|
|||||||
</Hyperlink>
|
</Hyperlink>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="mb-3">
|
<PostFooter post={post} preview={preview} />
|
||||||
<PostFooter post={post} preview={preview} />
|
|
||||||
</div>
|
|
||||||
<ClosePostReasonModal
|
<ClosePostReasonModal
|
||||||
isOpen={isClosing}
|
isOpen={isClosing}
|
||||||
onCancel={hideClosePostModal}
|
onCancel={hideClosePostModal}
|
||||||
@@ -154,6 +181,7 @@ Post.propTypes = {
|
|||||||
intl: intlShape.isRequired,
|
intl: intlShape.isRequired,
|
||||||
post: postShape.isRequired,
|
post: postShape.isRequired,
|
||||||
preview: PropTypes.bool,
|
preview: PropTypes.bool,
|
||||||
|
handleAddResponseButton: PropTypes.func.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
Post.defaultProps = {
|
Post.defaultProps = {
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
|
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
import * as timeago from 'timeago.js';
|
|
||||||
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||||
import {
|
import {
|
||||||
Badge, Icon, IconButtonWithTooltip, OverlayTrigger, Tooltip,
|
Icon, IconButton, OverlayTrigger, Tooltip,
|
||||||
} from '@edx/paragon';
|
} from '@edx/paragon';
|
||||||
import {
|
import {
|
||||||
Locked,
|
Locked,
|
||||||
@@ -14,12 +12,9 @@ import {
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
People,
|
People,
|
||||||
QuestionAnswer,
|
|
||||||
QuestionAnswerOutline,
|
|
||||||
StarFilled,
|
StarFilled,
|
||||||
StarOutline,
|
StarOutline,
|
||||||
} from '../../../components/icons';
|
} from '../../../components/icons';
|
||||||
import timeLocale from '../../common/time-locale';
|
|
||||||
import { selectUserHasModerationPrivileges } from '../../data/selectors';
|
import { selectUserHasModerationPrivileges } from '../../data/selectors';
|
||||||
import { updateExistingThread } from '../data/thunks';
|
import { updateExistingThread } from '../data/thunks';
|
||||||
import LikeButton from './LikeButton';
|
import LikeButton from './LikeButton';
|
||||||
@@ -29,56 +24,39 @@ import { postShape } from './proptypes';
|
|||||||
function PostFooter({
|
function PostFooter({
|
||||||
post,
|
post,
|
||||||
intl,
|
intl,
|
||||||
preview,
|
|
||||||
showNewCountLabel,
|
|
||||||
}) {
|
}) {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
|
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
|
||||||
timeago.register('time-locale', timeLocale);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="d-flex align-items-center">
|
<div className="d-flex align-items-center ml-n1.5 mt-10px" style={{ lineHeight: '32px' }}>
|
||||||
<LikeButton
|
{post.voteCount !== 0 && (
|
||||||
count={post.voteCount}
|
<LikeButton
|
||||||
onClick={() => dispatch(updateExistingThread(post.id, { voted: !post.voted }))}
|
count={post.voteCount}
|
||||||
voted={post.voted}
|
onClick={() => dispatch(updateExistingThread(post.id, { voted: !post.voted }))}
|
||||||
preview={preview}
|
voted={post.voted}
|
||||||
/>
|
/>
|
||||||
<IconButtonWithTooltip
|
|
||||||
id={`follow-${post.id}-tooltip`}
|
|
||||||
tooltipPlacement="top"
|
|
||||||
tooltipContent={intl.formatMessage(post.following ? messages.unFollow : messages.follow)}
|
|
||||||
src={post.following ? StarFilled : StarOutline}
|
|
||||||
iconAs={Icon}
|
|
||||||
alt="Follow"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
dispatch(updateExistingThread(post.id, { following: !post.following }));
|
|
||||||
return true;
|
|
||||||
}}
|
|
||||||
size={preview ? 'inline' : 'sm'}
|
|
||||||
className={preview && 'p-3'}
|
|
||||||
iconClassNames={preview && 'icon-size'}
|
|
||||||
/>
|
|
||||||
{preview && post.commentCount > 1 && (
|
|
||||||
<div className="d-flex align-items-center ml-4">
|
|
||||||
<IconButtonWithTooltip
|
|
||||||
tooltipPlacement="top"
|
|
||||||
tooltipContent={intl.formatMessage(messages.viewActivity)}
|
|
||||||
src={post.unreadCommentCount ? QuestionAnswer : QuestionAnswerOutline}
|
|
||||||
iconAs={Icon}
|
|
||||||
alt="Comment Count"
|
|
||||||
size="inline"
|
|
||||||
className="p-3 mr-0.5"
|
|
||||||
iconClassNames="icon-size"
|
|
||||||
/>
|
|
||||||
{post.commentCount}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{showNewCountLabel && preview && post?.unreadCommentCount > 0 && post.commentCount > 1 && (
|
{post.following && (
|
||||||
<Badge variant="light" className="ml-2">
|
<OverlayTrigger
|
||||||
{intl.formatMessage(messages.newLabel, { count: post.unreadCommentCount })}
|
overlay={(
|
||||||
</Badge>
|
<Tooltip id={`follow-${post.id}-tooltip`}>
|
||||||
|
{intl.formatMessage(post.following ? messages.unFollow : messages.follow)}
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
src={post.following ? StarFilled : StarOutline}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dispatch(updateExistingThread(post.id, { following: !post.following }));
|
||||||
|
return true;
|
||||||
|
}}
|
||||||
|
iconAs={Icon}
|
||||||
|
iconClassNames="follow-icon-dimentions"
|
||||||
|
className="post-footer-icon-dimentions"
|
||||||
|
alt="Follow"
|
||||||
|
/>
|
||||||
|
</OverlayTrigger>
|
||||||
)}
|
)}
|
||||||
<div className="d-flex flex-fill justify-content-end align-items-center">
|
<div className="d-flex flex-fill justify-content-end align-items-center">
|
||||||
{post.groupId && userHasModerationPrivileges && (
|
{post.groupId && userHasModerationPrivileges && (
|
||||||
@@ -100,10 +78,8 @@ function PostFooter({
|
|||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<span title={post.createdAt} className="text-gray-700">
|
|
||||||
{timeago.format(post.createdAt, 'time-locale')}
|
{post.closed
|
||||||
</span>
|
|
||||||
{!preview && post.closed
|
|
||||||
&& (
|
&& (
|
||||||
<OverlayTrigger
|
<OverlayTrigger
|
||||||
overlay={(
|
overlay={(
|
||||||
@@ -130,13 +106,7 @@ function PostFooter({
|
|||||||
PostFooter.propTypes = {
|
PostFooter.propTypes = {
|
||||||
intl: intlShape.isRequired,
|
intl: intlShape.isRequired,
|
||||||
post: postShape.isRequired,
|
post: postShape.isRequired,
|
||||||
preview: PropTypes.bool,
|
|
||||||
showNewCountLabel: PropTypes.bool,
|
|
||||||
};
|
|
||||||
|
|
||||||
PostFooter.defaultProps = {
|
|
||||||
preview: false,
|
|
||||||
showNewCountLabel: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(PostFooter);
|
export default injectIntl(PostFooter);
|
||||||
|
|||||||
@@ -64,11 +64,6 @@ describe('PostFooter', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows 'x new' badge for new comments in case of read post only", () => {
|
|
||||||
renderComponent(mockPost, true, true);
|
|
||||||
expect(screen.getByText('2 New')).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("doesn't have 'new' badge when there are 0 new comments", () => {
|
it("doesn't have 'new' badge when there are 0 new comments", () => {
|
||||||
renderComponent({ ...mockPost, unreadCommentCount: 0 });
|
renderComponent({ ...mockPost, unreadCommentCount: 0 });
|
||||||
expect(screen.queryByText('2 New')).toBeFalsy();
|
expect(screen.queryByText('2 New')).toBeFalsy();
|
||||||
@@ -89,18 +84,24 @@ describe('PostFooter', () => {
|
|||||||
expect(screen.getByTestId('cohort-icon')).toBeTruthy();
|
expect(screen.getByTestId('cohort-icon')).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([[true, /unfollow/i], [false, /follow/i]])('test follow button when following=%s', async (following, message) => {
|
it('test follow button when following=true', async () => {
|
||||||
renderComponent({ ...mockPost, following });
|
renderComponent({ ...mockPost, following: true });
|
||||||
const followButton = screen.getByRole('button', { name: /follow/i });
|
const followButton = screen.getByRole('button', { name: /follow/i });
|
||||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
|
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.mouseEnter(followButton);
|
fireEvent.mouseEnter(followButton);
|
||||||
});
|
});
|
||||||
expect(screen.getByRole('tooltip')).toHaveTextContent(message);
|
|
||||||
|
expect(screen.getByRole('tooltip')).toHaveTextContent(/unfollow/i);
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(followButton);
|
fireEvent.click(followButton);
|
||||||
});
|
});
|
||||||
// clicking on the button triggers thread update.
|
// clicking on the button triggers thread update.
|
||||||
expect(store.getState().threads.status === RequestStatus.IN_PROGRESS).toBeTruthy();
|
expect(store.getState().threads.status === RequestStatus.IN_PROGRESS).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('test follow button when following=false', async () => {
|
||||||
|
renderComponent({ ...mockPost, following: false });
|
||||||
|
expect(screen.queryByRole('button', { name: /follow/i })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { Avatar, Badge, Icon } from '@edx/paragon';
|
|||||||
|
|
||||||
import { Issue, Question } from '../../../components/icons';
|
import { Issue, Question } from '../../../components/icons';
|
||||||
import { AvatarOutlineAndLabelColors, ThreadType } from '../../../data/constants';
|
import { AvatarOutlineAndLabelColors, ThreadType } from '../../../data/constants';
|
||||||
import { ActionsDropdown, AuthorLabel } from '../../common';
|
import { AuthorLabel } from '../../common';
|
||||||
import { useAlertBannerVisible } from '../../data/hooks';
|
import { useAlertBannerVisible } from '../../data/hooks';
|
||||||
import { selectAuthorAvatars } from '../data/selectors';
|
import { selectAuthorAvatars } from '../data/selectors';
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
@@ -24,7 +24,7 @@ export function PostAvatar({
|
|||||||
const avatarSize = useMemo(() => {
|
const avatarSize = useMemo(() => {
|
||||||
let size = '2rem';
|
let size = '2rem';
|
||||||
if (post.type === ThreadType.DISCUSSION && !fromPostLink) {
|
if (post.type === ThreadType.DISCUSSION && !fromPostLink) {
|
||||||
size = '2.375rem';
|
size = '2rem';
|
||||||
} else if (post.type === ThreadType.QUESTION) {
|
} else if (post.type === ThreadType.QUESTION) {
|
||||||
size = '1.5rem';
|
size = '1.5rem';
|
||||||
}
|
}
|
||||||
@@ -52,11 +52,11 @@ export function PostAvatar({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Avatar
|
<Avatar
|
||||||
className={classNames('border-0', {
|
className={classNames('border-0 mt-1', {
|
||||||
[`outline-${outlineColor}`]: outlineColor,
|
[`outline-${outlineColor}`]: outlineColor,
|
||||||
'outline-anonymous': !outlineColor,
|
'outline-anonymous': !outlineColor,
|
||||||
'mt-3 ml-2': post.type === ThreadType.QUESTION && fromPostLink,
|
'mt-3 ml-2': post.type === ThreadType.QUESTION && fromPostLink,
|
||||||
'avarat-img-position': post.type === ThreadType.QUESTION,
|
'avarat-img-position mt-17px': post.type === ThreadType.QUESTION,
|
||||||
})}
|
})}
|
||||||
style={{
|
style={{
|
||||||
height: avatarSize,
|
height: avatarSize,
|
||||||
@@ -86,14 +86,13 @@ function PostHeader({
|
|||||||
intl,
|
intl,
|
||||||
post,
|
post,
|
||||||
preview,
|
preview,
|
||||||
actionHandlers,
|
|
||||||
}) {
|
}) {
|
||||||
const showAnsweredBadge = preview && post.hasEndorsed && post.type === ThreadType.QUESTION;
|
const showAnsweredBadge = preview && post.hasEndorsed && post.type === ThreadType.QUESTION;
|
||||||
const authorLabelColor = AvatarOutlineAndLabelColors[post.authorLabel];
|
const authorLabelColor = AvatarOutlineAndLabelColors[post.authorLabel];
|
||||||
const hasAnyAlert = useAlertBannerVisible(post);
|
const hasAnyAlert = useAlertBannerVisible(post);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classNames('d-flex flex-fill mw-100', { 'mt-2': hasAnyAlert && !preview })}>
|
<div className={classNames('d-flex flex-fill mw-100', { 'mt-10px': hasAnyAlert && !preview })}>
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<PostAvatar post={post} authorLabel={post.authorLabel} />
|
<PostAvatar post={post} authorLabel={post.authorLabel} />
|
||||||
</div>
|
</div>
|
||||||
@@ -109,21 +108,17 @@ function PostHeader({
|
|||||||
&& <Badge variant="success">{intl.formatMessage(messages.answered)}</Badge>}
|
&& <Badge variant="success">{intl.formatMessage(messages.answered)}</Badge>}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
: <h4 className="mb-0" style={{ lineHeight: '28px' }} aria-level="1" tabIndex="-1" accessKey="h">{post.title}</h4>}
|
: <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
|
<AuthorLabel
|
||||||
author={post.author || intl.formatMessage(messages.anonymous)}
|
author={post.author || intl.formatMessage(messages.anonymous)}
|
||||||
authorLabel={post.authorLabel}
|
authorLabel={post.authorLabel}
|
||||||
labelColor={authorLabelColor && `text-${authorLabelColor}`}
|
labelColor={authorLabelColor && `text-${authorLabelColor}`}
|
||||||
linkToProfile
|
linkToProfile
|
||||||
|
postCreatedAt={post.createdAt}
|
||||||
|
postOrComment
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!preview
|
|
||||||
&& (
|
|
||||||
<div className="ml-auto d-flex">
|
|
||||||
<ActionsDropdown commentOrPost={post} actionHandlers={actionHandlers} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -132,7 +127,6 @@ PostHeader.propTypes = {
|
|||||||
intl: intlShape.isRequired,
|
intl: intlShape.isRequired,
|
||||||
post: postShape.isRequired,
|
post: postShape.isRequired,
|
||||||
preview: PropTypes.bool,
|
preview: PropTypes.bool,
|
||||||
actionHandlers: PropTypes.objectOf(PropTypes.func).isRequired,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
PostHeader.defaultProps = {
|
PostHeader.defaultProps = {
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ const messages = defineMessages({
|
|||||||
defaultMessage: 'anonymous',
|
defaultMessage: 'anonymous',
|
||||||
description: 'Author name displayed when a post is anonymous',
|
description: 'Author name displayed when a post is anonymous',
|
||||||
},
|
},
|
||||||
|
addResponse: {
|
||||||
|
id: 'discussions.post.addResponse',
|
||||||
|
defaultMessage: 'Add response',
|
||||||
|
description: 'Button to add a response in a thread of forum posts',
|
||||||
|
},
|
||||||
lastResponse: {
|
lastResponse: {
|
||||||
id: 'discussions.post.lastResponse',
|
id: 'discussions.post.lastResponse',
|
||||||
defaultMessage: 'Last response {time}',
|
defaultMessage: 'Last response {time}',
|
||||||
|
|||||||
@@ -185,7 +185,6 @@ export function useActions(content) {
|
|||||||
.every(condition => condition === true)
|
.every(condition => condition === true)
|
||||||
: true
|
: true
|
||||||
);
|
);
|
||||||
|
|
||||||
return ACTIONS_LIST.filter(
|
return ACTIONS_LIST.filter(
|
||||||
({
|
({
|
||||||
action,
|
action,
|
||||||
@@ -295,3 +294,7 @@ export function handleKeyDown(event) {
|
|||||||
selectedOption.focus();
|
selectedOption.focus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isLastElementOfList(list, element) {
|
||||||
|
return list[list.length - 1] === element;
|
||||||
|
}
|
||||||
|
|||||||
168
src/index.scss
168
src/index.scss
@@ -45,6 +45,14 @@ $fa-font-path: "~font-awesome/fonts";
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.font-size-12 {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-size-8 {
|
||||||
|
font-size: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.font-weight-500 {
|
.font-weight-500 {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
@@ -57,9 +65,24 @@ $fa-font-path: "~font-awesome/fonts";
|
|||||||
font-family: "Inter";
|
font-family: "Inter";
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-size {
|
.post-footer-icon-dimentions {
|
||||||
height: 20px !important;
|
width: 32px !important;
|
||||||
|
height: 32px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.like-icon-dimentions {
|
||||||
|
width: 21px !important;
|
||||||
|
height: 23px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.follow-icon-dimentions {
|
||||||
|
width: 21px !important;
|
||||||
|
height: 24px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-icon-dimentions {
|
||||||
width: 20px !important;
|
width: 20px !important;
|
||||||
|
height: 21px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-summary-icons-dimensions {
|
.post-summary-icons-dimensions {
|
||||||
@@ -77,6 +100,20 @@ $fa-font-path: "~font-awesome/fonts";
|
|||||||
border-right-style: solid;
|
border-right-style: solid;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.my-14px {
|
||||||
|
margin-top: 14px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.my-10px {
|
||||||
|
margin-top: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-14px {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.mr-0\.5 {
|
.mr-0\.5 {
|
||||||
margin-right: 2px;
|
margin-right: 2px;
|
||||||
}
|
}
|
||||||
@@ -93,6 +130,26 @@ $fa-font-path: "~font-awesome/fonts";
|
|||||||
margin-left: 2px;
|
margin-left: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mt-14px {
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-10px {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mt-10px {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mt-17px {
|
||||||
|
margin-top: 17px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mr-36px {
|
||||||
|
margin-right: 36.6px;
|
||||||
|
}
|
||||||
|
|
||||||
.badge-padding {
|
.badge-padding {
|
||||||
padding-top: 1px;
|
padding-top: 1px;
|
||||||
padding-bottom: 1px
|
padding-bottom: 1px
|
||||||
@@ -102,7 +159,7 @@ $fa-font-path: "~font-awesome/fonts";
|
|||||||
background-color: unset !important;
|
background-color: unset !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.learner > a:hover {
|
.learner>a:hover {
|
||||||
background-color: #F2F0EF;
|
background-color: #F2F0EF;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,14 +168,27 @@ $fa-font-path: "~font-awesome/fonts";
|
|||||||
padding-bottom: 10px;
|
padding-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.py-8px {
|
||||||
|
padding-top: 8px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pb-10px {
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pt-10px {
|
||||||
|
padding-top: 10px !important;
|
||||||
|
}
|
||||||
|
|
||||||
.px-10px {
|
.px-10px {
|
||||||
padding-left: 10px;
|
padding-left: 10px;
|
||||||
padding-right: 10px;
|
padding-right: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.question-icon-size {
|
.question-icon-size {
|
||||||
width: 1.625rem;
|
width: 1.4581rem;
|
||||||
height: 1.625rem;
|
height: 1.4581rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.question-icon-position {
|
.question-icon-position {
|
||||||
@@ -134,6 +204,7 @@ $fa-font-path: "~font-awesome/fonts";
|
|||||||
header {
|
header {
|
||||||
.logo {
|
.logo {
|
||||||
margin-right: 1rem;
|
margin-right: 1rem;
|
||||||
|
|
||||||
img {
|
img {
|
||||||
height: 1.75rem;
|
height: 1.75rem;
|
||||||
}
|
}
|
||||||
@@ -142,6 +213,7 @@ header {
|
|||||||
|
|
||||||
#learner-posts-link {
|
#learner-posts-link {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
|
|
||||||
span[role=heading]:hover {
|
span[role=heading]:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
@@ -170,11 +242,12 @@ header {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.pointer-cursor-hover :hover{
|
.pointer-cursor-hover :hover {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-bar:focus-visible, .filter-bar:focus {
|
.filter-bar:focus-visible,
|
||||||
|
.filter-bar:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,9 +271,9 @@ header {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
.container-xl{
|
.container-xl {
|
||||||
.course-title-lockup {
|
.course-title-lockup {
|
||||||
font-size: 1.125 rem;
|
font-size: 1.125rem;
|
||||||
};
|
};
|
||||||
|
|
||||||
.logo {
|
.logo {
|
||||||
@@ -221,7 +294,7 @@ header {
|
|||||||
|
|
||||||
.container-xl {
|
.container-xl {
|
||||||
padding-left: 31px;
|
padding-left: 31px;
|
||||||
font-size: 1.125 rem;
|
font-size: 1.125rem;
|
||||||
|
|
||||||
.nav {
|
.nav {
|
||||||
line-height: 28px;
|
line-height: 28px;
|
||||||
@@ -239,7 +312,7 @@ header {
|
|||||||
|
|
||||||
.header-action-bar {
|
.header-action-bar {
|
||||||
background-color: #fff;
|
background-color: #fff;
|
||||||
z-index: 1;
|
z-index: 2;
|
||||||
box-shadow: 0px 2px 4px rgb(0 0 0 / 15%), 0px 2px 8px rgb(0 0 0 / 15%);
|
box-shadow: 0px 2px 4px rgb(0 0 0 / 15%), 0px 2px 8px rgb(0 0 0 / 15%);
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -250,10 +323,10 @@ header {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.actions-dropdown {
|
.actions-dropdown {
|
||||||
z-index: 0;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.discussion-topic-group:last-of-type .divider{
|
.discussion-topic-group:last-of-type .divider {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,6 +342,12 @@ header {
|
|||||||
z-index: 0;
|
z-index: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-icon.btn-icon-primary:hover {
|
||||||
|
background-color: #F2F0EF !important;
|
||||||
|
color: #00262B !important
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@media only screen and (max-width: 767px) {
|
@media only screen and (max-width: 767px) {
|
||||||
body:not(.tox-force-desktop) .tox .tox-dialog {
|
body:not(.tox-force-desktop) .tox .tox-dialog {
|
||||||
align-self: center;
|
align-self: center;
|
||||||
@@ -286,3 +365,66 @@ header {
|
|||||||
.pgn__checkpoint {
|
.pgn__checkpoint {
|
||||||
max-width: 340px !important;
|
max-width: 340px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.post-card-padding {
|
||||||
|
padding: 24px 24px 10px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-card-margin {
|
||||||
|
margin: 24px 24px 0px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover-card {
|
||||||
|
height: 36px;
|
||||||
|
box-shadow: 0px 8px 16px rgba(0, 0, 0, 0.15), 0px 4px 10px rgba(0, 0, 0, 0.15);
|
||||||
|
border-radius: 3px;
|
||||||
|
background: #FFFFFF;
|
||||||
|
max-width: fit-content;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-top: -2.063rem;
|
||||||
|
z-index: 1;
|
||||||
|
right: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.response-editor-position {
|
||||||
|
margin-top: 50px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover-button:hover {
|
||||||
|
background-color: #F2F0EF !important;
|
||||||
|
height: 36px;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-tertiary:hover {
|
||||||
|
background-color: #F2F0EF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-tertiary:disabled {
|
||||||
|
color: #454545;
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disable-div {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.on-focus:focus-visible {
|
||||||
|
outline: 2px solid black;
|
||||||
|
}
|
||||||
|
|
||||||
|
.html-loader p:last-child {
|
||||||
|
margin-bottom: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-card-comment:hover,
|
||||||
|
.post-card-comment:focus {
|
||||||
|
.hover-card {
|
||||||
|
display: flex !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner-dimentions {
|
||||||
|
height: 24px;
|
||||||
|
width: 24px;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user