Merge pull request #22 from edx/kshitij/tnl-8723/questions

feat: add support for question type posts [BD-38] [TNL-8723] [BB-4884]
This commit is contained in:
Kshitij Sobti
2021-10-06 14:20:10 +00:00
committed by GitHub
22 changed files with 636 additions and 324 deletions

View File

@@ -12,6 +12,28 @@ export const ThreadType = {
DISCUSSION: 'discussion',
};
/**
* Enum to map between endorsement status and friendly name.
* @readonly
* @enum
*/
export const EndorsementStatus = {
ENDORSED: 'endorsed',
UNENDORSED: 'unendorsed',
DISCUSSION: 'discussion',
};
/**
* Maps endorsement status and the corresponding API parameter.
* @readonly
* @enum
*/
export const EndorsementValue = {
[EndorsementStatus.ENDORSED]: true,
[EndorsementStatus.UNENDORSED]: false,
[EndorsementStatus.DISCUSSION]: null,
};
/**
* Edit actions for posts and comments
* @readonly
@@ -121,7 +143,7 @@ const BASE_PATH = '/discussions/:courseId';
export const Routes = {
DISCUSSIONS: {
PATH: `${BASE_PATH}?`,
PATH: BASE_PATH,
},
POSTS: {
PATH: `${BASE_PATH}/topics/:topicId`,
@@ -162,7 +184,7 @@ export const Routes = {
},
};
export const ALL_ROUTES = []
export const ALL_ROUTES = [Routes.DISCUSSIONS.PATH]
.concat(Routes.COMMENTS.PATH)
.concat(Routes.TOPICS.PATH)
.concat([Routes.POSTS.ALL_POSTS, Routes.POSTS.MY_POSTS]);

30
src/data/hooks.js Normal file
View File

@@ -0,0 +1,30 @@
/* eslint-disable import/prefer-default-export */
import { useState } from 'react';
import { useDispatch } from 'react-redux';
/**
* A hook that creates an enhanced version of dispatch that can track the loading state.
*
* This hook will return a boolean that tracks the current loading state, and a function
* that can be used an an alternative to dispatch for dispatching thunks. If dispatch
* is called with a thunk it's loading state will be reflected in the boolean.
*
* If you need to track multiple requests, or multiple types of requests, use multiple
* instances of this hook. e.g. one for loading and one for saving.
*
* @return {(boolean|(function(*=): Promise<void>)|*)[]}
*/
export function useDispatchWithState() {
const dispatch = useDispatch();
const [isDispatching, setDispatching] = useState(false);
const dispatchWithState = async (thunk) => {
setDispatching(true);
await dispatch(thunk);
setDispatching(false);
};
return [
isDispatching,
dispatchWithState,
];
}

View File

