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:
@@ -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
30
src/data/hooks.js
Normal 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,
|
||||
];
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
<Hyperlink>{comment.endorsedBy}</Hyperlink>
|
||||
{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,
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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())}
|
||||
>
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user