Compare commits

...

11 Commits

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

View File

@@ -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
View 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'

View File

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

View File

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

View File

@@ -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 (
<div style={{
position: 'absolute',
top: '50%',
}}
>
<Spinner animation="border" variant="primary" data-testid="loading-indicator" /> <Spinner animation="border" variant="primary" data-testid="loading-indicator" />
</div>
); );
} }
@@ -211,15 +260,23 @@ 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 && ( {
thread.type === ThreadType.DISCUSSION && (
<DiscussionCommentsView <DiscussionCommentsView
postId={postId} postId={postId}
intl={intl} intl={intl}
@@ -227,8 +284,10 @@ function CommentsView({ intl }) {
endorsed={EndorsementStatus.DISCUSSION} endorsed={EndorsementStatus.DISCUSSION}
isClosed={thread.closed} isClosed={thread.closed}
/> />
)} )
{thread.type === ThreadType.QUESTION && ( }
{
thread.type === ThreadType.QUESTION && (
<> <>
<DiscussionCommentsView <DiscussionCommentsView
postId={postId} postId={postId}
@@ -245,7 +304,8 @@ function CommentsView({ intl }) {
isClosed={thread.closed} isClosed={thread.closed}
/> />
</> </>
)} )
}
</> </>
); );
} }

View File

@@ -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(); });
// });
}); });
}); });

View File

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

View File

@@ -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,22 +119,47 @@ 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
@@ -131,12 +170,13 @@ function Comment({
/> />
))} ))}
</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,6 +187,7 @@ function Comment({
)} )}
{!isNested && showFullThread && ( {!isNested && showFullThread && (
isReplying ? ( isReplying ? (
<div className="mt-2.5">
<CommentEditor <CommentEditor
comment={{ comment={{
threadId: comment.threadId, threadId: comment.threadId,
@@ -155,15 +196,17 @@ function Comment({
edit={false} edit={false}
onCloseEditor={() => setReplying(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);

View File

@@ -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);

View File

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

View File

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

View File

@@ -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 = {

View File

@@ -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',

View File

@@ -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
@@ -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);

View File

@@ -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)}:&nbsp;{content.lastEdit.reason} {intl.formatMessage(messages.reason)}:&nbsp;{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>
)} )}

View File

@@ -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' }}>
{!alert && (
<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-gray-700': isRetiredUser, 'text-gray-700': isRetiredUser,
'text-primary-500': !authorLabelMessage && !isRetiredUser && !alert, 'text-primary-500': !authorLabelMessage && !isRetiredUser,
})} })}
role="heading" role="heading"
aria-level="2" aria-level="2"
> >
{isRetiredUser ? '[Deactivated]' : author } {isRetiredUser ? '[Deactivated]' : author}
</span> </span>
{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 <Icon
style={{ style={{
width: '1rem', width: '1rem',
height: '1rem', height: '1rem',
}} }}
src={icon} 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);

View File

@@ -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">
<Icon
src={iconClass}
style={{
width: '21px',
height: '20px',
}}
/>
<strong className="ml-2 font-family-inter">{intl.formatMessage(
isQuestion isQuestion
? messages.answer ? messages.answer
: messages.endorsed, : messages.endorsed,
)} )}
</strong> </strong>
<span className="d-flex align-items-center mr-1 flex-wrap"> </div>
<span className="mr-1"> <span className="d-flex align-items-center align-items-center flex-wrap" style={{ marginRight: '-1px' }}>
{intl.formatMessage(
isQuestion
? messages.answeredLabel
: messages.endorsedLabel,
)}
</span>
<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>

View File

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

View 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);

View 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();
});
});

View File

@@ -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: {

View File

@@ -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,21 +24,28 @@ 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)}
</Tooltip>
)}
>
<IconButton
src={voted ? ThumbUpFilled : ThumbUpOutline} src={voted ? ThumbUpFilled : ThumbUpOutline}
iconAs={Icon}
alt="Like"
onClick={handleClick} onClick={handleClick}
size={preview ? 'inline' : 'sm'} className="post-footer-icon-dimentions"
className={`mr-0.5 ${preview && 'p-3'}`} alt="Like"
iconClassNames={preview && 'icon-size'} iconAs={Icon}
iconClassNames="like-icon-dimentions"
/> />
</OverlayTrigger>
<div className="font-family-inter font-style-normal">
{(count && count > 0) ? count : null} {(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);

View File

@@ -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
className={classNames('mt-14px mb-1 font-style-normal font-family-inter font-size-12',
{ 'w-100': enableInContextSidebar })} { '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 = {

View File

@@ -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' }}>
{post.voteCount !== 0 && (
<LikeButton <LikeButton
count={post.voteCount} count={post.voteCount}
onClick={() => dispatch(updateExistingThread(post.id, { voted: !post.voted }))} onClick={() => dispatch(updateExistingThread(post.id, { voted: !post.voted }))}
voted={post.voted} voted={post.voted}
preview={preview}
/> />
<IconButtonWithTooltip )}
id={`follow-${post.id}-tooltip`} {post.following && (
tooltipPlacement="top" <OverlayTrigger
tooltipContent={intl.formatMessage(post.following ? messages.unFollow : messages.follow)} overlay={(
<Tooltip id={`follow-${post.id}-tooltip`}>
{intl.formatMessage(post.following ? messages.unFollow : messages.follow)}
</Tooltip>
)}
>
<IconButton
src={post.following ? StarFilled : StarOutline} src={post.following ? StarFilled : StarOutline}
iconAs={Icon}
alt="Follow"
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
dispatch(updateExistingThread(post.id, { following: !post.following })); dispatch(updateExistingThread(post.id, { following: !post.following }));
return true; 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} iconAs={Icon}
alt="Comment Count" iconClassNames="follow-icon-dimentions"
size="inline" className="post-footer-icon-dimentions"
className="p-3 mr-0.5" alt="Follow"
iconClassNames="icon-size"
/> />
{post.commentCount} </OverlayTrigger>
</div>
)}
{showNewCountLabel && preview && post?.unreadCommentCount > 0 && post.commentCount > 1 && (
<Badge variant="light" className="ml-2">
{intl.formatMessage(messages.newLabel, { count: post.unreadCommentCount })}
</Badge>
)} )}
<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);

View File

@@ -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();
});
}); });

View File

@@ -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 = {

View File

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

View File

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

View File

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