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:
Kshitij Sobti
2021-09-11 11:47:27 +05:30
parent 669aa22400
commit 5db87f54bb
19 changed files with 578 additions and 253 deletions

View File

@@ -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,
};

View File

@@ -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]);

View File

@@ -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>

View File

@@ -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,

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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"',
},
});

View File

@@ -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);

View 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);

View 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,
});

View File

@@ -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>
);
}

View 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;

View File

@@ -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' } });

View File

@@ -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
);

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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);

View File

@@ -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>
);

View File

@@ -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)
),
);
}