From 62ebd4450fc930c09361b668a06dd7b92dfac12c Mon Sep 17 00:00:00 2001 From: ayesha waris <73840786+ayeshoali@users.noreply.github.com> Date: Mon, 30 Jan 2023 17:30:12 +0500 Subject: [PATCH] style: post content style updates (#407) * style: post content design updates * fix: fixing test cases * fix: preview p changed from capital to small and 2px focus state border * style: comment time moved next to author name * fix: fixed post style according to figma * test: added test cases for hover card component * refactor: added utility func to check if last element of list * fix: fixed blur event for actions dropdown * fix: review fixees * test: fixed test cases post mathjax-v3 merge --------- Co-authored-by: Mehak Nasir --- src/components/HTMLLoader.jsx | 13 +- src/components/PostPreviewPane.jsx | 2 +- src/discussions/comments/CommentsView.jsx | 150 ++- .../comments/CommentsView.test.jsx | 1004 +++++++++-------- .../comments/comment-icons/CommentIcons.jsx | 8 +- src/discussions/comments/comment/Comment.jsx | 114 +- .../comments/comment/CommentHeader.jsx | 52 +- .../comments/comment/CommentHeader.test.jsx | 57 - src/discussions/comments/comment/Reply.jsx | 30 +- .../comments/comment/ResponseEditor.jsx | 38 +- src/discussions/comments/messages.js | 22 +- src/discussions/common/ActionsDropdown.jsx | 11 +- src/discussions/common/AlertBanner.jsx | 29 +- src/discussions/common/AuthorLabel.jsx | 92 +- .../common/EndorsedAlertBanner.jsx | 39 +- .../common/EndorsedAlertBanner.test.jsx | 6 +- src/discussions/common/HoverCard.jsx | 123 ++ src/discussions/common/HoverCard.test.jsx | 194 ++++ src/discussions/posts/post-editor/messages.js | 2 +- src/discussions/posts/post/LikeButton.jsx | 42 +- src/discussions/posts/post/Post.jsx | 52 +- src/discussions/posts/post/PostFooter.jsx | 92 +- .../posts/post/PostFooter.test.jsx | 17 +- src/discussions/posts/post/PostHeader.jsx | 22 +- src/discussions/posts/post/messages.js | 5 + src/discussions/utils.js | 5 +- src/index.scss | 168 ++- 27 files changed, 1479 insertions(+), 910 deletions(-) delete mode 100644 src/discussions/comments/comment/CommentHeader.test.jsx create mode 100644 src/discussions/common/HoverCard.jsx create mode 100644 src/discussions/common/HoverCard.test.jsx diff --git a/src/components/HTMLLoader.jsx b/src/components/HTMLLoader.jsx index 8f57a8a7..48908f43 100644 --- a/src/components/HTMLLoader.jsx +++ b/src/components/HTMLLoader.jsx @@ -10,14 +10,17 @@ const defaultSanitizeOptions = { ADD_ATTR: ['columnalign'], }; -function HTMLLoader({ htmlNode, componentId, cssClassName }) { +function HTMLLoader({ + htmlNode, componentId, cssClassName, testId, +}) { const sanitizedMath = DOMPurify.sanitize(htmlNode, { ...defaultSanitizeOptions }); const previewRef = useRef(); useEffect(() => { let promise = Promise.resolve(); // Used to hold chain of typesetting calls + function typeset(code) { - promise = promise.then(() => window.MathJax.typesetPromise(code())) + promise = promise.then(() => window.MathJax?.typesetPromise(code())) .catch((err) => logError(`Typeset failed: ${err.message}`)); return promise; } @@ -25,10 +28,10 @@ function HTMLLoader({ htmlNode, componentId, cssClassName }) { typeset(() => { previewRef.current.innerHTML = sanitizedMath; }); - }, [sanitizedMath]); + }, [htmlNode]); return ( -
+
); } @@ -37,12 +40,14 @@ HTMLLoader.propTypes = { htmlNode: PropTypes.node, componentId: PropTypes.string, cssClassName: PropTypes.string, + testId: PropTypes.string, }; HTMLLoader.defaultProps = { htmlNode: '', componentId: null, cssClassName: '', + testId: '', }; export default HTMLLoader; diff --git a/src/components/PostPreviewPane.jsx b/src/components/PostPreviewPane.jsx index 8334c280..5126e65d 100644 --- a/src/components/PostPreviewPane.jsx +++ b/src/components/PostPreviewPane.jsx @@ -29,7 +29,7 @@ function PostPreviewPane({ className="float-right p-3" iconClassNames="icon-size" /> - +
)}
diff --git a/src/discussions/comments/CommentsView.jsx b/src/discussions/comments/CommentsView.jsx index 431eb55e..e347e5d6 100644 --- a/src/discussions/comments/CommentsView.jsx +++ b/src/discussions/comments/CommentsView.jsx @@ -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 classNames from 'classnames'; @@ -8,7 +10,8 @@ import { useHistory, useLocation } from 'react-router-dom'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { - Button, Icon, IconButton, Spinner, + Button, Icon, IconButton, + Spinner, } from '@edx/paragon'; import { ArrowBack } from '@edx/paragon/icons'; @@ -17,12 +20,12 @@ import { } from '../../data/constants'; import { useDispatchWithState } from '../../data/hooks'; import { DiscussionContext } from '../common/context'; -import { useIsOnDesktop } from '../data/hooks'; +import { useIsOnDesktop, useUserCanAddThreadInBlackoutDate } from '../data/hooks'; import { EmptyPage } from '../empty-posts'; import { Post } from '../posts'; import { selectThread } from '../posts/data/selectors'; import { fetchThread, markThreadAsRead } from '../posts/data/thunks'; -import { discussionsPath, filterPosts } from '../utils'; +import { discussionsPath, filterPosts, isLastElementOfList } from '../utils'; import { selectThreadComments, selectThreadCurrentPage, selectThreadHasMorePages } from './data/selectors'; import { fetchThreadComments } from './data/thunks'; import { Comment, ResponseEditor } from './comment'; @@ -80,26 +83,41 @@ function DiscussionCommentsView({ const endorsedComments = useMemo(() => [...filterPosts(comments, 'endorsed')], [comments]); const unEndorsedComments = useMemo(() => [...filterPosts(comments, 'unendorsed')], [comments]); + const userCanAddThreadInBlackoutDate = useUserCanAddThreadInBlackoutDate(); + const [addingResponse, setAddingResponse] = useState(false); const handleDefinition = (message, commentsLength) => ( -
+
{intl.formatMessage(message, { num: commentsLength })}
); - const handleComments = (postComments, showAddResponse = false, showLoadMoreResponses = false) => ( + const handleComments = (postComments, showLoadMoreResponses = false) => (
- {postComments.map(comment => ( - + {postComments.map((comment) => ( + + ))} {hasMorePages && !isLoading && !showLoadMoreResponses && ( )} {isLoading && !showLoadMoreResponses && ( -
- +
+
)} - {!!postComments.length && !isClosed && showAddResponse - && }
); return ( @@ -123,15 +139,40 @@ function DiscussionCommentsView({ <> {handleDefinition(messages.endorsedResponseCount, endorsedComments.length)} {endorsed === EndorsementStatus.DISCUSSION - ? handleComments(endorsedComments, false, true) - : handleComments(endorsedComments)} + ? handleComments(endorsedComments, true) + : handleComments(endorsedComments, false)} )} {endorsed !== EndorsementStatus.ENDORSED && ( <> {handleDefinition(messages.responseCount, unEndorsedComments.length)} {unEndorsedComments.length === 0 &&
} - {handleComments(unEndorsedComments, true)} + {handleComments(unEndorsedComments, false)} + {(userCanAddThreadInBlackoutDate && !!unEndorsedComments.length && !isClosed) && ( +
+ {!addingResponse && ( + + )} + + setAddingResponse(false)} + addingResponse={addingResponse} + /> +
+ )} )} @@ -158,12 +199,14 @@ function CommentsView({ intl }) { const history = useHistory(); const location = useLocation(); const isOnDesktop = useIsOnDesktop(); + const [addingResponse, setAddingResponse] = useState(false); const { courseId, learnerUsername, category, topicId, page, enableInContextSidebar, } = useContext(DiscussionContext); useEffect(() => { if (!thread) { submitDispatch(fetchThread(postId, courseId, true)); } + setAddingResponse(false); }, [postId]); if (!thread) { @@ -173,7 +216,13 @@ function CommentsView({ intl }) { ); } return ( - +
+ +
); } @@ -211,41 +260,52 @@ function CommentsView({ intl }) { /> ) )} -
- - {!thread.closed && } + setAddingResponse(true)} /> + {!thread.closed && ( + setAddingResponse(false)} + addingResponse={addingResponse} + /> + )}
- {thread.type === ThreadType.DISCUSSION && ( - - )} - {thread.type === ThreadType.QUESTION && ( - <> + { + thread.type === ThreadType.DISCUSSION && ( - - - )} + ) + } + { + thread.type === ThreadType.QUESTION && ( + <> + + + + ) + } ); } diff --git a/src/discussions/comments/CommentsView.test.jsx b/src/discussions/comments/CommentsView.test.jsx index 90874cef..da849168 100644 --- a/src/discussions/comments/CommentsView.test.jsx +++ b/src/discussions/comments/CommentsView.test.jsx @@ -1,5 +1,5 @@ import { - act, fireEvent, render, screen, waitFor, /* within, */ + act, fireEvent, render, screen, waitFor, within, } from '@testing-library/react'; import MockAdapter from 'axios-mock-adapter'; import { IntlProvider } from 'react-intl'; @@ -32,7 +32,7 @@ const closedPostId = 'thread-2'; const courseId = 'course-v1:edX+TestX+Test_Course'; let store; let axiosMock; -// let testLocation; +let testLocation; function mockAxiosReturnPagedComments() { [null, false, true].forEach(endorsed => { @@ -92,7 +92,10 @@ function renderComponent(postId) { null} + render={({ location }) => { + testLocation = location; + return null; + }} /> @@ -157,104 +160,112 @@ describe('CommentsView', () => { expect(JSON.parse(axiosMock.history.patch[axiosMock.history.patch.length - 1].data)).toMatchObject(data); } - // it('should show and hide the editor', async () => { - // renderComponent(discussionPostId); - // await waitFor(() => screen.findByText('comment number 1', { exact: false })); - // const addResponseButtons = screen.getAllByRole('button', { name: /add a response/i }); - // await act(async () => { - // fireEvent.click( - // addResponseButtons[0], - // ); - // }); - // expect(screen.queryByTestId('tinymce-editor')).toBeInTheDocument(); - // await act(async () => { - // fireEvent.click(screen.getByRole('button', { name: /cancel/i })); - // }); - // expect(screen.queryByTestId('tinymce-editor')).not.toBeInTheDocument(); - // }); + it('should show and hide the editor', async () => { + renderComponent(discussionPostId); + await screen.findByTestId('thread-1'); + const addResponseButtons = screen.getAllByRole('button', { name: /add a response/i }); + await act(async () => { + fireEvent.click( + addResponseButtons[0], + ); + }); + expect(screen.queryByTestId('tinymce-editor')).toBeInTheDocument(); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /cancel/i })); + }); + expect(screen.queryByTestId('tinymce-editor')).not.toBeInTheDocument(); + }); - // it('should allow posting a response', async () => { - // renderComponent(discussionPostId); - // await waitFor(() => screen.findByText('comment number 1', { exact: false })); - // const responseButtons = screen.getAllByRole('button', { name: /add a response/i }); - // await act(async () => { - // fireEvent.click( - // responseButtons[0], - // ); - // }); - // await act(() => { - // fireEvent.change(screen.getByTestId('tinymce-editor'), { target: { value: 'testing123' } }); - // }); - // - // await act(async () => { - // fireEvent.click( - // screen.getByText(/submit/i), - // ); - // }); - // expect(screen.queryByTestId('tinymce-editor')).not.toBeInTheDocument(); - // await waitFor(async () => expect(await screen.findByText('testing123', { exact: false })).toBeInTheDocument()); - // }); + it('should allow posting a response', async () => { + renderComponent(discussionPostId); + await screen.findByTestId('thread-1'); + const responseButtons = screen.getAllByRole('button', { name: /add a response/i }); + await act(async () => { + fireEvent.click( + responseButtons[0], + ); + }); + await act(() => { + fireEvent.change(screen.getByTestId('tinymce-editor'), { target: { value: 'testing123' } }); + }); + + await act(async () => { + fireEvent.click( + screen.getByText(/submit/i), + ); + }); + expect(screen.queryByTestId('tinymce-editor')).not.toBeInTheDocument(); + await waitFor(async () => expect(await screen.findByTestId('comment-1')).toBeInTheDocument()); + }); it('should not allow posting a response on a closed post', async () => { renderComponent(closedPostId); - await waitFor(() => screen.findByText('Thread-2', { exact: false })); - expect(screen.queryByRole('button', { name: /add a response/i })).not.toBeInTheDocument(); + await act(async () => { + fireEvent.mouseOver(await waitFor(() => screen.findByText('Thread-2', { exact: false }))); + }); + expect(screen.queryByRole('button', { name: /add response/i }, { hidden: false })).toBeDisabled(); }); - // it('should allow posting a comment', async () => { - // renderComponent(discussionPostId); - // await waitFor(() => screen.findByText('comment number 1', { exact: false })); - // await act(async () => { - // fireEvent.click( - // screen.getAllByRole('button', { name: /add a comment/i })[0], - // ); - // }); - // act(() => { - // fireEvent.change(screen.getByTestId('tinymce-editor'), { target: { value: 'testing123' } }); - // }); - // - // await act(async () => { - // fireEvent.click( - // screen.getByText(/submit/i), - // ); - // }); - // expect(screen.queryByTestId('tinymce-editor')).not.toBeInTheDocument(); - // await waitFor(async () => expect(await screen.findByText('testing123', { exact: false })).toBeInTheDocument()); - // }); + it('should allow posting a comment', async () => { + renderComponent(discussionPostId); + await act(async () => { + fireEvent.mouseOver(await waitFor(() => screen.findByTestId('comment-1'))); + }); + await act(async () => { + fireEvent.click( + screen.getAllByRole('button', { name: /add comment/i })[0], + ); + }); + act(() => { + fireEvent.change(screen.getByTestId('tinymce-editor'), { target: { value: 'testing123' } }); + }); + + await act(async () => { + fireEvent.click( + screen.getByText(/submit/i), + ); + }); + expect(screen.queryByTestId('tinymce-editor')).not.toBeInTheDocument(); + await waitFor(async () => expect(await screen.findByTestId('comment-comment-1')).toBeInTheDocument()); + }); it('should not allow posting a comment on a closed post', async () => { renderComponent(closedPostId); - await waitFor(() => screen.findByText('thread-2', { exact: false })); + await screen.findByTestId('thread-2'); + await act(async () => { - expect( - screen.queryByRole('button', { name: /add a comment/i }), - ).not.toBeInTheDocument(); + fireEvent.mouseOver(await waitFor(() => screen.findByTestId('comment-3'))); + }); + + const addCommentButton = screen.getAllByRole('button', { name: /add comment/i }, { hidden: false })[0]; + expect(addCommentButton).toBeDisabled(); + }); + + it('should allow editing an existing comment', async () => { + renderComponent(discussionPostId); + 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) { axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`).reply(200, { has_moderation_privileges: true, @@ -271,89 +282,100 @@ describe('CommentsView', () => { axiosMock.onGet(`${courseConfigApiUrl}${courseId}/settings`).reply(200, {}); await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState); } - // - // it('should show reason codes when editing an existing comment', async () => { - // setupCourseConfig(); - // renderComponent(discussionPostId); - // await waitFor(() => screen.findByText('comment number 1', { exact: false })); - // await act(async () => { - // fireEvent.click( - // // The first edit menu is for the post, the second will be for the first comment. - // screen.getAllByRole('button', { name: /actions menu/i })[1], - // ); - // }); - // await act(async () => { - // fireEvent.click(screen.getByRole('button', { name: /edit/i })); - // }); - // expect(screen.queryByRole('combobox', { name: /reason for editing/i })).toBeInTheDocument(); - // expect(screen.getAllByRole('option', { name: /reason \d/i })).toHaveLength(2); - // await act(async () => { - // fireEvent.change(screen.queryByRole('combobox', { name: /reason for editing/i }), - // { target: { value: null } }); - // }); - // await act(async () => { - // fireEvent.change(screen.queryByRole('combobox', - // { name: /reason for editing/i }), { target: { value: 'reason-1' } }); - // }); - // await act(async () => { - // fireEvent.change(screen.getByTestId('tinymce-editor'), { target: { value: 'testing123' } }); - // }); - // await act(async () => { - // fireEvent.click(screen.getByRole('button', { name: /submit/i })); - // }); - // assertLastUpdateData({ edit_reason_code: 'reason-1' }); - // }); - // - // it('should show reason codes when closing a post', async () => { - // setupCourseConfig(); - // renderComponent(discussionPostId); - // await act(async () => { - // fireEvent.click( - // // The first edit menu is for the post - // screen.getAllByRole('button', { - // name: /actions menu/i, - // })[0], - // ); - // }); - // expect(screen.queryByRole('dialog', { name: /close post/i })).not.toBeInTheDocument(); - // await act(async () => { - // fireEvent.click(screen.getByRole('button', { name: /close/i })); - // }); - // expect(screen.queryByRole('dialog', { name: /close post/i })).toBeInTheDocument(); - // expect(screen.queryByRole('combobox', { name: /reason/i })).toBeInTheDocument(); - // expect(screen.getAllByRole('option', { name: /reason \d/i })).toHaveLength(2); - // await act(async () => { - // fireEvent.change(screen.queryByRole('combobox', { name: /reason/i }), { target: { value: 'reason-1' } }); - // }); - // await act(async () => { - // fireEvent.click(screen.getByRole('button', { name: /close post/i })); - // }); - // expect(screen.queryByRole('dialog', { name: /close post/i })).not.toBeInTheDocument(); - // assertLastUpdateData({ closed: true, close_reason_code: 'reason-1' }); - // }); - // it('should close the post directly if reason codes are not enabled', async () => { - // setupCourseConfig(false); - // renderComponent(discussionPostId); - // await act(async () => { - // fireEvent.click( - // // The first edit menu is for the post - // screen.getAllByRole('button', { name: /actions menu/i })[0], - // ); - // }); - // expect(screen.queryByRole('dialog', { name: /close post/i })).not.toBeInTheDocument(); - // await act(async () => { - // fireEvent.click(screen.getByRole('button', { name: /close/i })); - // }); - // expect(screen.queryByRole('dialog', { name: /close post/i })).not.toBeInTheDocument(); - // assertLastUpdateData({ closed: true }); - // }); + it('should show reason codes when editing an existing comment', async () => { + setupCourseConfig(); + 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 })); + }); + 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])( 'should reopen the post directly when reason codes enabled=%s', async (reasonCodesEnabled) => { setupCourseConfig(reasonCodesEnabled); renderComponent(closedPostId); + await act(async () => { + fireEvent.mouseOver(await waitFor(() => screen.findByText('Thread-2', { exact: false }))); + }); await act(async () => { fireEvent.click( // 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 () => { - // setupCourseConfig(false); - // renderComponent(discussionPostId); - // await act(async () => { - // fireEvent.click( - // // The first edit menu is for the post - // screen.getAllByRole('button', { name: /actions menu/i })[0], - // ); - // }); - // await act(async () => { - // fireEvent.click(screen.getByRole('button', { name: /edit/i })); - // }); - // expect(testLocation.pathname).toBe(`/${courseId}/posts/${discussionPostId}/edit`); - // }); + it('should show the editor if the post is edited', 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], + ); + }); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /edit/i })); + }); + expect(testLocation.pathname).toBe(`/${courseId}/posts/${discussionPostId}/edit`); + }); it('should allow pinning the post', async () => { renderComponent(discussionPostId); + await act(async () => { + fireEvent.mouseOver(await waitFor(() => screen.findByTestId('post-thread-1'))); + }); await act(async () => { fireEvent.click( // The first edit menu is for the post @@ -400,6 +428,9 @@ describe('CommentsView', () => { it('should allow reporting the post', async () => { 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 @@ -417,314 +448,313 @@ describe('CommentsView', () => { assertLastUpdateData({ abuse_flagged: true }); }); - // it('handles liking a comment', async () => { - // renderComponent(discussionPostId); - // - // // Wait for the content to load - // await screen.findByText('comment number 7', { exact: false }); - // const view = screen.getByTestId('comment-comment-1'); - // - // const likeButton = within(view).getByRole('button', { name: /like/i }); - // await act(async () => { - // fireEvent.click(likeButton); - // }); - // expect(axiosMock.history.patch).toHaveLength(2); - // expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject({ voted: true }); - // }); - // - // it('handles endorsing comments', async () => { - // renderComponent(discussionPostId); - // // Wait for the content to load - // await screen.findByText('comment number 7', { exact: false }); - // - // // There should be three buttons, one for the post, the second for the - // // comment and the third for a response to that comment - // const actionButtons = screen.queryAllByRole('button', { name: /actions menu/i }); - // await act(async () => { - // fireEvent.click(actionButtons[1]); - // }); - // - // await act(async () => { - // fireEvent.click(screen.getByRole('button', { name: /Endorse/i })); - // }); - // expect(axiosMock.history.patch).toHaveLength(2); - // expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject({ endorsed: true }); - // }); - // - // it('handles reporting comments', async () => { - // renderComponent(discussionPostId); - // // Wait for the content to load - // await screen.findByText('comment number 7', { exact: false }); - // - // // There should be three buttons, one for the post, the second for the - // // comment and the third for a response to that comment - // const actionButtons = screen.queryAllByRole('button', { name: /actions menu/i }); - // await act(async () => { - // fireEvent.click(actionButtons[1]); - // }); - // - // await act(async () => { - // fireEvent.click(screen.getByRole('button', { name: /Report/i })); - // }); - // expect(screen.queryByRole('dialog', { name: /Report \w+/i, exact: false })).toBeInTheDocument(); - // await act(async () => { - // fireEvent.click(screen.queryByRole('button', { name: /Confirm/i })); - // }); - // expect(screen.queryByRole('dialog', { name: /Report \w+/i, exact: false })).not.toBeInTheDocument(); - // expect(axiosMock.history.patch).toHaveLength(2); - // expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject({ abuse_flagged: true }); - // }); - // }); - // - // describe('for discussion thread', () => { - // const findLoadMoreCommentsButton = () => screen.findByTestId('load-more-comments'); - // - // it('shown post not found when post id does not belong to course', async () => { - // renderComponent('unloaded-id'); - // expect(await screen.findByText('Thread not found', { exact: true })) - // .toBeInTheDocument(); - // }); - // - // it('initially loads only the first page', async () => { - // renderComponent(discussionPostId); - // expect(await screen.findByText('comment number 1', { exact: false })) - // .toBeInTheDocument(); - // expect(screen.queryByText('comment number 2', { exact: false })) - // .not - // .toBeInTheDocument(); - // }); - // - // it('pressing load more button will load next page of comments', async () => { - // renderComponent(discussionPostId); - // - // const loadMoreButton = await findLoadMoreCommentsButton(); - // fireEvent.click(loadMoreButton); - // - // await screen.findByText('comment number 1', { exact: false }); - // await screen.findByText('comment number 2', { exact: false }); - // }); - // - // it('newly loaded comments are appended to the old ones', async () => { - // renderComponent(discussionPostId); - // - // const loadMoreButton = await findLoadMoreCommentsButton(); - // fireEvent.click(loadMoreButton); - // - // await screen.findByText('comment number 1', { exact: false }); - // // check that comments from the first page are also displayed - // expect(screen.queryByText('comment number 2', { exact: false })) - // .toBeInTheDocument(); - // }); - // - // it('load more button is hidden when no more comments pages to load', async () => { - // const totalPages = 2; - // renderComponent(discussionPostId); - // - // const loadMoreButton = await findLoadMoreCommentsButton(); - // for (let page = 1; page < totalPages; page++) { - // fireEvent.click(loadMoreButton); - // } - // - // await screen.findByText('comment number 2', { exact: false }); - // await expect(findLoadMoreCommentsButton()) - // .rejects - // .toThrow(); - // }); - // }); - // - // describe('for question thread', () => { - // const findLoadMoreCommentsButtons = () => screen.findAllByTestId('load-more-comments'); - // - // it('initially loads only the first page', async () => { - // act(() => renderComponent(questionPostId)); - // expect(await screen.findByText('comment number 3', { exact: false })) - // .toBeInTheDocument(); - // expect(await screen.findByText('endorsed comment number 5', { exact: false })) - // .toBeInTheDocument(); - // expect(screen.queryByText('comment number 4', { exact: false })) - // .not - // .toBeInTheDocument(); - // }); - // - // it('pressing load more button will load next page of comments', async () => { - // act(() => { - // renderComponent(questionPostId); - // }); - // - // const [loadMoreButtonEndorsed, loadMoreButtonUnendorsed] = await findLoadMoreCommentsButtons(); - // // Both load more buttons should show - // expect(await findLoadMoreCommentsButtons()).toHaveLength(2); - // expect(await screen.findByText('unendorsed comment number 3', { exact: false })) - // .toBeInTheDocument(); - // expect(await screen.findByText('endorsed comment number 5', { exact: false })) - // .toBeInTheDocument(); - // // Comments from next page should not be loaded yet. - // expect(await screen.queryByText('endorsed comment number 6', { exact: false })) - // .not - // .toBeInTheDocument(); - // expect(await screen.queryByText('unendorsed comment number 4', { exact: false })) - // .not - // .toBeInTheDocument(); - // - // await act(async () => { - // fireEvent.click(loadMoreButtonEndorsed); - // }); - // // Endorsed comment from next page should be loaded now. - // await waitFor(() => expect(screen.queryByText('endorsed comment number 6', { exact: false })) - // .toBeInTheDocument()); - // // Unendorsed comment from next page should not be loaded yet. - // expect(await screen.queryByText('unendorsed comment number 4', { exact: false })) - // .not - // .toBeInTheDocument(); - // // Now only one load more buttons should show, for unendorsed comments - // expect(await findLoadMoreCommentsButtons()).toHaveLength(1); - // await act(async () => { - // fireEvent.click(loadMoreButtonUnendorsed); - // }); - // // Unendorsed comment from next page should be loaded now. - // await waitFor(() => expect(screen.queryByText('unendorsed comment number 4', { exact: false })) - // .toBeInTheDocument()); - // await expect(findLoadMoreCommentsButtons()).rejects.toThrow(); - // }); - // }); - // - // describe('comments responses', () => { - // const findLoadMoreCommentsResponsesButton = () => screen.findByTestId('load-more-comments-responses'); - // - // it('initially loads only the first page', async () => { - // renderComponent(discussionPostId); - // - // await waitFor(() => screen.findByText('comment number 7', { exact: false })); - // expect(screen.queryByText('comment number 8', { exact: false })).not.toBeInTheDocument(); - // }); - // - // it('pressing load more button will load next page of responses', async () => { - // renderComponent(discussionPostId); - // - // const loadMoreButton = await findLoadMoreCommentsResponsesButton(); - // await act(async () => { - // fireEvent.click(loadMoreButton); - // }); - // - // await screen.findByText('comment number 8', { exact: false }); - // }); - // - // it('newly loaded responses are appended to the old ones', async () => { - // renderComponent(discussionPostId); - // - // const loadMoreButton = await findLoadMoreCommentsResponsesButton(); - // await act(async () => { - // fireEvent.click(loadMoreButton); - // }); - // - // await screen.findByText('comment number 8', { exact: false }); - // // check that comments from the first page are also displayed - // expect(screen.queryByText('comment number 7', { exact: false })).toBeInTheDocument(); - // }); - // - // it('load more button is hidden when no more responses pages to load', async () => { - // const totalPages = 2; - // renderComponent(discussionPostId); - // - // const loadMoreButton = await findLoadMoreCommentsResponsesButton(); - // for (let page = 1; page < totalPages; page++) { - // act(() => { - // fireEvent.click(loadMoreButton); - // }); - // } - // - // await screen.findByText('comment number 8', { exact: false }); - // await expect(findLoadMoreCommentsResponsesButton()) - // .rejects - // .toThrow(); - // }); - // - // it('handles liking a comment', async () => { - // renderComponent(discussionPostId); - // - // // Wait for the content to load - // await screen.findByText('comment number 7', { exact: false }); - // const view = screen.getByTestId('comment-comment-1'); - // - // const likeButton = within(view).getByRole('button', { name: /like/i }); - // await act(async () => { - // fireEvent.click(likeButton); - // }); - // expect(axiosMock.history.patch).toHaveLength(2); - // expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject({ voted: true }); - // }); - // - // it('handles endorsing comments', async () => { - // renderComponent(discussionPostId); - // // Wait for the content to load - // await screen.findByText('comment number 7', { exact: false }); - // - // // There should be three buttons, one for the post, the second for the - // // comment and the third for a response to that comment - // const actionButtons = screen.queryAllByRole('button', { name: /actions menu/i }); - // await act(async () => { - // fireEvent.click(actionButtons[1]); - // }); - // - // await act(async () => { - // fireEvent.click(screen.getByRole('button', { name: /Endorse/i })); - // }); - // expect(axiosMock.history.patch).toHaveLength(2); - // expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject({ endorsed: true }); - // }); - // - // it('handles reporting comments', async () => { - // renderComponent(discussionPostId); - // // Wait for the content to load - // await screen.findByText('comment number 7', { exact: false }); - // - // // There should be three buttons, one for the post, the second for the - // // comment and the third for a response to that comment - // const actionButtons = screen.queryAllByRole('button', { name: /actions menu/i }); - // await act(async () => { - // fireEvent.click(actionButtons[1]); - // }); - // - // await act(async () => { - // fireEvent.click(screen.getByRole('button', { name: /Report/i })); - // }); - // expect(screen.queryByRole('dialog', { name: /Report \w+/i, exact: false })).toBeInTheDocument(); - // await act(async () => { - // fireEvent.click(screen.queryByRole('button', { name: /Confirm/i })); - // }); - // expect(screen.queryByRole('dialog', { name: /Report \w+/i, exact: false })).not.toBeInTheDocument(); - // expect(axiosMock.history.patch).toHaveLength(2); - // expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject({ abuse_flagged: true }); - // }); - // }); - // - // describe.each([ - // { component: 'post', testId: 'post-thread-1' }, - // { component: 'comment', testId: 'comment-comment-1' }, - // { component: 'reply', testId: 'reply-comment-7' }, - // ])('delete confirmation modal', ({ - // component, - // testId, - // }) => { - // test(`for ${component}`, async () => { - // renderComponent(discussionPostId); - // // Wait for the content to load - // await waitFor(() => expect(screen.queryByText('comment number 7', { exact: false })).toBeInTheDocument()); - // const content = screen.getByTestId(testId); - // const actionsButton = within(content).getAllByRole('button', { name: /actions menu/i })[0]; - // await act(async () => { - // fireEvent.click(actionsButton); - // }); - // expect(screen.queryByRole('dialog', { name: /delete \w+/i, exact: false })).not.toBeInTheDocument(); - // const deleteButton = within(content).queryByRole('button', { name: /delete/i }); - // await act(async () => { - // fireEvent.click(deleteButton); - // }); - // expect(screen.queryByRole('dialog', { name: /delete \w+/i, exact: false })).toBeInTheDocument(); - // await act(async () => { - // fireEvent.click(screen.queryByRole('button', { name: /delete/i })); - // }); - // expect(screen.queryByRole('dialog', { name: /delete \w+/i, exact: false })).not.toBeInTheDocument(); - // }); + it('handles liking a comment', async () => { + renderComponent(discussionPostId); + + // Wait for the content to load + await act(async () => { + fireEvent.mouseOver(await waitFor(() => screen.findByTestId('comment-7'))); + }); + const view = screen.getByTestId('comment-comment-1'); + + const likeButton = within(view).getByRole('button', { name: /like/i }); + await act(async () => { + fireEvent.click(likeButton); + }); + expect(axiosMock.history.patch).toHaveLength(2); + expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject({ voted: true }); + }); + + it('handles endorsing comments', async () => { + renderComponent(discussionPostId); + // Wait for the content to load + await act(async () => { + fireEvent.mouseOver(await waitFor(() => screen.findByTestId('comment-7'))); + }); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /Endorse/i })); + }); + expect(axiosMock.history.patch).toHaveLength(2); + expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject({ endorsed: true }); + }); + + it('handles reporting comments', async () => { + renderComponent(discussionPostId); + // Wait for the content to load + await act(async () => { + fireEvent.mouseOver(await waitFor(() => screen.findByTestId('comment-7'))); + }); + const actionButtons = screen.queryAllByRole('button', { name: /actions menu/i }); + await act(async () => { + fireEvent.click(actionButtons[0]); + }); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /Report/i })); + }); + expect(screen.queryByRole('dialog', { name: /Report \w+/i, exact: false })).toBeInTheDocument(); + await act(async () => { + fireEvent.click(screen.queryByRole('button', { name: /Confirm/i })); + }); + expect(screen.queryByRole('dialog', { name: /Report \w+/i, exact: false })).not.toBeInTheDocument(); + expect(axiosMock.history.patch).toHaveLength(2); + expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject({ abuse_flagged: true }); + }); + }); + + describe('for discussion thread', () => { + const findLoadMoreCommentsButton = () => screen.findByTestId('load-more-comments'); + + it('shown post not found when post id does not belong to course', async () => { + renderComponent('unloaded-id'); + expect(await screen.findByText('Thread not found', { exact: true })) + .toBeInTheDocument(); + }); + + it('initially loads only the first page', async () => { + renderComponent(discussionPostId); + expect(await screen.findByTestId('comment-1')) + .toBeInTheDocument(); + expect(screen.queryByTestId('comment-2')) + .not + .toBeInTheDocument(); + }); + + it('pressing load more button will load next page of comments', async () => { + renderComponent(discussionPostId); + + const loadMoreButton = await findLoadMoreCommentsButton(); + fireEvent.click(loadMoreButton); + + await screen.findByTestId('comment-1'); + await screen.findByTestId('comment-2'); + }); + + it('newly loaded comments are appended to the old ones', async () => { + renderComponent(discussionPostId); + + const loadMoreButton = await findLoadMoreCommentsButton(); + fireEvent.click(loadMoreButton); + + await screen.findByTestId('comment-1'); + // check that comments from the first page are also displayed + expect(screen.queryByTestId('comment-2')) + .toBeInTheDocument(); + }); + + it('load more button is hidden when no more comments pages to load', async () => { + const totalPages = 2; + renderComponent(discussionPostId); + + const loadMoreButton = await findLoadMoreCommentsButton(); + for (let page = 1; page < totalPages; page++) { + fireEvent.click(loadMoreButton); + } + + await screen.findByTestId('comment-2'); + await expect(findLoadMoreCommentsButton()) + .rejects + .toThrow(); + }); + }); + + describe('for question thread', () => { + const findLoadMoreCommentsButtons = () => screen.findAllByTestId('load-more-comments'); + + it('initially loads only the first page', async () => { + act(() => renderComponent(questionPostId)); + expect(await screen.findByTestId('comment-3')) + .toBeInTheDocument(); + expect(await screen.findByTestId('comment-5')) + .toBeInTheDocument(); + expect(screen.queryByTestId('comment-4')) + .not + .toBeInTheDocument(); + }); + + it('pressing load more button will load next page of comments', async () => { + act(() => { + renderComponent(questionPostId); + }); + + const [loadMoreButtonEndorsed, loadMoreButtonUnendorsed] = await findLoadMoreCommentsButtons(); + // Both load more buttons should show + expect(await findLoadMoreCommentsButtons()).toHaveLength(2); + expect(await screen.findByTestId('comment-3')) + .toBeInTheDocument(); + expect(await screen.findByTestId('comment-5')) + .toBeInTheDocument(); + // Comments from next page should not be loaded yet. + expect(await screen.queryByTestId('comment-6')) + .not + .toBeInTheDocument(); + expect(await screen.queryByTestId('comment-4')) + .not + .toBeInTheDocument(); + + await act(async () => { + fireEvent.click(loadMoreButtonEndorsed); + }); + // Endorsed comment from next page should be loaded now. + await waitFor(() => expect(screen.queryByTestId('comment-6')) + .toBeInTheDocument()); + // Unendorsed comment from next page should not be loaded yet. + expect(await screen.queryByTestId('comment-4')) + .not + .toBeInTheDocument(); + // Now only one load more buttons should show, for unendorsed comments + expect(await findLoadMoreCommentsButtons()).toHaveLength(1); + await act(async () => { + fireEvent.click(loadMoreButtonUnendorsed); + }); + // Unendorsed comment from next page should be loaded now. + await waitFor(() => expect(screen.queryByTestId('comment-4')) + .toBeInTheDocument()); + await expect(findLoadMoreCommentsButtons()).rejects.toThrow(); + }); + }); + + describe('comments responses', () => { + const findLoadMoreCommentsResponsesButton = () => screen.findByTestId('load-more-comments-responses'); + + it('initially loads only the first page', async () => { + renderComponent(discussionPostId); + + await waitFor(() => screen.findByTestId('comment-7')); + expect(screen.queryByTestId('comment-8')).not.toBeInTheDocument(); + }); + + it('pressing load more button will load next page of responses', async () => { + renderComponent(discussionPostId); + + const loadMoreButton = await findLoadMoreCommentsResponsesButton(); + await act(async () => { + fireEvent.click(loadMoreButton); + }); + + await screen.findByTestId('comment-8'); + }); + + it('newly loaded responses are appended to the old ones', async () => { + renderComponent(discussionPostId); + + const loadMoreButton = await findLoadMoreCommentsResponsesButton(); + await act(async () => { + fireEvent.click(loadMoreButton); + }); + + await screen.findByTestId('comment-8'); + // check that comments from the first page are also displayed + expect(screen.queryByTestId('comment-7')).toBeInTheDocument(); + }); + + it('load more button is hidden when no more responses pages to load', async () => { + const totalPages = 2; + renderComponent(discussionPostId); + + const loadMoreButton = await findLoadMoreCommentsResponsesButton(); + for (let page = 1; page < totalPages; page++) { + act(() => { + fireEvent.click(loadMoreButton); + }); + } + + await screen.findByTestId('comment-8'); + await expect(findLoadMoreCommentsResponsesButton()) + .rejects + .toThrow(); + }); + + it('handles liking a comment', async () => { + renderComponent(discussionPostId); + + // Wait for the content to load + await act(async () => { + fireEvent.mouseOver(await waitFor(() => screen.findByTestId('comment-7'))); + }); + const view = screen.getByTestId('comment-comment-1'); + + const likeButton = within(view).getByRole('button', { name: /like/i }); + await act(async () => { + fireEvent.click(likeButton); + }); + expect(axiosMock.history.patch).toHaveLength(2); + expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject({ voted: true }); + }); + + it('handles endorsing comments', async () => { + renderComponent(discussionPostId); + // Wait for the content to load + await act(async () => { + fireEvent.mouseOver(await waitFor(() => screen.findByTestId('comment-7'))); + }); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /Endorse/i })); + }); + expect(axiosMock.history.patch).toHaveLength(2); + expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject({ endorsed: true }); + }); + + it('handles reporting comments', async () => { + renderComponent(discussionPostId); + // Wait for the content to load + await act(async () => { + fireEvent.mouseOver(await waitFor(() => screen.findByTestId('comment-7'))); + }); + + // There should be three buttons, one for the post, the second for the + // comment and the third for a response to that comment + const actionButtons = screen.queryAllByRole('button', { name: /actions menu/i }); + await act(async () => { + fireEvent.click(actionButtons[1]); + }); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /Report/i })); + }); + expect(screen.queryByRole('dialog', { name: /Report \w+/i, exact: false })).toBeInTheDocument(); + await act(async () => { + fireEvent.click(screen.queryByRole('button', { name: /Confirm/i })); + }); + expect(screen.queryByRole('dialog', { name: /Report \w+/i, exact: false })).not.toBeInTheDocument(); + expect(axiosMock.history.patch).toHaveLength(2); + expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject({ abuse_flagged: true }); + }); + }); + + describe.each([ + { component: 'post', testId: 'post-thread-1' }, + { component: 'comment', testId: 'comment-comment-1' }, + { component: 'reply', testId: 'reply-comment-7' }, + ])('delete confirmation modal', ({ + component, + testId, + }) => { + test(`for ${component}`, async () => { + renderComponent(discussionPostId); + // Wait for the content to load + // await waitFor(() => expect(screen.findByTestId('post-thread-1')).toBeInTheDocument()); + await waitFor(() => expect(screen.queryByText('This is Thread-1', { exact: false })).toBeInTheDocument()); + const content = screen.getByTestId(testId); + await act(async () => { + fireEvent.mouseOver(content); + }); + const actionsButton = within(content).getAllByRole('button', { name: /actions menu/i })[0]; + await act(async () => { + fireEvent.click(actionsButton); + }); + expect(screen.queryByRole('dialog', { name: /delete \w+/i, exact: false })).not.toBeInTheDocument(); + const deleteButton = within(content).queryByRole('button', { name: /delete/i }); + await act(async () => { + fireEvent.click(deleteButton); + }); + expect(screen.queryByRole('dialog', { name: /delete \w+/i, exact: false })).toBeInTheDocument(); + await act(async () => { + fireEvent.click(screen.queryByRole('button', { name: /delete/i })); + }); + expect(screen.queryByRole('dialog', { name: /delete \w+/i, exact: false })).not.toBeInTheDocument(); + }); }); }); diff --git a/src/discussions/comments/comment-icons/CommentIcons.jsx b/src/discussions/comments/comment-icons/CommentIcons.jsx index 1a87d284..5e36ba75 100644 --- a/src/discussions/comments/comment-icons/CommentIcons.jsx +++ b/src/discussions/comments/comment-icons/CommentIcons.jsx @@ -17,16 +17,16 @@ function CommentIcons({ timeago.register('time-locale', timeLocale); const handleLike = () => dispatch(editComment(comment.id, { voted: !comment.voted })); + if (comment.voteCount === 0) { + return null; + } return ( -
+
-
- {timeago.format(comment.createdAt, 'time-locale')} -
); } diff --git a/src/discussions/comments/comment/Comment.jsx b/src/discussions/comments/comment/Comment.jsx index 99284348..99864bd7 100644 --- a/src/discussions/comments/comment/Comment.jsx +++ b/src/discussions/comments/comment/Comment.jsx @@ -8,11 +8,13 @@ import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { Button, useToggle } from '@edx/paragon'; import HTMLLoader from '../../../components/HTMLLoader'; -import { ContentActions } from '../../../data/constants'; +import { ContentActions, EndorsementStatus } from '../../../data/constants'; import { AlertBanner, Confirmation, EndorsedAlertBanner } from '../../common'; import { DiscussionContext } from '../../common/context'; +import HoverCard from '../../common/HoverCard'; import { useUserCanAddThreadInBlackoutDate } from '../../data/hooks'; import { fetchThread } from '../../posts/data/thunks'; +import { useActions } from '../../utils'; import CommentIcons from '../comment-icons/CommentIcons'; import { selectCommentCurrentPage, selectCommentHasMorePages, selectCommentResponses } from '../data/selectors'; import { editComment, fetchCommentResponses, removeComment } from '../data/thunks'; @@ -28,6 +30,7 @@ function Comment({ showFullThread = true, isClosedPost, intl, + marginBottom, }) { const dispatch = useDispatch(); const hasChildren = comment.childCount > 0; @@ -40,6 +43,7 @@ function Comment({ const hasMorePages = useSelector(selectCommentHasMorePages(comment.id)); const currentPage = useSelector(selectCommentCurrentPage(comment.id)); const userCanAddThreadInBlackoutDate = useUserCanAddThreadInBlackoutDate(); + const [showHoverCard, setShowHoverCard] = useState(false); const { courseId, } = useContext(DiscussionContext); @@ -49,6 +53,11 @@ function Comment({ dispatch(fetchCommentResponses(comment.id, { page: 1 })); } }, [comment.id]); + const actions = useActions({ + ...comment, + postType, + }); + const endorseIcons = actions.find(({ action }) => action === EndorsementStatus.ENDORSED); const handleAbusedFlag = () => { if (comment.abuseFlagged) { @@ -82,9 +91,20 @@ function Comment({ dispatch(fetchCommentResponses(comment.id, { page: currentPage + 1 })) ); + const handleHoverCardBlurEvent = (e) => { + if (!e.currentTarget.contains(e.relatedTarget)) { + setShowHoverCard(false); + } + }; return ( -
-
+
+ {/* eslint-disable jsx-a11y/no-noninteractive-tabindex */} +
)} -
+
setShowHoverCard(true)} + onMouseLeave={() => setShowHoverCard(false)} + onFocus={() => setShowHoverCard(true)} + onBlur={(e) => handleHoverCardBlurEvent(e)} + > + {showHoverCard && ( + setReplying(true)} + onLike={() => dispatch(editComment(comment.id, { voted: !comment.voted }))} + addResponseCommentButtonMessage={intl.formatMessage(messages.addComment)} + isClosedPost={isClosedPost} + endorseIcons={endorseIcons} + /> + )} - + {isEditing ? ( setEditing(false)} formClasses="pt-3" /> ) - : } + : ( + + )} dispatch(editComment(comment.id, { voted: !comment.voted }))} createdAt={comment.createdAt} /> -
{intl.formatMessage(messages.replies, { count: inlineReplies.length })}
-
- {/* Pass along intl since component used here is the one before it's injected with `injectIntl` */} - {inlineReplies.map(inlineReply => ( - - ))} -
+ {inlineReplies.length > 0 && ( +
+ {/* Pass along intl since component used here is the one before it's injected with `injectIntl` */} + {inlineReplies.map(inlineReply => ( + + ))} +
+ )} {hasMorePages && ( )} - ) )}
@@ -186,11 +234,13 @@ Comment.propTypes = { showFullThread: PropTypes.bool, isClosedPost: PropTypes.bool, intl: intlShape.isRequired, + marginBottom: PropTypes.bool, }; Comment.defaultProps = { showFullThread: true, isClosedPost: false, + marginBottom: true, }; export default injectIntl(Comment); diff --git a/src/discussions/comments/comment/CommentHeader.jsx b/src/discussions/comments/comment/CommentHeader.jsx index 30f087ad..5ff6dda1 100644 --- a/src/discussions/comments/comment/CommentHeader.jsx +++ b/src/discussions/comments/comment/CommentHeader.jsx @@ -1,46 +1,24 @@ import React from 'react'; -import PropTypes from 'prop-types'; import classNames from 'classnames'; import { useSelector } from 'react-redux'; import { injectIntl } from '@edx/frontend-platform/i18n'; -import { logError } from '@edx/frontend-platform/logging'; -import { - Avatar, Icon, -} from '@edx/paragon'; +import { Avatar } from '@edx/paragon'; -import { AvatarOutlineAndLabelColors, EndorsementStatus, ThreadType } from '../../../data/constants'; +import { AvatarOutlineAndLabelColors } from '../../../data/constants'; import { AuthorLabel } from '../../common'; -import ActionsDropdown from '../../common/ActionsDropdown'; import { useAlertBannerVisible } from '../../data/hooks'; import { selectAuthorAvatars } from '../../posts/data/selectors'; -import { useActions } from '../../utils'; import { commentShape } from './proptypes'; function CommentHeader({ comment, - postType, - actionHandlers, }) { const authorAvatars = useSelector(selectAuthorAvatars(comment.author)); const colorClass = AvatarOutlineAndLabelColors[comment.authorLabel]; const hasAnyAlert = useAlertBannerVisible(comment); - const actions = useActions({ - ...comment, - postType, - }); - const actionIcons = actions.find(({ action }) => action === EndorsementStatus.ENDORSED); - - const handleIcons = (action) => { - const actionFunction = actionHandlers[action]; - if (actionFunction) { - actionFunction(); - } else { - logError(`Unknown or unimplemented action ${action}`); - } - }; return (
-
-
- - {actionIcons && ( - - handleIcons(actionIcons.action)} - src={actionIcons.icon} - className={['endorse', 'unendorse'].includes(actionIcons.id) ? 'text-dark-500' : 'text-success-500'} - size="sm" - /> - - )} - -
@@ -91,8 +49,6 @@ function CommentHeader({ CommentHeader.propTypes = { comment: commentShape.isRequired, - actionHandlers: PropTypes.objectOf(PropTypes.func).isRequired, - postType: PropTypes.oneOf([ThreadType.QUESTION, ThreadType.DISCUSSION]).isRequired, }; export default injectIntl(CommentHeader); diff --git a/src/discussions/comments/comment/CommentHeader.test.jsx b/src/discussions/comments/comment/CommentHeader.test.jsx deleted file mode 100644 index 0018378b..00000000 --- a/src/discussions/comments/comment/CommentHeader.test.jsx +++ /dev/null @@ -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( - - - - - - - , - ); -} - -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); - }); -}); diff --git a/src/discussions/comments/comment/Reply.jsx b/src/discussions/comments/comment/Reply.jsx index 0d91876f..a0a15438 100644 --- a/src/discussions/comments/comment/Reply.jsx +++ b/src/discussions/comments/comment/Reply.jsx @@ -64,7 +64,7 @@ function Reply({ const hasAnyAlert = useAlertBannerVisible(reply); return ( -
+
-
- -
+
+ +
{isEditing ? setEditing(false)} /> - : } + : ( + + )}
-
- {timeago.format(reply.createdAt, 'time-locale')} -
); } diff --git a/src/discussions/comments/comment/ResponseEditor.jsx b/src/discussions/comments/comment/ResponseEditor.jsx index d9b29eb8..08bc5457 100644 --- a/src/discussions/comments/comment/ResponseEditor.jsx +++ b/src/discussions/comments/comment/ResponseEditor.jsx @@ -1,59 +1,39 @@ -import React, { useContext, useEffect, useState } from 'react'; +import React, { useEffect } from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; -import { Button } from '@edx/paragon'; +import { injectIntl } from '@edx/frontend-platform/i18n'; -import { DiscussionContext } from '../../common/context'; -import { useUserCanAddThreadInBlackoutDate } from '../../data/hooks'; -import messages from '../messages'; import CommentEditor from './CommentEditor'; function ResponseEditor({ postId, - intl, addWrappingDiv, + handleCloseEditor, + addingResponse, }) { - const { enableInContextSidebar } = useContext(DiscussionContext); - const [addingResponse, setAddingResponse] = useState(false); - const userCanAddThreadInBlackoutDate = useUserCanAddThreadInBlackoutDate(); - useEffect(() => { - setAddingResponse(false); + handleCloseEditor(); }, [postId]); return addingResponse - ? ( + && (
setAddingResponse(false)} + onCloseEditor={handleCloseEditor} />
- ) - : userCanAddThreadInBlackoutDate && ( -
- -
); } ResponseEditor.propTypes = { postId: PropTypes.string.isRequired, - intl: intlShape.isRequired, addWrappingDiv: PropTypes.bool, + handleCloseEditor: PropTypes.func.isRequired, + addingResponse: PropTypes.bool.isRequired, }; ResponseEditor.defaultProps = { diff --git a/src/discussions/comments/messages.js b/src/discussions/comments/messages.js index 31f08651..5aeaadd6 100644 --- a/src/discussions/comments/messages.js +++ b/src/discussions/comments/messages.js @@ -1,15 +1,15 @@ import { defineMessages } from '@edx/frontend-platform/i18n'; const messages = defineMessages({ + addComment: { + id: 'discussions.comments.comment.addComment', + defaultMessage: 'Add comment', + description: 'Button to add a comment to a response', + }, addResponse: { id: 'discussions.comments.comment.addResponse', defaultMessage: 'Add a response', - description: 'Button to add a response in a thread of forum posts', - }, - addComment: { - id: 'discussions.comments.comment.addComment', - defaultMessage: 'Add a comment', - description: 'Button to add a comment to a response', + description: 'Button to add a response to a response', }, abuseFlaggedMessage: { id: 'discussions.comments.comment.abuseFlaggedMessage', @@ -188,6 +188,11 @@ const messages = defineMessages({ defaultMessage: 'Edited by', description: 'Text shown to users to indicate who edited a post. Followed by the username of editor.', }, + fullStop: { + id: 'discussions.comment.comments.fullStop', + defaultMessage: '•', + description: 'Fullstop shown to users to indicate who edited a post. Followed by a reason.', + }, reason: { id: 'discussions.comment.comments.reason', defaultMessage: 'Reason', @@ -197,11 +202,6 @@ const messages = defineMessages({ id: 'discussions.post.closedBy', defaultMessage: 'Post closed by', }, - replies: { - id: 'discussion.comment.repliesHeading', - defaultMessage: '{count} replies for the response added', - description: 'Text added for screen reader to understand nesting replies.', - }, time: { id: 'discussion.comment.time', defaultMessage: '{time} ago', diff --git a/src/discussions/common/ActionsDropdown.jsx b/src/discussions/common/ActionsDropdown.jsx index 6d90c801..be78a6eb 100644 --- a/src/discussions/common/ActionsDropdown.jsx +++ b/src/discussions/common/ActionsDropdown.jsx @@ -23,6 +23,8 @@ function ActionsDropdown({ commentOrPost, disabled, actionHandlers, + iconSize, + dropDownIconSize, }) { const [isOpen, open, close] = useToggle(false); const [target, setTarget] = useState(null); @@ -49,8 +51,9 @@ function ActionsDropdown({ src={MoreHoriz} iconAs={Icon} disabled={disabled} - size="sm" + size={iconSize} ref={setTarget} + iconClassNames={dropDownIconSize ? 'dropdown-icon-dimentions' : ''} />
( {(action.action === ContentActions.DELETE) - && } + && } {canSeeReportedBanner && ( - + {intl.formatMessage(messages.abuseFlaggedMessage)} )} {reasonCodesEnabled && canSeeLastEditOrClosedAlert && ( <> {content.lastEdit?.reason && ( - -
+ +
{intl.formatMessage(messages.editedBy)} - + + + + {intl.formatMessage(messages.fullStop)} {intl.formatMessage(messages.reason)}: {content.lastEdit.reason}
)} {content.closed && ( - -
+ +
{intl.formatMessage(messages.closedBy)} - + - + + {intl.formatMessage(messages.fullStop)} + + {content.closeReason && (`${intl.formatMessage(messages.reason)}: ${content.closeReason}`)} +
)} diff --git a/src/discussions/common/AuthorLabel.jsx b/src/discussions/common/AuthorLabel.jsx index 6a4e67c1..45c2e03a 100644 --- a/src/discussions/common/AuthorLabel.jsx +++ b/src/discussions/common/AuthorLabel.jsx @@ -3,9 +3,10 @@ import PropTypes from 'prop-types'; import classNames from 'classnames'; import { Link, useLocation } from 'react-router-dom'; +import * as timeago from 'timeago.js'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; -import { Icon } from '@edx/paragon'; +import { Icon, OverlayTrigger, Tooltip } from '@edx/paragon'; import { Institution, School } from '@edx/paragon/icons'; import { Routes } from '../../data/constants'; @@ -13,6 +14,7 @@ import { useShowLearnersTab } from '../data/hooks'; import messages from '../messages'; import { discussionsPath } from '../utils'; import { DiscussionContext } from './context'; +import timeLocale from './time-locale'; function AuthorLabel({ intl, @@ -21,11 +23,15 @@ function AuthorLabel({ linkToProfile, labelColor, alert, + postCreatedAt, + authorToolTip, + postOrComment, }) { const location = useLocation(); const { courseId } = useContext(DiscussionContext); let icon = null; let authorLabelMessage = null; + timeago.register('time-locale', timeLocale); if (authorLabel === 'Staff') { icon = Institution; @@ -37,37 +43,56 @@ function AuthorLabel({ } const isRetiredUser = author ? author.startsWith('retired__user') : false; + const showTextPrimary = !authorLabelMessage && !isRetiredUser && !alert; - const className = classNames('d-flex align-items-center mb-0.5', labelColor); + const className = classNames('d-flex align-items-center', { 'mb-0.5': !postOrComment }, labelColor); const showUserNameAsLink = useShowLearnersTab() - && linkToProfile && author && author !== intl.formatMessage(messages.anonymous); + && linkToProfile && author && author !== intl.formatMessage(messages.anonymous); const labelContents = ( -
- - {isRetiredUser ? '[Deactivated]' : author } - - {icon && ( - +
+ {!alert && ( + + {isRetiredUser ? '[Deactivated]' : author} + )} + + + {author} + + )} + + trigger={['hover', 'focus']} + > +
+ + +
+
{authorLabelMessage && ( )} + {postCreatedAt && ( + + {timeago.format(postCreatedAt, 'time-locale')} + + )} +
); @@ -100,6 +138,9 @@ AuthorLabel.propTypes = { linkToProfile: PropTypes.bool, labelColor: PropTypes.string, alert: PropTypes.bool, + postCreatedAt: PropTypes.string, + authorToolTip: PropTypes.bool, + postOrComment: PropTypes.bool, }; AuthorLabel.defaultProps = { @@ -107,6 +148,9 @@ AuthorLabel.defaultProps = { authorLabel: null, labelColor: '', alert: false, + postCreatedAt: null, + authorToolTip: false, + postOrComment: false, }; export default injectIntl(AuthorLabel); diff --git a/src/discussions/common/EndorsedAlertBanner.jsx b/src/discussions/common/EndorsedAlertBanner.jsx index 3e830412..0d6320be 100644 --- a/src/discussions/common/EndorsedAlertBanner.jsx +++ b/src/discussions/common/EndorsedAlertBanner.jsx @@ -4,7 +4,7 @@ import PropTypes from 'prop-types'; import * as timeago from 'timeago.js'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; -import { Alert } from '@edx/paragon'; +import { Alert, Icon } from '@edx/paragon'; import { CheckCircle, Verified } from '@edx/paragon/icons'; import { ThreadType } from '../../data/constants'; @@ -27,32 +27,35 @@ function EndorsedAlertBanner({ content.endorsed && (
- {intl.formatMessage( - isQuestion - ? messages.answer - : messages.endorsed, - )} - - - - {intl.formatMessage( - isQuestion - ? messages.answeredLabel - : messages.endorsedLabel, - )} - +
+ + {intl.formatMessage( + isQuestion + ? messages.answer + : messages.endorsed, + )} + +
+ - {intl.formatMessage(messages.time, { time: timeago.format(content.endorsedAt, 'time-locale') })}
diff --git a/src/discussions/common/EndorsedAlertBanner.test.jsx b/src/discussions/common/EndorsedAlertBanner.test.jsx index b9ab9b5b..11a10595 100644 --- a/src/discussions/common/EndorsedAlertBanner.test.jsx +++ b/src/discussions/common/EndorsedAlertBanner.test.jsx @@ -46,21 +46,21 @@ describe.each([ type: 'comment', postType: ThreadType.QUESTION, props: { endorsed: true, endorsedBy: 'test-user', endorsedByLabel: 'Staff' }, - expectText: [messages.answer.defaultMessage, messages.answeredLabel.defaultMessage, 'test-user', 'Staff'], + expectText: [messages.answer.defaultMessage, 'Staff'], }, { label: 'TA endorsed comment in a question thread', type: 'comment', postType: ThreadType.QUESTION, props: { endorsed: true, endorsedBy: 'test-user', endorsedByLabel: 'Community TA' }, - expectText: [messages.answer.defaultMessage, messages.answeredLabel.defaultMessage, 'test-user', 'TA'], + expectText: [messages.answer.defaultMessage, 'TA'], }, { label: 'endorsed comment in a discussion thread', type: 'comment', postType: ThreadType.DISCUSSION, props: { endorsed: true, endorsedBy: 'test-user' }, - expectText: [messages.endorsed.defaultMessage, messages.endorsedLabel.defaultMessage, 'test-user'], + expectText: [messages.endorsed.defaultMessage], }, ])('EndorsedAlertBanner', ({ label, type, postType, props, expectText, diff --git a/src/discussions/common/HoverCard.jsx b/src/discussions/common/HoverCard.jsx new file mode 100644 index 00000000..62901051 --- /dev/null +++ b/src/discussions/common/HoverCard.jsx @@ -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 ( +
+ {userCanAddThreadInBlackoutDate && ( +
+ +
+ )} + + {endorseIcons && ( +
+ { + const actionFunction = actionHandlers[endorseIcons.action]; + actionFunction(); + }} + className={['endorse', 'unendorse'].includes(endorseIcons.id) ? 'text-dark-500' : 'text-success-500'} + size="sm" + alt="Endorse" + /> +
+ )} +
+ { + e.preventDefault(); + onLike(); + }} + /> +
+ {commentOrPost.following !== undefined && ( +
+ { + e.preventDefault(); + onFollow(); + return true; + }} + /> +
+ )} +
+ +
+
+ ); +} + +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); diff --git a/src/discussions/common/HoverCard.test.jsx b/src/discussions/common/HoverCard.test.jsx new file mode 100644 index 00000000..4976b1d5 --- /dev/null +++ b/src/discussions/common/HoverCard.test.jsx @@ -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( + + + + + + + + + + , + ); + 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\/(?[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(); + }); +}); diff --git a/src/discussions/posts/post-editor/messages.js b/src/discussions/posts/post-editor/messages.js index 9d06316d..c2691aeb 100644 --- a/src/discussions/posts/post-editor/messages.js +++ b/src/discussions/posts/post-editor/messages.js @@ -113,7 +113,7 @@ const messages = defineMessages({ }, showPreviewButton: { id: 'discussions.editor.posts.showPreview.button', - defaultMessage: 'Show Preview', + defaultMessage: 'Show preview', description: 'show preview button text to allow user to see their post content.', }, actionsAlt: { diff --git a/src/discussions/posts/post/LikeButton.jsx b/src/discussions/posts/post/LikeButton.jsx index 51bfaf06..fa9b9392 100644 --- a/src/discussions/posts/post/LikeButton.jsx +++ b/src/discussions/posts/post/LikeButton.jsx @@ -2,7 +2,9 @@ import React from 'react'; import PropTypes from 'prop-types'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; -import { Icon, IconButtonWithTooltip } from '@edx/paragon'; +import { + Icon, IconButton, OverlayTrigger, Tooltip, +} from '@edx/paragon'; import { ThumbUpFilled, ThumbUpOutline } from '../../../components/icons'; import messages from './messages'; @@ -12,7 +14,6 @@ function LikeButton({ intl, onClick, voted, - preview, }) { const handleClick = (e) => { e.preventDefault(); @@ -23,20 +24,27 @@ function LikeButton({ }; return ( -
- - {(count && count > 0) ? count : null} +
+ + {intl.formatMessage(voted ? messages.removeLike : messages.like)} + + )} + > + + +
+ {(count && count > 0) ? count : null} +
+
); } @@ -46,13 +54,11 @@ LikeButton.propTypes = { intl: intlShape.isRequired, onClick: PropTypes.func, voted: PropTypes.bool, - preview: PropTypes.bool, }; LikeButton.defaultProps = { voted: false, onClick: undefined, - preview: false, }; export default injectIntl(LikeButton); diff --git a/src/discussions/posts/post/Post.jsx b/src/discussions/posts/post/Post.jsx index 18e5f4e5..8b972a3b 100644 --- a/src/discussions/posts/post/Post.jsx +++ b/src/discussions/posts/post/Post.jsx @@ -1,4 +1,4 @@ -import React, { useContext } from 'react'; +import React, { useContext, useState } from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; @@ -13,6 +13,7 @@ import { ContentActions } from '../../../data/constants'; import { selectorForUnitSubsection, selectTopicContext } from '../../../data/selectors'; import { AlertBanner, Confirmation } from '../../common'; import { DiscussionContext } from '../../common/context'; +import HoverCard from '../../common/HoverCard'; import { selectModerationSettings } from '../../data/selectors'; import { selectTopic } from '../../topics/data/selectors'; import { removeThread, updateExistingThread } from '../data/thunks'; @@ -26,6 +27,7 @@ function Post({ post, preview, intl, + handleAddResponseButton, }) { const location = useLocation(); const history = useHistory(); @@ -39,7 +41,7 @@ function Post({ const [isDeleting, showDeleteConfirmation, hideDeleteConfirmation] = useToggle(false); const [isReporting, showReportConfirmation, hideReportConfirmation] = useToggle(false); const [isClosing, showClosePostModal, hideClosePostModal] = useToggle(false); - + const [showHoverCard, setShowHoverCard] = useState(false); const handleAbusedFlag = () => { if (post.abuseFlagged) { dispatch(updateExistingThread(post.id, { flagged: !post.abuseFlagged })); @@ -62,6 +64,12 @@ function Post({ hideReportConfirmation(); }; + const handleHoverCardBlurEvent = (e) => { + if (!e.currentTarget.contains(e.relatedTarget)) { + setShowHoverCard(false); + } + }; + const actionHandlers = { [ContentActions.EDIT_CONTENT]: () => history.push({ ...location, @@ -87,7 +95,15 @@ function Post({ ); return ( -
+
setShowHoverCard(true)} + onMouseLeave={() => setShowHoverCard(false)} + onFocus={() => setShowHoverCard(true)} + onBlur={(e) => handleHoverCardBlurEvent(e)} + > )} + {showHoverCard && ( + dispatch(updateExistingThread(post.id, { voted: !post.voted }))} + onFollow={() => dispatch(updateExistingThread(post.id, { following: !post.following }))} + isClosedPost={post.closed} + /> + )} - -
- + +
+
{topicContext && topic && ( -
- {intl.formatMessage(messages.relatedTo)}{' '} + {intl.formatMessage(messages.relatedTo)}{' '}
)} -
- -
+ - dispatch(updateExistingThread(post.id, { voted: !post.voted }))} - voted={post.voted} - preview={preview} - /> - { - e.preventDefault(); - dispatch(updateExistingThread(post.id, { following: !post.following })); - return true; - }} - size={preview ? 'inline' : 'sm'} - className={preview && 'p-3'} - iconClassNames={preview && 'icon-size'} - /> - {preview && post.commentCount > 1 && ( -
- - {post.commentCount} -
+
+ {post.voteCount !== 0 && ( + dispatch(updateExistingThread(post.id, { voted: !post.voted }))} + voted={post.voted} + /> )} - {showNewCountLabel && preview && post?.unreadCommentCount > 0 && post.commentCount > 1 && ( - - {intl.formatMessage(messages.newLabel, { count: post.unreadCommentCount })} - + {post.following && ( + + {intl.formatMessage(post.following ? messages.unFollow : messages.follow)} + + )} + > + { + e.preventDefault(); + dispatch(updateExistingThread(post.id, { following: !post.following })); + return true; + }} + iconAs={Icon} + iconClassNames="follow-icon-dimentions" + className="post-footer-icon-dimentions" + alt="Follow" + /> + )}
{post.groupId && userHasModerationPrivileges && ( @@ -100,10 +78,8 @@ function PostFooter({ )} - - {timeago.format(post.createdAt, 'time-locale')} - - {!preview && post.closed + + {post.closed && ( { }); }); - it("shows 'x new' badge for new comments in case of read post only", () => { - renderComponent(mockPost, true, true); - expect(screen.getByText('2 New')).toBeTruthy(); - }); - it("doesn't have 'new' badge when there are 0 new comments", () => { renderComponent({ ...mockPost, unreadCommentCount: 0 }); expect(screen.queryByText('2 New')).toBeFalsy(); @@ -89,18 +84,24 @@ describe('PostFooter', () => { expect(screen.getByTestId('cohort-icon')).toBeTruthy(); }); - it.each([[true, /unfollow/i], [false, /follow/i]])('test follow button when following=%s', async (following, message) => { - renderComponent({ ...mockPost, following }); + it('test follow button when following=true', async () => { + renderComponent({ ...mockPost, following: true }); const followButton = screen.getByRole('button', { name: /follow/i }); expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); await act(async () => { fireEvent.mouseEnter(followButton); }); - expect(screen.getByRole('tooltip')).toHaveTextContent(message); + + expect(screen.getByRole('tooltip')).toHaveTextContent(/unfollow/i); await act(async () => { fireEvent.click(followButton); }); // clicking on the button triggers thread update. expect(store.getState().threads.status === RequestStatus.IN_PROGRESS).toBeTruthy(); }); + + it('test follow button when following=false', async () => { + renderComponent({ ...mockPost, following: false }); + expect(screen.queryByRole('button', { name: /follow/i })).not.toBeInTheDocument(); + }); }); diff --git a/src/discussions/posts/post/PostHeader.jsx b/src/discussions/posts/post/PostHeader.jsx index e0915861..cf455872 100644 --- a/src/discussions/posts/post/PostHeader.jsx +++ b/src/discussions/posts/post/PostHeader.jsx @@ -9,7 +9,7 @@ import { Avatar, Badge, Icon } from '@edx/paragon'; import { Issue, Question } from '../../../components/icons'; import { AvatarOutlineAndLabelColors, ThreadType } from '../../../data/constants'; -import { ActionsDropdown, AuthorLabel } from '../../common'; +import { AuthorLabel } from '../../common'; import { useAlertBannerVisible } from '../../data/hooks'; import { selectAuthorAvatars } from '../data/selectors'; import messages from './messages'; @@ -24,7 +24,7 @@ export function PostAvatar({ const avatarSize = useMemo(() => { let size = '2rem'; if (post.type === ThreadType.DISCUSSION && !fromPostLink) { - size = '2.375rem'; + size = '2rem'; } else if (post.type === ThreadType.QUESTION) { size = '1.5rem'; } @@ -52,11 +52,11 @@ export function PostAvatar({ /> )} +
@@ -109,21 +108,17 @@ function PostHeader({ && {intl.formatMessage(messages.answered)}}
) - :

{post.title}

} + :
{post.title}
}
- {!preview - && ( -
- -
- )}
); } @@ -132,7 +127,6 @@ PostHeader.propTypes = { intl: intlShape.isRequired, post: postShape.isRequired, preview: PropTypes.bool, - actionHandlers: PropTypes.objectOf(PropTypes.func).isRequired, }; PostHeader.defaultProps = { diff --git a/src/discussions/posts/post/messages.js b/src/discussions/posts/post/messages.js index f47f1612..f095e1fb 100644 --- a/src/discussions/posts/post/messages.js +++ b/src/discussions/posts/post/messages.js @@ -6,6 +6,11 @@ const messages = defineMessages({ defaultMessage: 'anonymous', description: 'Author name displayed when a post is anonymous', }, + addResponse: { + id: 'discussions.post.addResponse', + defaultMessage: 'Add response', + description: 'Button to add a response in a thread of forum posts', + }, lastResponse: { id: 'discussions.post.lastResponse', defaultMessage: 'Last response {time}', diff --git a/src/discussions/utils.js b/src/discussions/utils.js index bb1c5587..4c4f8cc0 100644 --- a/src/discussions/utils.js +++ b/src/discussions/utils.js @@ -185,7 +185,6 @@ export function useActions(content) { .every(condition => condition === true) : true ); - return ACTIONS_LIST.filter( ({ action, @@ -295,3 +294,7 @@ export function handleKeyDown(event) { selectedOption.focus(); } } + +export function isLastElementOfList(list, element) { + return list[list.length - 1] === element; +} diff --git a/src/index.scss b/src/index.scss index 9a9c6c74..cae205c0 100755 --- a/src/index.scss +++ b/src/index.scss @@ -45,6 +45,14 @@ $fa-font-path: "~font-awesome/fonts"; font-size: 14px; } +.font-size-12 { + font-size: 12px; +} + +.font-size-8 { + font-size: 8px; +} + .font-weight-500 { font-weight: 500; } @@ -57,9 +65,24 @@ $fa-font-path: "~font-awesome/fonts"; font-family: "Inter"; } -.icon-size { - height: 20px !important; +.post-footer-icon-dimentions { + 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; + height: 21px !important; } .post-summary-icons-dimensions { @@ -77,6 +100,20 @@ $fa-font-path: "~font-awesome/fonts"; 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 { margin-right: 2px; } @@ -93,6 +130,26 @@ $fa-font-path: "~font-awesome/fonts"; 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 { padding-top: 1px; padding-bottom: 1px @@ -102,7 +159,7 @@ $fa-font-path: "~font-awesome/fonts"; background-color: unset !important; } -.learner > a:hover { +.learner>a:hover { background-color: #F2F0EF; } @@ -111,14 +168,27 @@ $fa-font-path: "~font-awesome/fonts"; padding-bottom: 10px; } +.py-8px { + padding-top: 8px; + padding-bottom: 8px; +} + +.pb-10px { + padding-bottom: 10px; +} + +.pt-10px { + padding-top: 10px !important; +} + .px-10px { padding-left: 10px; padding-right: 10px; } .question-icon-size { - width: 1.625rem; - height: 1.625rem; + width: 1.4581rem; + height: 1.4581rem; } .question-icon-position { @@ -134,6 +204,7 @@ $fa-font-path: "~font-awesome/fonts"; header { .logo { margin-right: 1rem; + img { height: 1.75rem; } @@ -142,6 +213,7 @@ header { #learner-posts-link { color: inherit; + span[role=heading]:hover { text-decoration: underline; } @@ -170,11 +242,12 @@ header { } } -.pointer-cursor-hover :hover{ +.pointer-cursor-hover :hover { cursor: pointer; } -.filter-bar:focus-visible, .filter-bar:focus { +.filter-bar:focus-visible, +.filter-bar:focus { outline: none; } @@ -198,9 +271,9 @@ header { }; }; - .container-xl{ + .container-xl { .course-title-lockup { - font-size: 1.125 rem; + font-size: 1.125rem; }; .logo { @@ -221,7 +294,7 @@ header { .container-xl { padding-left: 31px; - font-size: 1.125 rem; + font-size: 1.125rem; .nav { line-height: 28px; @@ -239,7 +312,7 @@ header { .header-action-bar { 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%); position: sticky; top: 0; @@ -250,10 +323,10 @@ header { } .actions-dropdown { - z-index: 0; + z-index: 1; } -.discussion-topic-group:last-of-type .divider{ +.discussion-topic-group:last-of-type .divider { display: none; } @@ -269,6 +342,12 @@ header { z-index: 0; } +.btn-icon.btn-icon-primary:hover { + background-color: #F2F0EF !important; + color: #00262B !important +} + + @media only screen and (max-width: 767px) { body:not(.tox-force-desktop) .tox .tox-dialog { align-self: center; @@ -286,3 +365,66 @@ header { .pgn__checkpoint { 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; +}