feat: Add post actions dropdown menu
Adds a dropdown menu for comments and posts to perform actions like pinning, unpinning, reporting etc.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Dropdown } from '@edx/paragon';
|
||||
import { MenuItem, SelectMenu } from '@edx/paragon';
|
||||
|
||||
function SelectableDropdown({
|
||||
options,
|
||||
@@ -9,30 +9,15 @@ function SelectableDropdown({
|
||||
label,
|
||||
}) {
|
||||
return (
|
||||
<Dropdown>
|
||||
<Dropdown.Toggle variant="link" size="sm">
|
||||
{label}
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu>
|
||||
{
|
||||
options.map(option => (
|
||||
<Dropdown.Item
|
||||
type="button"
|
||||
key={option.value}
|
||||
onClick={
|
||||
() => {
|
||||
if (onChange) {
|
||||
onChange(option);
|
||||
}
|
||||
}
|
||||
}
|
||||
>
|
||||
{option.label}
|
||||
</Dropdown.Item>
|
||||
))
|
||||
}
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
<SelectMenu defaultMessage={label}>
|
||||
{
|
||||
options.map(option => (
|
||||
<MenuItem key={option.value} onClick={() => onChange(option)}>
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
))
|
||||
}
|
||||
</SelectMenu>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -43,12 +28,11 @@ SelectableDropdown.propTypes = {
|
||||
label: PropTypes.string,
|
||||
}),
|
||||
).isRequired,
|
||||
onChange: PropTypes.func,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
label: PropTypes.node,
|
||||
};
|
||||
|
||||
SelectableDropdown.defaultProps = {
|
||||
onChange: null,
|
||||
label: null,
|
||||
};
|
||||
|
||||
|
||||
@@ -12,6 +12,27 @@ export const ThreadType = {
|
||||
DISCUSSION: 'discussion',
|
||||
};
|
||||
|
||||
/**
|
||||
* Edit actions for posts and comments
|
||||
* @readonly
|
||||
* @enum {string}
|
||||
*/
|
||||
export const ContentActions = {
|
||||
EDIT_CONTENT: 'raw_body',
|
||||
PIN: 'pin',
|
||||
ENDORSE: 'endorsed',
|
||||
CLOSE: 'closed',
|
||||
REPORT: 'abuse_flagged',
|
||||
DELETE: 'delete',
|
||||
FOLLOWING: 'following',
|
||||
CHANGE_GROUP: 'group_id',
|
||||
MARK_READ: 'read',
|
||||
CHANGE_TITLE: 'title',
|
||||
CHANGE_TOPIC: 'topic_id',
|
||||
CHANGE_TYPE: 'type',
|
||||
VOTE: 'voted',
|
||||
};
|
||||
|
||||
/**
|
||||
* Enum for request status.
|
||||
* @readonly
|
||||
@@ -140,3 +161,8 @@ export const Routes = {
|
||||
TOPIC: `${BASE_PATH}/topics/:topicId`,
|
||||
},
|
||||
};
|
||||
|
||||
export const ALL_ROUTES = []
|
||||
.concat(Routes.COMMENTS.PATH)
|
||||
.concat(Routes.TOPICS.PATH)
|
||||
.concat([Routes.POSTS.ALL_POSTS, Routes.POSTS.MY_POSTS]);
|
||||
|
||||
@@ -10,9 +10,9 @@ import { Button, Spinner } from '@edx/paragon';
|
||||
import { selectThread } from '../posts/data/selectors';
|
||||
import { markThreadAsRead } from '../posts/data/thunks';
|
||||
import Post from '../posts/post/Post';
|
||||
import Comment from './comment/Comment';
|
||||
import { selectThreadComments } from './data/selectors';
|
||||
import { fetchThreadComments } from './data/thunks';
|
||||
import Reply from './reply/Reply';
|
||||
import messages from './messages';
|
||||
|
||||
ensureConfig(['POST_MARK_AS_READ_DELAY'], 'Comment thread view');
|
||||
@@ -44,9 +44,9 @@ function CommentsView({ intl }) {
|
||||
<div className="list-group list-group-flush">
|
||||
<Post post={thread} />
|
||||
<div className="list-group">
|
||||
{comments.map(reply => (
|
||||
<div key={reply.id} className="list-group-item list-group-item-action">
|
||||
<Reply reply={reply} />
|
||||
{comments.map(comment => (
|
||||
<div key={comment.id} className="list-group-item list-group-item-action">
|
||||
<Comment comment={comment} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -2,23 +2,16 @@ import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Button, Icon } from '@edx/paragon';
|
||||
import {
|
||||
Flag,
|
||||
StarFilled,
|
||||
StarOutline,
|
||||
} from '@edx/paragon/icons';
|
||||
import { StarFilled, StarOutline } from '@edx/paragon/icons';
|
||||
|
||||
import LikeButton from '../../posts/post/LikeButton';
|
||||
|
||||
function CommentIcons(
|
||||
{
|
||||
abuseFlagged,
|
||||
count,
|
||||
following,
|
||||
onLike,
|
||||
voted,
|
||||
},
|
||||
) {
|
||||
function CommentIcons({
|
||||
count,
|
||||
following,
|
||||
onLike,
|
||||
voted,
|
||||
}) {
|
||||
return (
|
||||
<div className="d-flex flex-column icons">
|
||||
<LikeButton
|
||||
@@ -27,7 +20,7 @@ function CommentIcons(
|
||||
voted={voted}
|
||||
/>
|
||||
{/* Only show the star if the comment has a following attribute, indicating it can be followed */}
|
||||
{ following !== undefined && (
|
||||
{following !== undefined && (
|
||||
following
|
||||
? (
|
||||
<Button variant="link" className="p-0" size="xs">
|
||||
@@ -39,17 +32,11 @@ function CommentIcons(
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
{abuseFlagged && (
|
||||
<Button variant="link" className="p-0" size="xs">
|
||||
<Icon src={Flag} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
CommentIcons.propTypes = {
|
||||
abuseFlagged: PropTypes.bool,
|
||||
count: PropTypes.number.isRequired,
|
||||
following: PropTypes.bool,
|
||||
onLike: PropTypes.func,
|
||||
@@ -57,7 +44,6 @@ CommentIcons.propTypes = {
|
||||
};
|
||||
|
||||
CommentIcons.defaultProps = {
|
||||
abuseFlagged: undefined,
|
||||
following: undefined,
|
||||
onLike: undefined,
|
||||
voted: false,
|
||||
|
||||
@@ -1,51 +1,110 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect } 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 { Avatar } from '@edx/paragon';
|
||||
|
||||
import { ContentActions } from '../../../data/constants';
|
||||
import ActionsDropdown from '../../common/ActionsDropdown';
|
||||
import { selectAuthorAvatars } from '../../posts/data/selectors';
|
||||
import CommentIcons from '../comment-icons/CommentIcons';
|
||||
import { selectCommentResponses } from '../data/selectors';
|
||||
import { editComment, fetchCommentResponses, removeComment } from '../data/thunks';
|
||||
import messages from '../messages';
|
||||
|
||||
const commentShape = PropTypes.shape({
|
||||
createdAt: PropTypes.string,
|
||||
abuseFlagged: PropTypes.bool,
|
||||
renderedBody: PropTypes.string,
|
||||
author: PropTypes.string,
|
||||
authorLabel: PropTypes.string,
|
||||
users: PropTypes.objectOf(PropTypes.shape({
|
||||
profile: PropTypes.shape({
|
||||
hasImage: PropTypes.bool,
|
||||
imageUrlFull: PropTypes.string,
|
||||
imageUrlLarge: PropTypes.string,
|
||||
imageUrlMedium: PropTypes.string,
|
||||
imageUrlSmall: PropTypes.string,
|
||||
}),
|
||||
})),
|
||||
});
|
||||
|
||||
function CommentHeader({
|
||||
comment,
|
||||
actionHandlers,
|
||||
intl,
|
||||
}) {
|
||||
const authorAvatars = useSelector(selectAuthorAvatars(comment.author));
|
||||
return (
|
||||
<div className="d-flex flex-row justify-content-between">
|
||||
<div className="align-items-center d-flex flex-row">
|
||||
<Avatar className="m-2" alt={comment.author} src={authorAvatars?.imageUrlSmall} />
|
||||
<div className="status small">
|
||||
<a href="#nowhere" className="font-weight-normal text-info-300 mr-1 small">
|
||||
{comment.author} {comment.authorLabel ? `(${comment.authorLabel})` : ''}
|
||||
</a>
|
||||
<div className="font-weight-normal small text-gray-500" title={comment.createdAt}>
|
||||
{intl.formatMessage(messages.commentTime, {
|
||||
relativeTime: timeago.format(comment.createdAt, intl.locale),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ActionsDropdown commentOrPost={comment} actionHandlers={actionHandlers} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
CommentHeader.propTypes = {
|
||||
comment: commentShape.isRequired,
|
||||
actionHandlers: PropTypes.objectOf(PropTypes.func).isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
function Comment({
|
||||
comment,
|
||||
intl,
|
||||
}) {
|
||||
const dispatch = useDispatch();
|
||||
const hasChildren = !comment.parentId;
|
||||
const inlineReplies = useSelector(selectCommentResponses(comment.id));
|
||||
useEffect(() => {
|
||||
// If the comment has a parent comment, it won't have any children, so don't fetch them.
|
||||
if (hasChildren) {
|
||||
dispatch(fetchCommentResponses(comment.id));
|
||||
}
|
||||
}, [comment.id]);
|
||||
const actionHandlers = {
|
||||
// TODO: Will be added with reply editor
|
||||
[ContentActions.EDIT_CONTENT]: () => null,
|
||||
[ContentActions.ENDORSE]: () => dispatch(editComment(comment.id, { endorsed: !comment.endorsed })),
|
||||
// TODO: Add flow to confirm before deleting
|
||||
[ContentActions.DELETE]: () => dispatch(removeComment(comment.id)),
|
||||
[ContentActions.REPORT]: () => dispatch(editComment(comment.id, { flagged: !comment.abuseFlagged })),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="discussion-comment d-flex flex-column">
|
||||
<div className="header d-flex flex-row">
|
||||
<div className="d-flex flex-column flex-fill">
|
||||
<h4 className="title">
|
||||
{comment.title}
|
||||
</h4>
|
||||
<div className="status small">
|
||||
{intl.formatMessage(messages.postTime, {
|
||||
postType: comment.type,
|
||||
relativeTime: timeago.format(comment.createdAt, intl.locale),
|
||||
})}
|
||||
<span className="font-weight-bold text-info-300 ml-1">{comment.author}</span>
|
||||
</div>
|
||||
</div>
|
||||
<CommentIcons abuseFlagged={comment.abuseFlagged} following={comment.following} />
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<div className="d-flex" dangerouslySetInnerHTML={{ __html: comment.renderedBody }} />
|
||||
<div className="d-flex small text-gray-300">
|
||||
{intl.formatMessage(messages.postVisibility, { group: comment.groupName })}
|
||||
</div>
|
||||
<div className="bt-1 discussion-comment d-flex flex-column my-2">
|
||||
<CommentHeader comment={comment} intl={intl} actionHandlers={actionHandlers} />
|
||||
<div className="comment-body d-flex px-2" dangerouslySetInnerHTML={{ __html: comment.renderedBody }} />
|
||||
<CommentIcons
|
||||
abuseFlagged={comment.abuseFlagged}
|
||||
count={comment.voteCount}
|
||||
following={comment.following}
|
||||
onLike={() => dispatch(editComment(comment.id, { voted: !comment.voted }))}
|
||||
voted={comment.voted}
|
||||
/>
|
||||
<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} />)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const commentShape = PropTypes.shape({
|
||||
createdAt: PropTypes.string,
|
||||
abuseFlagged: PropTypes.bool,
|
||||
renderedBody: PropTypes.string,
|
||||
author: PropTypes.string,
|
||||
});
|
||||
|
||||
Comment.propTypes = {
|
||||
comment: commentShape.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
|
||||
@@ -75,14 +75,28 @@ export async function postComment(comment, threadId, parentId) {
|
||||
/**
|
||||
* Updates existing comment.
|
||||
* @param {string} commentId ID of comment to update.
|
||||
* @param {string} comment Raw updated comment data to post.
|
||||
* @param {string=} comment Raw updated comment data to post.
|
||||
* @param {boolean=} voted
|
||||
* @param {boolean=} flagged
|
||||
* @param {boolean=} endorsed
|
||||
* @returns {Promise<{}>}
|
||||
*/
|
||||
export async function updateComment(commentId, comment) {
|
||||
export async function updateComment(commentId, {
|
||||
comment,
|
||||
voted,
|
||||
flagged,
|
||||
endorsed,
|
||||
}) {
|
||||
const url = `${commentsApiUrl}${commentId}/`;
|
||||
const postData = {
|
||||
raw_body: comment,
|
||||
voted,
|
||||
abuse_flagged: flagged,
|
||||
endorsed,
|
||||
};
|
||||
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.patch(url, { raw_body: comment }, { headers: { 'Content-Type': 'application/merge-patch+json' } });
|
||||
.patch(url, postData, { headers: { 'Content-Type': 'application/merge-patch+json' } });
|
||||
return data;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,10 +22,10 @@ const messages = defineMessages({
|
||||
} posted {relativeTime} by`,
|
||||
description: 'Timestamp for when a user posted the message followed by username. The relative time is already translated.',
|
||||
},
|
||||
replyTime: {
|
||||
id: 'discussions.comments.comment.repliedTime',
|
||||
commentTime: {
|
||||
id: 'discussions.comments.comment.commentTime',
|
||||
defaultMessage: 'Posted {relativeTime}',
|
||||
description: 'Message about hwo long ago a reply was posted. Appears as "username posted 7 minutes ago"',
|
||||
description: 'Message about how long ago a comment was posted. Appears as "username posted 7 minutes ago"',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
import React, { useEffect } from 'react';
|
||||
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import * as timeago from 'timeago.js';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Avatar, Icon, IconButton } from '@edx/paragon';
|
||||
import { MoreVert } from '@edx/paragon/icons';
|
||||
|
||||
import { commentShape } from '../comment/Comment';
|
||||
import CommentIcons from '../comment-icons/CommentIcons';
|
||||
import { selectCommentResponses } from '../data/selectors';
|
||||
import { editComment, fetchCommentResponses } from '../data/thunks';
|
||||
import messages from '../messages';
|
||||
|
||||
function ReplyHeader({ reply, intl }) {
|
||||
return (
|
||||
<div className="d-flex flex-row justify-content-between">
|
||||
<div className="align-items-center d-flex flex-row">
|
||||
<Avatar className="m-2" alt={reply.author} src={reply.users?.[reply.author]?.profile.image.image_url_small} />
|
||||
<div className="status small">
|
||||
<a href="#nowhere">
|
||||
<h1 className="font-weight-normal text-info-300 mr-1 small">
|
||||
{reply.author} {reply.authorLabel ? `(${reply.authorLabel})` : '' }
|
||||
</h1>
|
||||
</a>
|
||||
<h2 className="font-weight-normal small text-gray-500" title={reply.createdAt}>
|
||||
{intl.formatMessage(messages.replyTime, {
|
||||
relativeTime: timeago.format(reply.createdAt, intl.locale),
|
||||
})}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
<IconButton
|
||||
alt="Options"
|
||||
iconAs={Icon}
|
||||
// TODO: Implement overlay
|
||||
onClick={() => { }}
|
||||
size="sm"
|
||||
src={MoreVert}
|
||||
variant="primary"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ReplyHeader.propTypes = {
|
||||
reply: commentShape.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
function Reply({
|
||||
reply,
|
||||
intl,
|
||||
}) {
|
||||
const dispatch = useDispatch();
|
||||
const hasChildren = !reply.parentId;
|
||||
const inlineReplies = useSelector(selectCommentResponses(reply.id));
|
||||
useEffect(() => {
|
||||
// If the comment has a parent comment, it won't have any children, so don't fetch them.
|
||||
if (hasChildren) {
|
||||
dispatch(fetchCommentResponses(reply.id));
|
||||
}
|
||||
}, [reply.id]);
|
||||
|
||||
return (
|
||||
<div className="bt-1 discussion-reply d-flex flex-column my-2">
|
||||
<div>
|
||||
<ReplyHeader reply={reply} intl={intl} />
|
||||
<div className="reply-body d-flex px-2" dangerouslySetInnerHTML={{ __html: reply.renderedBody }} />
|
||||
<CommentIcons
|
||||
abuseFlagged={reply.abuseFlagged}
|
||||
count={reply.voteCount}
|
||||
following={reply.following}
|
||||
// FIXME: this API call fails
|
||||
onLike={() => dispatch(editComment(reply.id, { voted: !reply.voted }))}
|
||||
voted={reply.voted}
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
{/* Pass along intl since the `Reply` component used here is the one before it's injected with `injectIntl` */}
|
||||
{inlineReplies.map(inlineReply => <Reply reply={inlineReply} key={inlineReply.id} intl={intl} />)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reply.propTypes = {
|
||||
reply: commentShape.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(Reply);
|
||||
87
src/discussions/common/ActionsDropdown.jsx
Normal file
87
src/discussions/common/ActionsDropdown.jsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
import {
|
||||
Button, Dropdown, Icon, IconButton, ModalPopup,
|
||||
} from '@edx/paragon';
|
||||
import { MoreVert } from '@edx/paragon/icons';
|
||||
|
||||
import { ContentActions } from '../../data/constants';
|
||||
import messages from '../messages';
|
||||
import { useActions } from '../utils';
|
||||
|
||||
function ActionsDropdown({
|
||||
intl,
|
||||
commentOrPost,
|
||||
disabled,
|
||||
actionHandlers,
|
||||
}) {
|
||||
const [isOpen, setOpen] = useState(false);
|
||||
const dropdownIconRef = React.useRef(null);
|
||||
const actions = useActions(commentOrPost);
|
||||
const handleActions = (action) => {
|
||||
const actionFunction = actionHandlers[action];
|
||||
if (actionFunction) {
|
||||
actionFunction();
|
||||
} else {
|
||||
logError(`Unknown or unimplemented action ${action}`);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<span ref={dropdownIconRef}>
|
||||
<IconButton
|
||||
onClick={() => setOpen(!isOpen)}
|
||||
alt={intl.formatMessage(messages.actionsAlt)}
|
||||
src={MoreVert}
|
||||
iconAs={Icon}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</span>
|
||||
<ModalPopup
|
||||
onClose={() => setOpen(false)}
|
||||
positionRef={dropdownIconRef}
|
||||
isOpen={isOpen}
|
||||
placement="auto-start"
|
||||
>
|
||||
<div className="bg-white p-1 shadow d-flex flex-column">
|
||||
{actions.map(action => (
|
||||
<>
|
||||
{action.action === ContentActions.DELETE
|
||||
&& <Dropdown.Divider key="divider" />}
|
||||
|
||||
<Dropdown.Item
|
||||
as={Button}
|
||||
key={action.id}
|
||||
variant="tertiary"
|
||||
size="inline"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
handleActions(action.action);
|
||||
}}
|
||||
className="d-flex justify-content-start py-1.5 mr-4"
|
||||
>
|
||||
<Icon src={action.icon} className="mr-1" /> {intl.formatMessage(action.label)}
|
||||
</Dropdown.Item>
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
</ModalPopup>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
ActionsDropdown.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
commentOrPost: PropTypes.objectOf(PropTypes.oneOfType([PropTypes.string, PropTypes.number])).isRequired,
|
||||
disabled: PropTypes.bool,
|
||||
actionHandlers: PropTypes.objectOf(PropTypes.func).isRequired,
|
||||
};
|
||||
|
||||
ActionsDropdown.defaultProps = {
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
export default injectIntl(ActionsDropdown);
|
||||
9
src/discussions/common/context.js
Normal file
9
src/discussions/common/context.js
Normal file
@@ -0,0 +1,9 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import React from 'react';
|
||||
|
||||
export const DiscussionContext = React.createContext({
|
||||
courseId: null,
|
||||
postId: null,
|
||||
category: null,
|
||||
commentId: null,
|
||||
});
|
||||
@@ -6,8 +6,9 @@ import {
|
||||
} from 'react-router';
|
||||
|
||||
import { PostActionsBar } from '../../components';
|
||||
import { Routes } from '../../data/constants';
|
||||
import { ALL_ROUTES, Routes } from '../../data/constants';
|
||||
import { CommentsView } from '../comments';
|
||||
import { DiscussionContext } from '../common/context';
|
||||
import { BreadcrumbMenu, NavigationBar } from '../navigation';
|
||||
import { PostEditor, PostsView } from '../posts';
|
||||
import { clearRedirect } from '../posts/data';
|
||||
@@ -18,6 +19,14 @@ 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: {
|
||||
courseId,
|
||||
postId,
|
||||
topicId,
|
||||
},
|
||||
} = useRouteMatch(ALL_ROUTES);
|
||||
const redirectToThread = useSelector(state => state.threads.redirectToThread);
|
||||
useEffect(() => {
|
||||
// After posting a new thread we'd like to redirect users to it, the topic and post id are temporarily
|
||||
@@ -32,45 +41,53 @@ export default function DiscussionsHome() {
|
||||
}, [redirectToThread]);
|
||||
|
||||
return (
|
||||
<main className="container my-4 d-flex flex-row">
|
||||
<div className="d-flex flex-column w-50 mr-1">
|
||||
<Route path={Routes.DISCUSSIONS.PATH} component={NavigationBar} />
|
||||
<Route
|
||||
path={[
|
||||
Routes.POSTS.PATH,
|
||||
Routes.TOPICS.CATEGORY,
|
||||
]}
|
||||
component={BreadcrumbMenu}
|
||||
/>
|
||||
<div className="card">
|
||||
<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} />
|
||||
</Switch>
|
||||
</div>
|
||||
</div>
|
||||
<div className="d-flex w-50 pl-1 flex-column">
|
||||
<PostActionsBar />
|
||||
{
|
||||
postEditorVisible ? (
|
||||
<Route path={Routes.POSTS.NEW_POST}>
|
||||
<PostEditor />
|
||||
</Route>
|
||||
) : (
|
||||
<DiscussionContext.Provider value={{
|
||||
page,
|
||||
courseId,
|
||||
postId,
|
||||
topicId,
|
||||
}}
|
||||
>
|
||||
<main className="container my-4 d-flex flex-row">
|
||||
<div className="d-flex flex-column w-50 mr-1">
|
||||
<Route path={Routes.DISCUSSIONS.PATH} component={NavigationBar} />
|
||||
<Route
|
||||
path={[
|
||||
Routes.POSTS.PATH,
|
||||
Routes.TOPICS.CATEGORY,
|
||||
]}
|
||||
component={BreadcrumbMenu}
|
||||
/>
|
||||
<div className="card">
|
||||
<Switch>
|
||||
<Route path={Routes.POSTS.EDIT_POST}>
|
||||
<PostEditor editExisting />
|
||||
</Route>
|
||||
<Route path={Routes.COMMENTS.PATH}>
|
||||
<CommentsView />
|
||||
<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} />
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<div className="d-flex w-50 pl-1 flex-column">
|
||||
<PostActionsBar />
|
||||
{
|
||||
postEditorVisible ? (
|
||||
<Route path={Routes.POSTS.NEW_POST}>
|
||||
<PostEditor />
|
||||
</Route>
|
||||
) : (
|
||||
<Switch>
|
||||
<Route path={Routes.POSTS.EDIT_POST}>
|
||||
<PostEditor editExisting />
|
||||
</Route>
|
||||
<Route path={Routes.COMMENTS.PATH}>
|
||||
<CommentsView />
|
||||
</Route>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</main>
|
||||
</DiscussionContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
71
src/discussions/messages.js
Normal file
71
src/discussions/messages.js
Normal file
@@ -0,0 +1,71 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
actionsAlt: {
|
||||
id: 'discussions.actions.button.alt',
|
||||
defaultMessage: 'Actions menu',
|
||||
description: 'Alt-text for dropdown button for actions related to a post or comment',
|
||||
},
|
||||
editAction: {
|
||||
id: 'discussions.actions.edit',
|
||||
defaultMessage: 'Edit',
|
||||
description: 'Action to edit a comment or post',
|
||||
},
|
||||
pinAction: {
|
||||
id: 'discussions.actions.pin',
|
||||
defaultMessage: 'Pin',
|
||||
description: 'Action to pin a post',
|
||||
},
|
||||
unpinAction: {
|
||||
id: 'discussions.actions.unpin',
|
||||
defaultMessage: 'Unpin',
|
||||
description: 'Action to unpin a post',
|
||||
},
|
||||
deleteAction: {
|
||||
id: 'discussions.actions.delete',
|
||||
defaultMessage: 'Delete',
|
||||
description: 'Action to delete a post or comment',
|
||||
},
|
||||
closeAction: {
|
||||
id: 'discussions.actions.close',
|
||||
defaultMessage: 'Close',
|
||||
description: 'Action to close a post',
|
||||
},
|
||||
reopenAction: {
|
||||
id: 'discussions.actions.reopen',
|
||||
defaultMessage: 'Reopen',
|
||||
description: 'Action to reopen a post',
|
||||
},
|
||||
reportAction: {
|
||||
id: 'discussions.actions.report',
|
||||
defaultMessage: 'Report',
|
||||
description: 'Action to report a post or comment',
|
||||
},
|
||||
unreportAction: {
|
||||
id: 'discussions.actions.unreport',
|
||||
defaultMessage: 'Unreport',
|
||||
description: 'Action to unreport a post or comment',
|
||||
},
|
||||
endorseAction: {
|
||||
id: 'discussions.actions.endorse',
|
||||
defaultMessage: 'Endorse',
|
||||
description: 'Action to endorse a comment',
|
||||
},
|
||||
unendorseAction: {
|
||||
id: 'discussions.actions.unendorse',
|
||||
defaultMessage: 'Unendorse',
|
||||
description: 'Action to unendorse a post or comment',
|
||||
},
|
||||
markAnsweredAction: {
|
||||
id: 'discussions.actions.markAnswered',
|
||||
defaultMessage: 'Mark as Answered',
|
||||
description: 'Action to mark a comment as answering a post',
|
||||
},
|
||||
unmarkAnsweredAction: {
|
||||
id: 'discussions.actions.unMarkAnswered',
|
||||
defaultMessage: 'Unmark as answered',
|
||||
description: 'Action to unmark a comment as answering a post',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -98,6 +98,8 @@ export async function postThread(courseId, topicId, type, title, content, follow
|
||||
* @param {boolean} voted
|
||||
* @param {boolean} read
|
||||
* @param {boolean} following
|
||||
* @param {boolean} closed
|
||||
* @param {boolean} pinned
|
||||
* @returns {Promise<{}>}
|
||||
*/
|
||||
export async function updateThread(threadId, {
|
||||
@@ -109,6 +111,8 @@ export async function updateThread(threadId, {
|
||||
title,
|
||||
content,
|
||||
following,
|
||||
closed,
|
||||
pinned,
|
||||
} = {}) {
|
||||
const url = `${threadsApiUrl}${threadId}/`;
|
||||
const patchData = snakeCaseObject({
|
||||
@@ -120,6 +124,8 @@ export async function updateThread(threadId, {
|
||||
title,
|
||||
raw_body: content,
|
||||
following,
|
||||
closed,
|
||||
pinned,
|
||||
});
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.patch(url, patchData, { headers: { 'Content-Type': 'application/merge-patch+json' } });
|
||||
|
||||
@@ -48,5 +48,5 @@ export const selectThreadSorting = () => state => state.threads.sortedBy;
|
||||
export const selectThreadFilters = () => state => state.threads.filters;
|
||||
|
||||
export const selectAuthorAvatars = author => state => (
|
||||
state.threads.avatars?.[author].profile.image
|
||||
state.threads.avatars?.[author]?.profile.image
|
||||
);
|
||||
|
||||
@@ -125,23 +125,23 @@ const threadsSlice = createSlice({
|
||||
},
|
||||
setSortedBy: (state, { payload }) => {
|
||||
state.sortedBy = payload;
|
||||
state.pages = {};
|
||||
state.pages = [];
|
||||
},
|
||||
setStatusFilter: (state, { payload }) => {
|
||||
state.filters.status = payload;
|
||||
state.pages = {};
|
||||
state.pages = [];
|
||||
},
|
||||
setAllPostsTypeFilter: (state, { payload }) => {
|
||||
state.filters.allPosts = payload;
|
||||
state.pages = {};
|
||||
state.pages = [];
|
||||
},
|
||||
setMyPostsTypeFilter: (state, { payload }) => {
|
||||
state.filters.myPosts = payload;
|
||||
state.pages = {};
|
||||
state.pages = [];
|
||||
},
|
||||
setSearchQuery: (state, { payload }) => {
|
||||
state.filters.search = payload;
|
||||
state.pages = {};
|
||||
state.pages = [];
|
||||
},
|
||||
showPostEditor: (state) => {
|
||||
state.postEditorVisible = true;
|
||||
|
||||
@@ -187,7 +187,7 @@ export function createNewThread({
|
||||
}
|
||||
|
||||
export function updateExistingThread(threadId, {
|
||||
flagged, voted, read, topicId, type, title, content, following,
|
||||
flagged, voted, read, topicId, type, title, content, following, closed, pinned,
|
||||
}) {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
@@ -201,6 +201,8 @@ export function updateExistingThread(threadId, {
|
||||
title,
|
||||
content,
|
||||
following,
|
||||
closed,
|
||||
pinned,
|
||||
}));
|
||||
const data = await updateThread(threadId, {
|
||||
flagged,
|
||||
@@ -211,6 +213,8 @@ export function updateExistingThread(threadId, {
|
||||
title,
|
||||
content,
|
||||
following,
|
||||
closed,
|
||||
pinned,
|
||||
});
|
||||
dispatch(updateThreadSuccess(camelCaseObject(data)));
|
||||
} catch (error) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useHistory, useLocation } from 'react-router';
|
||||
import * as timeago from 'timeago.js';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
@@ -9,17 +10,13 @@ import {
|
||||
Avatar, Icon, IconButton, OverlayTrigger, Tooltip,
|
||||
} from '@edx/paragon';
|
||||
import {
|
||||
Help,
|
||||
Pin,
|
||||
Post as PostIcon,
|
||||
QuestionAnswer,
|
||||
StarFilled,
|
||||
StarOutline,
|
||||
Help, Pin, Post as PostIcon, QuestionAnswer, StarFilled, StarOutline,
|
||||
} from '@edx/paragon/icons';
|
||||
|
||||
import { ThreadType } from '../../../data/constants';
|
||||
import { ContentActions, ThreadType } from '../../../data/constants';
|
||||
import ActionsDropdown from '../../common/ActionsDropdown';
|
||||
import { selectAuthorAvatars } from '../data/selectors';
|
||||
import { updateExistingThread } from '../data/thunks';
|
||||
import { removeThread, updateExistingThread } from '../data/thunks';
|
||||
import LikeButton from './LikeButton';
|
||||
import messages from './messages';
|
||||
|
||||
@@ -61,15 +58,18 @@ PostTypeIcon.propTypes = {
|
||||
function PostHeader({
|
||||
intl,
|
||||
post,
|
||||
preview,
|
||||
actionHandlers,
|
||||
}) {
|
||||
const authorAvatars = useSelector(selectAuthorAvatars(post.author));
|
||||
|
||||
return (
|
||||
<div className="d-flex flex-fill justify-content-between">
|
||||
<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="align-items-center d-flex flex-row flex-fill">
|
||||
<div className="d-flex flex-column flex-fill">
|
||||
<span className="d-flex font-weight-bold">
|
||||
<span className="font-weight-bold">
|
||||
{post.title}
|
||||
</span>
|
||||
<span className="d-flex text-gray-500 x-small">
|
||||
@@ -86,12 +86,16 @@ function PostHeader({
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="d-flex mr-3">
|
||||
<Icon src={QuestionAnswer} />
|
||||
<span style={{ minWidth: '2rem' }}>
|
||||
{post.commentCount}
|
||||
</span>
|
||||
</div>
|
||||
{preview
|
||||
? (
|
||||
<div className="d-flex">
|
||||
<Icon src={QuestionAnswer} />
|
||||
<span style={{ minWidth: '2rem' }}>
|
||||
{post.commentCount}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
: <ActionsDropdown commentOrPost={post} actionHandlers={actionHandlers} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -99,18 +103,38 @@ function PostHeader({
|
||||
PostHeader.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
post: postShape.isRequired,
|
||||
preview: PropTypes.bool.isRequired,
|
||||
actionHandlers: PropTypes.objectOf(PropTypes.func).isRequired,
|
||||
};
|
||||
|
||||
function Post({
|
||||
post,
|
||||
preview,
|
||||
intl,
|
||||
}) {
|
||||
const location = useLocation();
|
||||
const history = useHistory();
|
||||
const dispatch = useDispatch();
|
||||
const actionHandlers = {
|
||||
[ContentActions.EDIT_CONTENT]: () => history.push(`${location.pathname}/edit`),
|
||||
// TODO: Add flow to confirm before deleting
|
||||
[ContentActions.DELETE]: () => dispatch(removeThread(post.id)),
|
||||
[ContentActions.CLOSE]: () => dispatch(updateExistingThread(post.id, { closed: !post.closed })),
|
||||
[ContentActions.PIN]: () => dispatch(updateExistingThread(post.id, { pinned: !post.pinned })),
|
||||
[ContentActions.REPORT]: () => dispatch(updateExistingThread(post.id, { flagged: !post.abuseFlagged })),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="d-flex flex-column p-2.5 w-100">
|
||||
<PostHeader post={post} intl={intl} />
|
||||
<div className="mt-2 mb-0 p-0" dangerouslySetInnerHTML={{ __html: post.renderedBody }} />
|
||||
<PostHeader post={post} intl={intl} preview={preview} actionHandlers={actionHandlers} />
|
||||
<div
|
||||
className="d-block mt-2 mb-0 p-0 overflow-hidden text-break"
|
||||
dangerouslySetInnerHTML={{ __html: post.renderedBody }}
|
||||
style={{
|
||||
maxHeight: preview ? '3rem' : null,
|
||||
maxWidth: preview ? '80%' : null,
|
||||
}}
|
||||
/>
|
||||
<div className="d-flex align-items-center">
|
||||
<LikeButton
|
||||
count={post.voteCount}
|
||||
@@ -145,6 +169,7 @@ function Post({
|
||||
Post.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
post: postShape.isRequired,
|
||||
preview: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(Post);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import React, { useContext } from 'react';
|
||||
|
||||
import { generatePath, useRouteMatch } from 'react-router';
|
||||
import classNames from 'classnames';
|
||||
import { generatePath } from 'react-router';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
@@ -8,6 +9,7 @@ import { Icon } from '@edx/paragon';
|
||||
import { Flag, Unread } from '@edx/paragon/icons';
|
||||
|
||||
import { Routes } from '../../../data/constants';
|
||||
import { DiscussionContext } from '../../common/context';
|
||||
import messages from './messages';
|
||||
import Post, { postShape } from './Post';
|
||||
|
||||
@@ -15,7 +17,10 @@ function PostLink({
|
||||
post,
|
||||
intl,
|
||||
}) {
|
||||
const { params: { page } } = useRouteMatch(Routes.COMMENTS.PAGE);
|
||||
const {
|
||||
page,
|
||||
postId,
|
||||
} = useContext(DiscussionContext);
|
||||
const linkUrl = generatePath(Routes.COMMENTS.PAGES[page], {
|
||||
courseId: post.courseId,
|
||||
topicId: post.topicId,
|
||||
@@ -32,9 +37,10 @@ function PostLink({
|
||||
<span className="text-gray-700 x-small">{intl.formatMessage(messages.contentReported)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className={`d-flex flex-row p-2 ${post.read ? 'bg-light-200' : ''}`}>
|
||||
{!post.read && <Icon className="text-brand-500" src={Unread} />}
|
||||
<Post post={post} />
|
||||
<div className={classNames('d-flex flex-row flex-fill mw-100', { 'bg-light-200': post.read })}>
|
||||
<Icon className={classNames('p-0 mr-n3 text-brand-500', { invisible: post.read })} src={Unread} />
|
||||
<Post post={post} preview />
|
||||
<div className={classNames('d-flex pl-1.5 bg-info-500', { invisible: post.id !== postId })} />
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import { useContext } from 'react';
|
||||
|
||||
import { getIn } from 'formik';
|
||||
import { useRouteMatch } from 'react-router';
|
||||
|
||||
import { Routes } from '../data/constants';
|
||||
import { AppContext } from '@edx/frontend-platform/react';
|
||||
import {
|
||||
Delete, Edit, Flag, Pin, QuestionAnswer, VerifiedBadge,
|
||||
} from '@edx/paragon/icons';
|
||||
|
||||
export function buildIntlSelectionList(options, intl, messages) {
|
||||
import { ContentActions, Routes } from '../data/constants';
|
||||
import messages from './messages';
|
||||
|
||||
export function buildIntlSelectionList(options, intl, messagesData) {
|
||||
return Object.values(options)
|
||||
.map(
|
||||
option => (
|
||||
{
|
||||
label: intl.formatMessage(messages[option]),
|
||||
label: intl.formatMessage(messagesData[option]),
|
||||
value: option,
|
||||
}
|
||||
),
|
||||
@@ -45,3 +53,119 @@ export function useCommentsPagePath() {
|
||||
const { params } = useRouteMatch(Routes.COMMENTS.PAGE);
|
||||
return Routes.COMMENTS.PAGES[params.page];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the provided comment or post supports the provided option.
|
||||
* @param {{}} commentOrPost
|
||||
* @param {ContentActions} action
|
||||
* @param {UserData} user
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function permissionCheck(commentOrPost, action, user) {
|
||||
if (commentOrPost.editableFields.includes(action)) {
|
||||
return true;
|
||||
}
|
||||
if (action === ContentActions.DELETE && (user.administrator || commentOrPost.author === user.username)) {
|
||||
return true;
|
||||
}
|
||||
if ((action === ContentActions.CLOSE || action === ContentActions.PIN) && user.administrator) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* List of all possible actions for comments or posts.
|
||||
*
|
||||
* * `id` is a unique id for each action.
|
||||
* * `action` is the action being performed. One action can
|
||||
* have multiple mutually-exclusive entries (such as close/open)..
|
||||
* * `icon` is the icon component to show for the action.
|
||||
* * `label` is the translatable label message that can be passed to intl.
|
||||
* * `condition` is the array where the first one is a property of the post
|
||||
* or comment and the second is the value.
|
||||
* e.g. for ['pinned', false] the action will show up if the comment/post has post.pinned=false
|
||||
*/
|
||||
const ACTIONS_LIST = [
|
||||
{
|
||||
id: 'edit',
|
||||
action: ContentActions.EDIT_CONTENT,
|
||||
icon: Edit,
|
||||
label: messages.editAction,
|
||||
},
|
||||
{
|
||||
id: 'pin',
|
||||
action: ContentActions.PIN,
|
||||
icon: Pin,
|
||||
label: messages.pinAction,
|
||||
condition: ['pinned', false],
|
||||
},
|
||||
{
|
||||
id: 'unpin',
|
||||
action: ContentActions.PIN,
|
||||
icon: Pin,
|
||||
label: messages.unpinAction,
|
||||
condition: ['pinned', true],
|
||||
},
|
||||
{
|
||||
id: 'endorse',
|
||||
action: ContentActions.ENDORSE,
|
||||
icon: VerifiedBadge,
|
||||
label: messages.endorseAction,
|
||||
condition: ['endorsed', false],
|
||||
},
|
||||
{
|
||||
id: 'unendorse',
|
||||
action: ContentActions.ENDORSE,
|
||||
icon: VerifiedBadge,
|
||||
label: messages.unendorseAction,
|
||||
condition: ['endorsed', true],
|
||||
},
|
||||
{
|
||||
id: 'close',
|
||||
action: ContentActions.CLOSE,
|
||||
icon: QuestionAnswer,
|
||||
label: messages.closeAction,
|
||||
condition: ['closed', false],
|
||||
},
|
||||
{
|
||||
id: 'reopen',
|
||||
action: ContentActions.CLOSE,
|
||||
icon: QuestionAnswer,
|
||||
label: messages.reopenAction,
|
||||
condition: ['closed', true],
|
||||
},
|
||||
{
|
||||
id: 'report',
|
||||
action: ContentActions.REPORT,
|
||||
icon: Flag,
|
||||
label: messages.reportAction,
|
||||
condition: ['abuseFlagged', false],
|
||||
},
|
||||
{
|
||||
id: 'unreport',
|
||||
action: ContentActions.REPORT,
|
||||
icon: Flag,
|
||||
label: messages.unreportAction,
|
||||
condition: ['abuseFlagged', true],
|
||||
},
|
||||
{
|
||||
id: 'delete',
|
||||
action: ContentActions.DELETE,
|
||||
icon: Delete,
|
||||
label: messages.deleteAction,
|
||||
},
|
||||
];
|
||||
|
||||
export function useActions(commentOrPost) {
|
||||
const { authenticatedUser } = useContext(AppContext);
|
||||
return ACTIONS_LIST.filter(
|
||||
({
|
||||
action,
|
||||
condition = null,
|
||||
}) => (
|
||||
permissionCheck(commentOrPost, action, authenticatedUser)
|
||||
&& (condition ? commentOrPost[condition[0]] === condition[1] : true)
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user