From 5db87f54bb53fd4352a47e384030bc3a27cb7f17 Mon Sep 17 00:00:00 2001 From: Kshitij Sobti Date: Sat, 11 Sep 2021 11:47:27 +0530 Subject: [PATCH] feat: Add post actions dropdown menu Adds a dropdown menu for comments and posts to perform actions like pinning, unpinning, reporting etc. --- src/components/SelectableDropdown.jsx | 38 ++--- src/data/constants.js | 26 ++++ src/discussions/comments/CommentsView.jsx | 8 +- .../comments/comment-icons/CommentIcons.jsx | 30 ++-- src/discussions/comments/comment/Comment.jsx | 117 ++++++++++++---- src/discussions/comments/data/api.js | 20 ++- src/discussions/comments/messages.js | 6 +- src/discussions/comments/reply/Reply.jsx | 93 ------------- src/discussions/common/ActionsDropdown.jsx | 87 ++++++++++++ src/discussions/common/context.js | 9 ++ .../discussions-home/DiscussionsHome.jsx | 93 ++++++++----- src/discussions/messages.js | 71 ++++++++++ src/discussions/posts/data/api.js | 6 + src/discussions/posts/data/selectors.js | 2 +- src/discussions/posts/data/slices.js | 10 +- src/discussions/posts/data/thunks.js | 6 +- src/discussions/posts/post/Post.jsx | 61 +++++--- src/discussions/posts/post/PostLink.jsx | 18 ++- src/discussions/utils.js | 130 +++++++++++++++++- 19 files changed, 578 insertions(+), 253 deletions(-) delete mode 100644 src/discussions/comments/reply/Reply.jsx create mode 100644 src/discussions/common/ActionsDropdown.jsx create mode 100644 src/discussions/common/context.js create mode 100644 src/discussions/messages.js diff --git a/src/components/SelectableDropdown.jsx b/src/components/SelectableDropdown.jsx index ee8cd90d..9138f698 100644 --- a/src/components/SelectableDropdown.jsx +++ b/src/components/SelectableDropdown.jsx @@ -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 ( - - - {label} - - - { - options.map(option => ( - { - if (onChange) { - onChange(option); - } - } - } - > - {option.label} - - )) -} - - + + { + options.map(option => ( + onChange(option)}> + {option.label} + + )) + } + ); } @@ -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, }; diff --git a/src/data/constants.js b/src/data/constants.js index 42758eae..b87bf999 100644 --- a/src/data/constants.js +++ b/src/data/constants.js @@ -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]); diff --git a/src/discussions/comments/CommentsView.jsx b/src/discussions/comments/CommentsView.jsx index 34993c2c..1db7d05c 100644 --- a/src/discussions/comments/CommentsView.jsx +++ b/src/discussions/comments/CommentsView.jsx @@ -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 }) {
- {comments.map(reply => ( -
- + {comments.map(comment => ( +
+
))}
diff --git a/src/discussions/comments/comment-icons/CommentIcons.jsx b/src/discussions/comments/comment-icons/CommentIcons.jsx index a38202cc..a880dfe6 100644 --- a/src/discussions/comments/comment-icons/CommentIcons.jsx +++ b/src/discussions/comments/comment-icons/CommentIcons.jsx @@ -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 (
{/* Only show the star if the comment has a following attribute, indicating it can be followed */} - { following !== undefined && ( + {following !== undefined && ( following ? ( ) )} - {abuseFlagged && ( - - )}
); } 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, diff --git a/src/discussions/comments/comment/Comment.jsx b/src/discussions/comments/comment/Comment.jsx index befeee80..eb11426e 100644 --- a/src/discussions/comments/comment/Comment.jsx +++ b/src/discussions/comments/comment/Comment.jsx @@ -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 ( +
+
+ +
+ + {comment.author} {comment.authorLabel ? `(${comment.authorLabel})` : ''} + +
+ {intl.formatMessage(messages.commentTime, { + relativeTime: timeago.format(comment.createdAt, intl.locale), + })} +
+
+
+ +
+ ); +} + +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 ( -
-
-
-

- {comment.title} -

-
- {intl.formatMessage(messages.postTime, { - postType: comment.type, - relativeTime: timeago.format(comment.createdAt, intl.locale), - })} - {comment.author} -
-
- -
-
-
-
- {intl.formatMessage(messages.postVisibility, { group: comment.groupName })} -
+
+ +
+ dispatch(editComment(comment.id, { voted: !comment.voted }))} + voted={comment.voted} + /> +
+ {/* Pass along intl since component used here is the one before it's injected with `injectIntl` */} + {inlineReplies.map(inlineReply => )}
); } -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, diff --git a/src/discussions/comments/data/api.js b/src/discussions/comments/data/api.js index 762f4252..34235099 100644 --- a/src/discussions/comments/data/api.js +++ b/src/discussions/comments/data/api.js @@ -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; } diff --git a/src/discussions/comments/messages.js b/src/discussions/comments/messages.js index 577e47b5..69c7ba46 100644 --- a/src/discussions/comments/messages.js +++ b/src/discussions/comments/messages.js @@ -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"', }, }); diff --git a/src/discussions/comments/reply/Reply.jsx b/src/discussions/comments/reply/Reply.jsx deleted file mode 100644 index d85fc376..00000000 --- a/src/discussions/comments/reply/Reply.jsx +++ /dev/null @@ -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 ( -
-
- -
- -

- {reply.author} {reply.authorLabel ? `(${reply.authorLabel})` : '' } -

-
-

- {intl.formatMessage(messages.replyTime, { - relativeTime: timeago.format(reply.createdAt, intl.locale), - })} -

-
-
- { }} - size="sm" - src={MoreVert} - variant="primary" - /> -
- ); -} - -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 ( -
-
- -
- dispatch(editComment(reply.id, { voted: !reply.voted }))} - voted={reply.voted} - /> -
-
- {/* Pass along intl since the `Reply` component used here is the one before it's injected with `injectIntl` */} - {inlineReplies.map(inlineReply => )} -
-
- ); -} - -Reply.propTypes = { - reply: commentShape.isRequired, - intl: intlShape.isRequired, -}; - -export default injectIntl(Reply); diff --git a/src/discussions/common/ActionsDropdown.jsx b/src/discussions/common/ActionsDropdown.jsx new file mode 100644 index 00000000..9f30b049 --- /dev/null +++ b/src/discussions/common/ActionsDropdown.jsx @@ -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 ( + <> + + setOpen(!isOpen)} + alt={intl.formatMessage(messages.actionsAlt)} + src={MoreVert} + iconAs={Icon} + disabled={disabled} + /> + + setOpen(false)} + positionRef={dropdownIconRef} + isOpen={isOpen} + placement="auto-start" + > +
+ {actions.map(action => ( + <> + {action.action === ContentActions.DELETE + && } + + { + setOpen(false); + handleActions(action.action); + }} + className="d-flex justify-content-start py-1.5 mr-4" + > + {intl.formatMessage(action.label)} + + + ))} +
+
+ + ); +} + +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); diff --git a/src/discussions/common/context.js b/src/discussions/common/context.js new file mode 100644 index 00000000..bf61b6aa --- /dev/null +++ b/src/discussions/common/context.js @@ -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, +}); diff --git a/src/discussions/discussions-home/DiscussionsHome.jsx b/src/discussions/discussions-home/DiscussionsHome.jsx index cb87ed93..fb0f1d08 100644 --- a/src/discussions/discussions-home/DiscussionsHome.jsx +++ b/src/discussions/discussions-home/DiscussionsHome.jsx @@ -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 ( -
-
- - -
- - - - - - - -
-
-
- - { - postEditorVisible ? ( - - - - ) : ( + +
+
+ + +
- - - - - + + + + - ) - } -
-
+
+
+
+ + { + postEditorVisible ? ( + + + + ) : ( + + + + + + + + + ) + } +
+ + ); } diff --git a/src/discussions/messages.js b/src/discussions/messages.js new file mode 100644 index 00000000..d05e5f71 --- /dev/null +++ b/src/discussions/messages.js @@ -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; diff --git a/src/discussions/posts/data/api.js b/src/discussions/posts/data/api.js index ac6e74ee..dd2bda56 100644 --- a/src/discussions/posts/data/api.js +++ b/src/discussions/posts/data/api.js @@ -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' } }); diff --git a/src/discussions/posts/data/selectors.js b/src/discussions/posts/data/selectors.js index 33b55d73..2f8098c6 100644 --- a/src/discussions/posts/data/selectors.js +++ b/src/discussions/posts/data/selectors.js @@ -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 ); diff --git a/src/discussions/posts/data/slices.js b/src/discussions/posts/data/slices.js index eb7391e7..8f402d53 100644 --- a/src/discussions/posts/data/slices.js +++ b/src/discussions/posts/data/slices.js @@ -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; diff --git a/src/discussions/posts/data/thunks.js b/src/discussions/posts/data/thunks.js index 5e2c660b..befaaca2 100644 --- a/src/discussions/posts/data/thunks.js +++ b/src/discussions/posts/data/thunks.js @@ -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) { diff --git a/src/discussions/posts/post/Post.jsx b/src/discussions/posts/post/Post.jsx index 54a16a1a..d85cbf02 100644 --- a/src/discussions/posts/post/Post.jsx +++ b/src/discussions/posts/post/Post.jsx @@ -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 ( -
+
- + {post.title} @@ -86,12 +86,16 @@ function PostHeader({
-
- - - {post.commentCount} - -
+ {preview + ? ( +
+ + + {post.commentCount} + +
+ ) + : }
); } @@ -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 (
- -
+ +
{intl.formatMessage(messages.contentReported)}
)} -
- {!post.read && } - +
+ + +
); diff --git a/src/discussions/utils.js b/src/discussions/utils.js index c660b05d..ceeea504 100644 --- a/src/discussions/utils.js +++ b/src/discussions/utils.js @@ -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) + ), + ); +}