@@ -1,4 +1,5 @@
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import { useParams } from 'react-router';
@@ -7,32 +8,22 @@ import { ensureConfig, getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button, Spinner } from '@edx/paragon';
import { EndorsementStatus, ThreadType } from '../../data/constants';
import { useDispatchWithState } from '../../data/hooks';
import { Post } from '../posts';
import { selectThread } from '../posts/data/selectors';
import { markThreadAsRead } from '../posts/data/thunks';
import {
selectThreadComments,
selectThreadCurrentPage,
selectThreadHasMorePages,
} from './data/selectors';
import { selectThreadComments, selectThreadCurrentPage, selectThreadHasMorePages } from './data/selectors';
import { fetchThreadComments } from './data/thunks';
import { Comment, ResponseEditor } from './comment';
import messages from './messages';
ensureConfig(['POST_MARK_AS_READ_DELAY'], 'Comment thread view');
function CommentsView({ intl }) {
const { postId } = useParams();
function usePost(postId) {
const dispatch = useDispatch();
const thread = useSelector(selectThread(postId));
const comments = useSelector(selectThreadComments(postId));
const hasMorePages = useSelector(selectThreadHasMorePages(postId));
const currentPage = useSelector(selectThreadCurrentPage(postId));
const handleLoadMoreComments = () => dispatch(fetchThreadComments(postId, { page: currentPage + 1 }));
useEffect(() => {
if (!currentPage) {
dispatch(fetchThreadComments(postId, { page: 1 }));
}
const markReadTimer = setTimeout(() => {
if (thread && !thread.read) {
dispatch(markThreadAsRead(postId));
@@ -42,42 +33,119 @@ function CommentsView({ intl }) {
clearTimeout(markReadTimer);
};
}, [postId]);
return thread;
}
function usePostComments(postId, endorsed = null) {
const [isLoading, dispatch] = useDispatchWithState();
const comments = useSelector(selectThreadComments(postId, endorsed));
const hasMorePages = useSelector(selectThreadHasMorePages(postId, endorsed));
const currentPage = useSelector(selectThreadCurrentPage(postId, endorsed));
const handleLoadMoreResponses = async () => dispatch(fetchThreadComments(postId, {
endorsed,
page: currentPage + 1,
}));
useEffect(() => {
dispatch(fetchThreadComments(postId, {
endorsed,
page: 1,
}));
}, [postId]);
return {
comments,
hasMorePages,
isLoading,
handleLoadMoreResponses,
};
}
function DiscussionCommentsView({
postType,
postId,
intl,
endorsed,
}) {
const {
comments,
hasMorePages,
isLoading,
handleLoadMoreResponses,
} = usePostComments(postId, endorsed);
return (
<div className="m-3">
{comments.map(comment => (
<Comment comment={comment} key={comment.id} postType={postType} />
))}
{hasMorePages && !isLoading && (
<Button
onClick={handleLoadMoreResponses}
variant="link"
block="true"
className="card p-4"
>
{intl.formatMessage(messages.loadMoreResponses)}
</Button>
)}
{isLoading
&& (
<div className="card my-4 p-4 d-flex align-items-center">
<Spinner animation="border" variant="primary" />
</div>
)}
</div>
);
}
DiscussionCommentsView.propTypes = {
postId: PropTypes.string.isRequired,
postType: PropTypes.string.isRequired,
intl: intlShape.isRequired,
endorsed: PropTypes.oneOf([
EndorsementStatus.ENDORSED, EndorsementStatus.ENDORSED, EndorsementStatus.DISCUSSION,
]).isRequired,
};
function CommentsView({ intl }) {
const { postId } = useParams();
const thread = usePost(postId);
if (!thread) {
return (
<Spinner animation="border" variant="primary" />
);
}
return (
<div className="discussion-comments d-flex flex-column w-100 ml-3">
<div className="mb-2">
<>
<div className="discussion-comments d-flex flex-column mt-3 mb-0 mx-3 p-4 card">
<Post post={thread} />
{comments.length > 0
&& (
<div className="my-3">
<ResponseEditor postId={postId} />
</div>
)}
<div className="card">
{comments.map(comment => (
<div key={comment.id} className="border-bottom">
<Comment comment={comment} />
</div>
))}
{hasMorePages && (
<div className="list-group-item list-group-item-action">
<Button
onClick={handleLoadMoreComments}
variant="link"
block="true"
>
{intl.formatMessage(messages.loadMoreComments)}
</Button>
</div>
)}
</div>
<ResponseEditor postId={postId} />
</div>
<ResponseEditor postId={postId} />
</div>
{thread.type === ThreadType.DISCUSSION
&& (
<DiscussionCommentsView
postId={postId}
intl={intl}
postType={thread.type}
endorsed={EndorsementStatus.DISCUSSION}
/>
)}
{thread.type === ThreadType.QUESTION && (
<>
<DiscussionCommentsView
postId={postId}
intl={intl}
postType={thread.type}
endorsed={EndorsementStatus.ENDORSED}
/>
<DiscussionCommentsView
postId={postId}
intl={intl}
postType={thread.type}
endorsed={EndorsementStatus.UNENDORSED}
/>
</>
)}
</>
);
}

View File

@@ -3,93 +3,65 @@
*/
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import {
act, fireEvent, render, screen, waitFor,
} from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import { IntlProvider } from 'react-intl';
import { MemoryRouter, Route } from 'react-router';
import { Factory } from 'rosie';
import { 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 { threadsApiUrl } from '../posts/data/api';
import { fetchThreads } from '../posts/data/thunks';
import { commentsApiUrl } from './data/api';
import CommentsView from './CommentsView';
import messages from './messages';
const postId = '1';
import '../posts/data/__factories__';
import './data/__factories__';
const discussionPostId = 'thread-1';
const questionPostId = 'thread-2';
const courseId = 'course-v1:edX+TestX+Test_Course';
let store;
let axiosMock;
const mockCommentsPaged = [
[
{
threadId: postId,
id: '1',
renderedBody: 'test comment 1',
voteCount: 0,
author: 'testauthor',
users: {
testauthor: {
profile: {
image: {
image_url_small: '',
},
},
},
},
editableFields: [],
},
],
[
{
threadId: postId,
id: '2',
renderedBody: 'test comment 2',
voteCount: 0,
author: 'testauthor',
users: {
testauthor: {
profile: {
image: {
image_url_small: '',
},
},
},
},
editableFields: [],
},
],
];
function mockAxiosReturnPagedComments() {
const paramsTemplate = {
thread_id: postId,
page: undefined,
page_size: undefined,
requested_fields: 'profile_image',
};
const numPages = mockCommentsPaged.length;
for (let page = 1; page <= numPages; page++) {
const comments = mockCommentsPaged[page - 1];
axiosMock
.onGet(commentsApiUrl, { params: { ...paramsTemplate, page } })
.reply(200, {
results: comments,
pagination: {
[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', null, {
threadId: postId,
page,
numPages,
next: page < numPages ? page + 1 : null,
},
});
}
pageSize: 1,
count: 2,
endorsed,
}));
});
});
}
function renderComponent() {
function renderComponent(postId) {
render(
<IntlProvider locale="en">
<AppProvider store={store}>
<MemoryRouter initialEntries={['comments/1']}>
<MemoryRouter initialEntries={[`comments/${postId}`]}>
<Route path="comments/:postId">
<CommentsView />
</Route>
@@ -100,100 +72,127 @@ function renderComponent() {
}
describe('CommentsView', () => {
beforeEach(() => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
adminsitrator: true,
administrator: true,
roles: [],
},
});
store = initializeStore({
threads: {
threadsById: {
[postId]: {
id: postId.toString(),
author: 'testauthor',
title: 'test thread',
voteCount: 0,
type: 'discussion',
pinned: false,
abuseFlagged: false,
commentCount: mockCommentsPaged.reduce((acc, cur) => acc + cur.length, 0),
courseId: 'course_id',
following: false,
rawBody: '',
read: true,
topicId: '',
updatedAt: '',
editableFields: [],
},
},
avatars: {
testauthor: {
profile: {
image: '',
},
},
},
},
store = initializeStore();
Factory.resetAll();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock.onGet(threadsApiUrl)
.reply(200, Factory.build('threadsResult'));
await executeThunk(fetchThreads(courseId), store.dispatch, store.getState);
mockAxiosReturnPagedComments();
});
describe('for discussion thread', () => {
const findLoadMoreCommentsButton = () => screen.findByRole('button', { name: messages.loadMoreResponses.defaultMessage });
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();
});
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
});
it('pressing load more button will load next page of comments', async () => {
renderComponent(discussionPostId);
// TODO: use test id to prevent breaking from text changes
const findLoadMoreCommentsButton = () => screen.findByRole('button', { name: /load more comments/i });
it('initially loads only the first page', async () => {
const firstPageComment = mockCommentsPaged[0][0];
const secondPageComment = mockCommentsPaged[1][0];
mockAxiosReturnPagedComments();
renderComponent();
await screen.findByText(firstPageComment.renderedBody);
expect(screen.queryByText(secondPageComment.renderedBody)).not.toBeInTheDocument();
});
it('pressing load more button will load next page of comments', async () => {
const secondPageComment = mockCommentsPaged[1][0];
mockAxiosReturnPagedComments();
renderComponent();
const loadMoreButton = await findLoadMoreCommentsButton();
fireEvent.click(loadMoreButton);
await screen.findByText(secondPageComment.renderedBody);
});
it('newly loaded comments are appended to the old ones', async () => {
const firstPageComment = mockCommentsPaged[0][0];
const secondPageComment = mockCommentsPaged[1][0];
mockAxiosReturnPagedComments();
renderComponent();
const loadMoreButton = await findLoadMoreCommentsButton();
fireEvent.click(loadMoreButton);
await screen.findByText(secondPageComment.renderedBody);
// check that comments from the first pages are also displayed
expect(screen.queryByText(firstPageComment.renderedBody)).toBeInTheDocument();
});
it('load more button is hidden when no more comments pages to load', async () => {
const totalePages = mockCommentsPaged.length;
const lastPageComment = mockCommentsPaged[totalePages - 1][0];
mockAxiosReturnPagedComments();
renderComponent();
const loadMoreButton = await findLoadMoreCommentsButton();
for (let page = 1; page < totalePages; page++) {
const loadMoreButton = await findLoadMoreCommentsButton();
fireEvent.click(loadMoreButton);
}
await screen.findByText(lastPageComment.renderedBody);
await expect(findLoadMoreCommentsButton()).rejects.toThrow();
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 pages 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.findAllByRole('button', { name: messages.loadMoreResponses.defaultMessage });
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 () => {
await 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(() => {
fireEvent.click(loadMoreButtonEndorsed);
});
// Endorsed comment from next page should be loaded now.
await waitFor(() => expect(screen.queryByText('endorsed comment number 6', { exact: false }))
.toBeInTheDocument());
// Unndorsed 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(() => {
fireEvent.click(loadMoreButtonUnendorsed);
});
// Unndorsed comment from next page should be loaded now.
await waitFor(() => expect(screen.queryByText('unendorsed comment number 4', { exact: false }))
.toBeInTheDocument());
expect(findLoadMoreCommentsButtons()).rejects.toThrow();
});
});
});

