-
-
- {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 (
-
-
-
{ }}
- 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)
+ ),
+ );
+}