View File

@@ -1,11 +1,14 @@
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import * as timeago from 'timeago.js';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button } from '@edx/paragon';
import { Alert, Button, Hyperlink } from '@edx/paragon';
import { CheckCircle, Verified } from '@edx/paragon/icons';
import { ContentActions } from '../../../data/constants';
import { ContentActions, ThreadType } from '../../../data/constants';
import CommentIcons from '../comment-icons/CommentIcons';
import { selectCommentResponses } from '../data/selectors';
import { editComment, fetchCommentResponses, removeComment } from '../data/thunks';
@@ -14,7 +17,45 @@ import CommentEditor from './CommentEditor';
import CommentHeader from './CommentHeader';
import { commentShape } from './proptypes';
function CommentBanner({
intl,
comment,
postType,
}) {
const isQuestion = postType === ThreadType.QUESTION;
const classes = isQuestion ? 'bg-success-500 text-white' : 'bg-dark-500 text-white';
const iconClass = isQuestion ? CheckCircle : Verified;
return comment.endorsed ? (
<Alert variant="plain" className={`p-3 m-0 rounded-0 shadow-none ${classes}`} icon={iconClass}>
<div className="d-flex justify-content-between">
<strong>{intl.formatMessage(
isQuestion
? messages.answer
: messages.endorsed,
)}
</strong>
<span>
{intl.formatMessage(
isQuestion
? messages.answeredLabel
: messages.endorsedLabel,
)}&nbsp;
<Hyperlink>{comment.endorsedBy}</Hyperlink>&nbsp;
{timeago.format(comment.endorsedAt, intl.locale)}
</span>
</div>
</Alert>
) : null;
}
CommentBanner.propTypes = {
intl: intlShape.isRequired,
comment: commentShape.isRequired,
postType: PropTypes.string.isRequired,
};
function Comment({
postType,
comment,
intl,
}) {
@@ -37,10 +78,10 @@ function Comment({
[ContentActions.DELETE]: () => dispatch(removeComment(comment.id)),
[ContentActions.REPORT]: () => dispatch(editComment(comment.id, { flagged: !comment.abuseFlagged })),
};
return (
<div className="bt-1 discussion-comment d-flex flex-column">
<div className="p-3">
<div className="discussion-comment d-flex flex-column card my-3">
<CommentBanner postType={postType} comment={comment} intl={intl} />
<div className="p-4">
<CommentHeader comment={comment} actionHandlers={actionHandlers} />
{isEditing
? (
@@ -55,9 +96,16 @@ function Comment({
voted={comment.voted}
/>
</div>
<div className="bg-light-200 border-top">
<div className="ml-4">
{/* Pass along intl since component used here is the one before it's injected with `injectIntl` */}
{inlineReplies.map(inlineReply => <Comment comment={inlineReply} key={inlineReply.id} intl={intl} />)}
{inlineReplies.map(inlineReply => (
<Comment
postType={postType}
comment={inlineReply}
key={inlineReply.id}
intl={intl}
/>
))}
</div>
{!isNested
&& (
@@ -82,6 +130,7 @@ function Comment({
}
Comment.propTypes = {
postType: PropTypes.oneOf(['discussion', 'question']).isRequired,
comment: commentShape.isRequired,
intl: intlShape.isRequired,
};

View File

@@ -5,6 +5,9 @@ export const commentShape = PropTypes.shape({
createdAt: PropTypes.string,
abuseFlagged: PropTypes.bool,
renderedBody: PropTypes.string,
endorsedBy: PropTypes.string,
endorsedAt: PropTypes.string,
endorsed: PropTypes.bool,
author: PropTypes.string,
authorLabel: PropTypes.string,
users: PropTypes.objectOf(PropTypes.shape({

View File

@@ -2,8 +2,8 @@ import { Factory } from 'rosie';
Factory.define('comment')
.sequence('id', (idx) => `comment-${idx}`)
.sequence('raw_body', (idx) => `Some contents for **comment number ${idx}**.`)
.sequence('rendered_body', (idx) => `Some contents for <b>comment number ${idx}</b>.`)
.sequence('raw_body', ['endorsed'], (idx, endorsed) => `Some contents for **${endorsed ? 'endorsed ' : 'unendorsed '}comment number ${idx}**.`)
.sequence('rendered_body', ['endorsed'], (idx, endorsed) => `Some contents for <b>${endorsed ? 'endorsed ' : 'unendorsed '}comment number ${idx}</b>.`)
.attr('thread_id', null, 'test-thread')
.option('endorsedBy', null, null)
.attr('endorsed', ['endorsedBy'], (endorsedBy) => !!endorsedBy)
@@ -36,6 +36,7 @@ Factory.define('commentsResult')
.option('pageSize', null, 5)
.option('threadId', null, 'test-thread')
.option('parentId', null, null)
.option('endorsed', null, null)
.attr('pagination', ['threadId', 'count', 'page', 'pageSize'], (threadId, count, page, pageSize) => {
const numPages = Math.ceil(count / pageSize);
const next = (page < numPages) ? `http://test.site/api/discussion/v1/comments/?thread_id=${threadId}&page=${page + 1}` : null;
@@ -47,7 +48,12 @@ Factory.define('commentsResult')
num_pages: numPages,
};
})
.attr('results', ['count', 'pageSize', 'page', 'threadId', 'parentId'], (count, pageSize, page, threadId, parentId) => {
.attr('results', ['count', 'pageSize', 'page', 'threadId', 'parentId', 'endorsed'], (count, pageSize, page, threadId, parentId, endorsed) => {
const len = (pageSize * page <= count) ? pageSize : count % pageSize;
return Factory.buildList('comment', len, { thread_id: threadId, parent_id: parentId });
return Factory.buildList('comment', len, {
thread_id: threadId,
parent_id: parentId,
}, {
endorsedBy: endorsed ? 'staff' : null,
});
});

View File

@@ -2,6 +2,8 @@
import { ensureConfig, getConfig, snakeCaseObject } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { EndorsementValue } from '../../../data/constants';
ensureConfig([
'LMS_BASE_URL',
], 'Comments API service');
@@ -13,18 +15,21 @@ export const commentsApiUrl = `${apiBaseUrl}/api/discussion/v1/comments/`;
/**
* Returns all the comments for the specified thread.
* @param {string} threadId
* @param {EndorsementStatus} endorsed
* @param {number=} page
* @param {number=} pageSize
* @returns {Promise<{}>}
*/
export async function getThreadComments(
threadId, {
endorsed,
page,
pageSize,
} = {},
) {
const params = snakeCaseObject({
threadId,
endorsed: EndorsementValue[endorsed],
page,
pageSize,
requestedFields: 'profile_image',

View File

@@ -4,6 +4,7 @@ import { Factory } from 'rosie';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { initializeMockApp } from '@edx/frontend-platform/testing';
import { EndorsementStatus } from '../../../data/constants';
import { initializeStore } from '../../../store';
import { executeThunk } from '../../../test-utils';
import { commentsApiUrl } from './api';
@@ -36,17 +37,40 @@ describe('Comments/Responses data layer tests', () => {
axiosMock.reset();
});
test('successfully processes comments', async () => {
test.each([
{
threadType: 'discussion',
endorsed: EndorsementStatus.DISCUSSION,
},
{
threadType: 'question',
endorsed: EndorsementStatus.UNENDORSED,
},
{
threadType: 'question',
endorsed: EndorsementStatus.ENDORSED,
},
])('successfully processes comments for \'$threadType\' thread with endorsed=$endorsed', async ({
endorsed,
}) => {
const threadId = 'test-thread';
axiosMock.onGet(commentsApiUrl)
.reply(200, Factory.build('commentsResult'));
await executeThunk(fetchThreadComments(threadId), store.dispatch, store.getState);
await executeThunk(fetchThreadComments(threadId, { endorsed }), store.dispatch, store.getState);
expect(store.getState().comments.commentsInThreads)
.toEqual({ 'test-thread': ['comment-1', 'comment-2', 'comment-3'] });
.toEqual({ 'test-thread': { [endorsed]: ['comment-1', 'comment-2', 'comment-3'] } });
expect(store.getState().comments.pagination)
.toEqual({ 'test-thread': { currentPage: 1, totalPages: 1, hasMorePages: false } });
.toEqual({
'test-thread': {
[endorsed]: {
currentPage: 1,
totalPages: 1,
hasMorePages: false,
},
},
});
expect(Object.keys(store.getState().comments.commentsById))
.toEqual(['comment-1', 'comment-2', 'comment-3']);
expect(store.getState().comments.commentsById['comment-1'])
@@ -76,7 +100,7 @@ describe('Comments/Responses data layer tests', () => {
.toEqual({ 'comment-1': ['comment-4', 'comment-5', 'comment-6'] });
});
test('successfully handles comment creation', async () => {
test('successfully handles comment creation for discussion type threads', async () => {
const threadId = 'test-thread';
const content = 'Test comment';
axiosMock.onGet(commentsApiUrl)
@@ -94,13 +118,68 @@ describe('Comments/Responses data layer tests', () => {
await executeThunk(addComment(content, threadId, null), store.dispatch, store.getState);
expect(store.getState().comments.commentsInThreads[threadId])
.toEqual(['comment-1', 'comment-2', 'comment-3', 'comment-4']);
.toEqual({
[EndorsementStatus.DISCUSSION]: [
'comment-1',
'comment-2',
'comment-3',
'comment-4',
],
});
expect(Object.keys(store.getState().comments.commentsById))
.toEqual(['comment-1', 'comment-2', 'comment-3', 'comment-4']);
expect(store.getState().comments.commentsById['comment-4'].threadId)
.toEqual(threadId);
});
test('successfully handles comment creation for question type threads', async () => {
const threadId = 'test-thread';
const content = 'Test comment';
axiosMock.onGet(commentsApiUrl)
.reply(200, Factory.build('commentsResult', null, { endorsed: false }));
await executeThunk(
fetchThreadComments(threadId, { endorsed: EndorsementStatus.UNENDORSED }),
store.dispatch,
store.getState,
);
axiosMock.onGet(commentsApiUrl)
.reply(200, Factory.build('commentsResult', null, { endorsed: true }));
await executeThunk(
fetchThreadComments(threadId, { endorsed: EndorsementStatus.ENDORSED }),
store.dispatch,
store.getState,
);
axiosMock.onPost(`${commentsApiUrl}`)
.reply(200, Factory.build('comment', {
thread_id: threadId,
raw_body: content,
rendered_body: content,
}));
await executeThunk(addComment(content, threadId, null), store.dispatch, store.getState);
expect(store.getState().comments.commentsInThreads[threadId])
.toEqual({
[EndorsementStatus.UNENDORSED]: [
'comment-1',
'comment-2',
'comment-3',
// Newly-added comment
'comment-7',
],
[EndorsementStatus.ENDORSED]: [
'comment-4',
'comment-5',
'comment-6',
],
});
expect(Object.keys(store.getState().comments.commentsById))
.toEqual(['comment-1', 'comment-2', 'comment-3', 'comment-4', 'comment-5', 'comment-6', 'comment-7']);
expect(store.getState().comments.commentsById['comment-7'].threadId)
.toEqual(threadId);
});
test('successfully handles comment edits', async () => {
const threadId = 'test-thread';
const commentId = 'comment-1';

View File

@@ -4,9 +4,9 @@ import { createSelector } from '@reduxjs/toolkit';
const selectCommentsById = state => state.comments.commentsById;
const mapIdToComment = (ids, comments) => ids.map(id => comments[id]);
export const selectThreadComments = threadId => createSelector(
export const selectThreadComments = (threadId, endorsed = null) => createSelector(
[
state => state.comments.commentsInThreads[threadId] || [],
state => state.comments.commentsInThreads[threadId]?.[endorsed] || [],
selectCommentsById,
],
mapIdToComment,
@@ -20,12 +20,12 @@ export const selectCommentResponses = commentId => createSelector(
mapIdToComment,
);
export const selectThreadHasMorePages = threadId => (
store => store.comments.pagination[threadId]?.hasMorePages || false
export const selectThreadHasMorePages = (threadId, endorsed = null) => (
store => store.comments.pagination[threadId]?.[endorsed]?.hasMorePages || false
);
export const selectThreadCurrentPage = threadId => (
store => store.comments.pagination[threadId]?.currentPage || null
export const selectThreadCurrentPage = (threadId, endorsed = null) => (
store => store.comments.pagination[threadId]?.[endorsed]?.currentPage || null
);
export const commentsStatus = state => state.comments.status;

View File

@@ -1,7 +1,7 @@
/* eslint-disable no-param-reassign,import/prefer-default-export */
import { createSlice } from '@reduxjs/toolkit';
import { RequestStatus } from '../../../data/constants';
import { EndorsementStatus, RequestStatus } from '../../../data/constants';
const commentsSlice = createSlice({
name: 'comments',
@@ -20,26 +20,29 @@ const commentsSlice = createSlice({
// TODO: save in localstorage so user can continue editing?
commentDraft: null,
postStatus: RequestStatus.SUCCESSFUL,
pagination: {
},
pagination: {},
},
reducers: {
fetchCommentsRequest: (state) => {
state.status = RequestStatus.IN_PROGRESS;
},
fetchCommentsSuccess: (state, { payload }) => {
const { threadId, endorsed } = payload;
// force endorsed to be null, true or false
state.status = RequestStatus.SUCCESSFUL;
state.commentsInThreads[payload.threadId] = [
...(state.commentsInThreads[payload.threadId] || []),
...(payload.commentsInThreads[payload.threadId] || []),
state.commentsInThreads[threadId] = state.commentsInThreads[threadId] ?? {};
state.pagination[threadId] = state.pagination[threadId] ?? {};
state.commentsInThreads[threadId][endorsed] = [
...(state.commentsInThreads[threadId][endorsed] || []),
...(payload.commentsInThreads[threadId] || []),
];
state.commentsInComments = { ...state.commentsInComments, ...payload.commentsInComments };
state.commentsById = { ...state.commentsById, ...payload.commentsById };
state.pagination[payload.threadId] = {
state.pagination[threadId][endorsed] = {
currentPage: payload.page,
totalPages: payload.pagination.numPages,
hasMorePages: Boolean(payload.pagination.next),
};
state.commentsInComments = { ...state.commentsInComments, ...payload.commentsInComments };
state.commentsById = { ...state.commentsById, ...payload.commentsById };
},
fetchCommentsFailed: (state) => {
state.status = RequestStatus.FAILED;
@@ -76,7 +79,12 @@ const commentsSlice = createSlice({
if (payload.parentId) {
state.commentsInComments[payload.parentId].push(payload.id);
} else {
state.commentsInThreads[payload.threadId].push(payload.id);
// The comment should be added to either the discussion or unendorsed
// sections since a new comment won't be endorsed yet.
(
state.commentsInThreads[payload.threadId][EndorsementStatus.DISCUSSION]
?? state.commentsInThreads[payload.threadId][EndorsementStatus.UNENDORSED]
).push(payload.id);
}
state.commentsById[payload.id] = payload;
state.commentDraft = null;
@@ -108,8 +116,13 @@ const commentsSlice = createSlice({
deleteCommentSuccess: (state, { payload }) => {
const { commentId } = payload;
const { threadId, parentId } = state.commentsById[commentId];
state.postStatus = RequestStatus.SUCCESSFUL;
state.commentsInThreads[threadId] = state.commentsInThreads[threadId].filter(item => item !== commentId);
[EndorsementStatus.DISCUSSION, EndorsementStatus.UNENDORSED, EndorsementStatus.ENDORSED].forEach((endorsed) => {
state.commentsInThreads[threadId][endorsed] = (
state.commentsInThreads[threadId]?.[endorsed]?.filter(item => item !== commentId)
);
});
if (parentId) {
state.commentsInComments[parentId] = state.commentsInComments[parentId].filter(item => item !== commentId);
}

View File

@@ -2,6 +2,7 @@
import { camelCaseObject } from '@edx/frontend-platform';
import { logError } from '@edx/frontend-platform/logging';
import { EndorsementStatus } from '../../../data/constants';
import { getHttpErrorStatus } from '../../utils';
import {
deleteComment, getCommentResponses, getThreadComments, postComment, updateComment,
@@ -72,13 +73,14 @@ function normaliseComments(data) {
};
}
export function fetchThreadComments(threadId, { page = 1 } = {}) {
export function fetchThreadComments(threadId, { page = 1, endorsed = EndorsementStatus.DISCUSSION } = {}) {
return async (dispatch) => {
try {
dispatch(fetchCommentsRequest({ threadId }));
const data = await getThreadComments(threadId, { page });
dispatch(fetchCommentsRequest());
const data = await getThreadComments(threadId, { page, endorsed });
dispatch(fetchCommentsSuccess({
...normaliseComments(camelCaseObject(data)),
endorsed,
page,
threadId,
}));

View File

@@ -11,10 +11,10 @@ const messages = defineMessages({
defaultMessage: 'Add a comment',
description: 'Button to add a comment to a response',
},
loadMoreComments: {
id: 'discussions.comments.comment.loadMoreComments',
defaultMessage: 'Load more comments',
description: 'Button to load more comments of forum posts',
loadMoreResponses: {
id: 'discussions.comments.comment.loadMoreResponses',
defaultMessage: 'Load more responses',
description: 'Button to load more responses of forum posts',
},
postVisibility: {
id: 'discussions.comments.comment.visibility',
@@ -37,6 +37,26 @@ const messages = defineMessages({
defaultMessage: 'Posted {relativeTime}',
description: 'Message about how long ago a comment was posted. Appears as "username posted 7 minutes ago"',
},
answer: {
id: 'discussions.comments.comment.answer',
defaultMessage: 'Answer',
description: 'Message above a comment that has been marked as the answer.',
},
answeredLabel: {
id: 'discussions.comments.comment.answeredlabel',
defaultMessage: 'Marked as answered by',
description: 'Message above a comment that has been marked as answered. Appears as "Marked as answered by Username"',
},
endorsed: {
id: 'discussions.comments.comment.endorsed',
defaultMessage: 'Endorsed',
description: 'Message above a comment that has been endorsed.',
},
endorsedLabel: {
id: 'discussions.comments.comment.endorsedlabel',
defaultMessage: 'Endorsed by',
description: 'Message above a comment that has been endorsed. Appears as "Endorsed by Username"',
},
actionsAlt: {
id: 'discussions.actions.label',
defaultMessage: 'Actions menu',
@@ -65,6 +85,10 @@ const messages = defineMessages({
id: 'discussions.editor.cancel',
defaultMessage: 'Cancel',
},
commentError: {
id: 'discussions.editor.error.empty',
defaultMessage: 'Post content cannot be empty.',
},
});
export default messages;

View File

@@ -9,7 +9,9 @@ import {
import { MoreVert } from '@edx/paragon/icons';
import { ContentActions } from '../../data/constants';
import { commentShape } from '../comments/comment/proptypes';
import messages from '../messages';
import { postShape } from '../posts/post/proptypes';
import { useActions } from '../utils';
function ActionsDropdown({
@@ -48,13 +50,12 @@ function ActionsDropdown({
>
<div className="bg-white p-1 shadow d-flex flex-column">
{actions.map(action => (
<>
<React.Fragment key={action.id}>
{action.action === ContentActions.DELETE
&& <Dropdown.Divider key="divider" />}
&& <Dropdown.Divider />}
<Dropdown.Item
as={Button}
key={action.id}
variant="tertiary"
size="inline"
onClick={() => {
@@ -65,7 +66,7 @@ function ActionsDropdown({
>
<Icon src={action.icon} className="mr-1" /> {intl.formatMessage(action.label)}
</Dropdown.Item>
</>
</React.Fragment>
))}
</div>
</ModalPopup>
@@ -75,7 +76,7 @@ function ActionsDropdown({
ActionsDropdown.propTypes = {
intl: intlShape.isRequired,
commentOrPost: PropTypes.objectOf(PropTypes.oneOfType([PropTypes.string, PropTypes.number])).isRequired,
commentOrPost: PropTypes.oneOfType([commentShape, postShape]).isRequired,
disabled: PropTypes.bool,
actionHandlers: PropTypes.objectOf(PropTypes.func).isRequired,
};

View File

@@ -2,7 +2,7 @@ import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import {
generatePath, Route, Switch, useHistory, useRouteMatch,
generatePath, Redirect, Route, Switch, useHistory, useRouteMatch,
} from 'react-router';
import { PostActionsBar } from '../../components';
@@ -19,7 +19,7 @@ export default function DiscussionsHome() {
const history = useHistory();
const { params } = useRouteMatch(Routes.DISCUSSIONS.PATH);
const postEditorVisible = useSelector(state => state.threads.postEditorVisible);
const { params: { page } } = useRouteMatch(Routes.COMMENTS.PAGE);
const { params: { page } } = useRouteMatch(`${Routes.COMMENTS.PAGE}?`);
const {
params: {
courseId,
@@ -48,44 +48,47 @@ export default function DiscussionsHome() {
topicId,
}}
>
<main className="container my-4 d-flex flex-row">
<div className="d-flex flex-column w-50 mr-1">
<main className="container-fluid d-flex flex-column p-0">
<div className="d-flex flex-row justify-content-between shadow navbar">
<Route path={Routes.DISCUSSIONS.PATH} component={NavigationBar} />
<Route
path={[
Routes.POSTS.PATH,
Routes.TOPICS.CATEGORY,
]}
component={BreadcrumbMenu}
/>
<div className="card">
<PostActionsBar />
</div>
<Route
path={[
Routes.POSTS.PATH,
Routes.TOPICS.CATEGORY,
]}
component={BreadcrumbMenu}
/>
<div className="d-flex flex-row">
<div className="d-flex flex-column w-25">
<Switch>
<Route path={Routes.POSTS.MY_POSTS}>
<PostsView showOwnPosts />
</Route>
<Route path={[Routes.POSTS.PATH, Routes.POSTS.ALL_POSTS]} component={PostsView} />
<Route path={Routes.TOPICS.PATH} component={TopicsView} />
<Redirect from={Routes.DISCUSSIONS.PATH} to={Routes.TOPICS.ALL} />
</Switch>
</div>
</div>
<div className="d-flex w-50 flex-column">
<PostActionsBar />
{
postEditorVisible ? (
<Route path={Routes.POSTS.NEW_POST}>
<PostEditor />
</Route>
) : (
<Switch>
<Route path={Routes.POSTS.EDIT_POST}>
<PostEditor editExisting />
<div className="d-flex w-75 flex-column bg-light-300">
{
postEditorVisible ? (
<Route path={Routes.POSTS.NEW_POST}>
<PostEditor />
</Route>
<Route path={Routes.COMMENTS.PATH}>
<CommentsView />
</Route>
</Switch>
)
}
) : (
<Switch>
<Route path={Routes.POSTS.EDIT_POST}>
<PostEditor editExisting />
</Route>
<Route path={Routes.COMMENTS.PATH}>
<CommentsView />
</Route>
</Switch>
)
}
</div>
</div>
</main>
</DiscussionContext.Provider>

View File

@@ -5,6 +5,7 @@ Factory.define('thread')
.sequence('title', (idx) => `This is Thread-${idx}`)
.sequence('raw_body', (idx) => `Some contents for **thread number ${idx}**.`)
.sequence('rendered_body', (idx) => `Some contents for <b>thread number ${idx}</b>.`)
.sequence('type', (idx) => (idx % 2 === 1 ? 'discussion' : 'question'))
.attr('comment_list_url', ['id'], (threadId) => `http://test.site/api/discussion/v1/comments/?thread_id=${threadId}`)
.attrs({
created_at: () => (new Date()).toISOString(),
@@ -29,7 +30,6 @@ Factory.define('thread')
topic_id: 'some-topic',
group_id: null,
group_name: null,
type: 'discussion',
abuse_flagged_count: 0,
pinned: false,
closed: false,

View File

@@ -19,7 +19,7 @@ function PostActionsBar({ intl }) {
/>
<div className="border-right mr-3 ml-4" />
<Button
variant="outline-primary"
variant="brand"
className="ml-2"
onClick={() => dispatch(showPostEditor())}
>

View File

@@ -8,7 +8,7 @@ const messages = defineMessages({
},
addAPost: {
id: 'discussion.posts.actionBar.add',
defaultMessage: 'Add post',
defaultMessage: 'Add a post',
description: 'Button to add a new discussion post',
},
});

View File

@@ -143,7 +143,7 @@ function PostEditor({
handleBlur,
handleChange,
}) => (
<Form className="mx-4 my-2" onSubmit={handleSubmit}>
<Form className="m-4 card p-4" onSubmit={handleSubmit}>
<h3>
{editExisting
? intl.formatMessage(messages.editPostHeading)
@@ -172,7 +172,7 @@ function PostEditor({
description={intl.formatMessage(messages.questionDescription)}
/>
</Form.RadioSet>
<Form.Group className="py-2 w-50">
<Form.Group className="py-3 w-50">
<Form.Control
name="topic"
as="select"
@@ -199,9 +199,9 @@ function PostEditor({
))}
</Form.Control>
</Form.Group>
<div className="border-bottom my-4" />
<div className="border-bottom my-1" />
<Form.Group
className="py-2"
className="py-2 mt-4"
isInvalid={isFormikFieldInvalid('title', {
errors,
touched,

View File

@@ -3,12 +3,13 @@ import PropTypes from 'prop-types';
import { useDispatch } from 'react-redux';
import { useHistory, useLocation } from 'react-router-dom';
import * as timeago from 'timeago.js';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import {
Icon, IconButton, OverlayTrigger, Tooltip,
} from '@edx/paragon';
import { StarFilled, StarOutline } from '@edx/paragon/icons';
import { QuestionAnswer, StarFilled, StarOutline } from '@edx/paragon/icons';
import { ContentActions } from '../../../data/constants';
import { removeThread, updateExistingThread } from '../data/thunks';
@@ -41,18 +42,17 @@ function Post({
className="d-block mt-2 mb-0 p-0 overflow-hidden text-break"
dangerouslySetInnerHTML={{ __html: post.renderedBody }}
style={{
maxHeight: preview ? '3rem' : null,
maxHeight: preview ? '2rem' : null,
maxWidth: preview ? '80%' : null,
}}
/>
<div className="d-flex align-items-center">
<div className="d-flex align-items-center mt-2">
<LikeButton
count={post.voteCount}
onClick={() => dispatch(updateExistingThread(post.id, { voted: !post.voted }))}
voted={post.voted}
/>
<OverlayTrigger
className="mx-2.5"
overlay={(
<Tooltip>
{intl.formatMessage(post.following ? messages.unfollow : messages.follow)}
@@ -67,10 +67,22 @@ function Post({
alt="Follow"
iconAs={Icon}
size="inline"
className="mx-2.5 my-0"
src={post.following ? StarFilled : StarOutline}
/>
</OverlayTrigger>
{post.following && <span>{intl.formatMessage(messages.following)}</span>}
{preview
&& (
<>
<Icon src={QuestionAnswer} className="mx-2" />
<span style={{ minWidth: '2rem' }}>
{post.commentCount}
</span>
</>
)}
<span title={post.createdAt} className="d-flex text-gray-500 x-small flex-fill justify-content-end">
{timeago.format(post.createdAt, intl.locale)}
</span>
</div>
</div>
);
@@ -79,7 +91,11 @@ function Post({
Post.propTypes = {
intl: intlShape.isRequired,
post: postShape.isRequired,
preview: PropTypes.bool.isRequired,
preview: PropTypes.bool,
};
Post.defaultProps = {
preview: false,
};
export default injectIntl(Post);

View File

@@ -2,13 +2,10 @@ import React from 'react';
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import * as timeago from 'timeago.js';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Avatar, Icon } from '@edx/paragon';
import {
Help, Pin, Post as PostIcon, QuestionAnswer,
} from '@edx/paragon/icons';
import { Avatar, Badge, Icon } from '@edx/paragon';
import { Help } from '@edx/paragon/icons';
import { ThreadType } from '../../../data/constants';
import ActionsDropdown from '../../common/ActionsDropdown';
@@ -16,21 +13,6 @@ import { selectAuthorAvatars } from '../data/selectors';
import messages from './messages';
import { postShape } from './proptypes';
export function PostTypeIcon(props) {
return (
<div className="m-1">
{props.type === ThreadType.QUESTION && <Icon src={Help} size="lg" />}
{props.type === ThreadType.DISCUSSION && <Icon src={PostIcon} size="lg" />}
{props.pinned && (<Icon src={Pin} />)}
</div>
);
}
PostTypeIcon.propTypes = {
type: PropTypes.string.isRequired,
pinned: PropTypes.bool.isRequired,
};
function PostHeader({
intl,
post,
@@ -41,35 +23,40 @@ function PostHeader({
return (
<div className="d-flex flex-fill">
<Avatar className="m-2" alt={post.author} src={authorAvatars?.imageUrlSmall} />
<PostTypeIcon type={post.type} pinned={post.pinned} />
<div className="mr-2">
{post.type === ThreadType.QUESTION && (
<Icon
src={Help}
className="position-absolute bg-white rounded-circle"
style={{
width: '1.75rem',
height: '1.75rem',
}}
/>
)}
<Avatar
size={post.type === ThreadType.QUESTION ? 'sm' : 'md'}
className={post.type === ThreadType.QUESTION ? 'mt-2.5 ml-2.5' : ''}
alt={post.author}
src={authorAvatars?.imageUrlSmall}
/>
</div>
<div className="align-items-center d-flex flex-row flex-fill">
<div className="d-flex flex-column flex-fill">
<div className="d-flex flex-column flex-fill justify-content-start">
{preview
? <span className="h4">{post.title}</span>
? (
<div className="h4 d-flex align-items-center">
<span className="flex-fill">
{post.title}
</span>
{preview && post.hasEndorsed && post.type === ThreadType.QUESTION
&& <Badge variant="success">{intl.formatMessage(messages.answered)}</Badge>}
</div>
)
: <h3>{post.title}</h3>}
<span title={post.createdAt} className="d-flex text-gray-500 x-small">
{intl.formatMessage(
messages.postedOn,
{
author: post.author,
time: timeago.format(post.createdAt, intl.locale),
authorLabel: post.authorLabel ? `(${post.authorLabel})` : '',
},
)}
</span>
</div>
</div>
{preview
? (
<div className="d-flex">
<Icon src={QuestionAnswer} />
<span style={{ minWidth: '2rem' }}>
{post.commentCount}
</span>
</div>
)
: <ActionsDropdown commentOrPost={post} actionHandlers={actionHandlers} />}
{!preview && <ActionsDropdown commentOrPost={post} actionHandlers={actionHandlers} />}
</div>
);
}

View File

@@ -22,6 +22,11 @@ const messages = defineMessages({
defaultMessage: 'Follow',
description: 'Tooltip/alttext for button to follow a discussion post',
},
answered: {
id: 'discussions.post.answered',
defaultMessage: 'Answered',
description: 'Tooltip/alttext for button to unfollow a discussion post',
},
unfollow: {
id: 'discussions.post.unfollow',
defaultMessage: 'Unfollow',