diff --git a/client/coral-admin/src/components/CommentAnimatedEdit.js b/client/coral-admin/src/components/CommentAnimatedEdit.js index 12280b204..f43cb9c89 100644 --- a/client/coral-admin/src/components/CommentAnimatedEdit.js +++ b/client/coral-admin/src/components/CommentAnimatedEdit.js @@ -29,7 +29,7 @@ const CommentAnimatedEdit = ({ children, body }) => { CommentAnimatedEdit.propTypes = { children: PropTypes.node, - body: PropTypes.string, + body: PropTypes.string.isRequired, }; export default CommentAnimatedEdit; diff --git a/client/coral-admin/src/components/CommentDeletedTombstone.css b/client/coral-admin/src/components/CommentDeletedTombstone.css new file mode 100644 index 000000000..81f5ddf7a --- /dev/null +++ b/client/coral-admin/src/components/CommentDeletedTombstone.css @@ -0,0 +1,5 @@ +.tombstone { + background-color: #f0f0f0; + padding: 1em; + color: #1a212f; +} \ No newline at end of file diff --git a/client/coral-admin/src/components/CommentDeletedTombstone.js b/client/coral-admin/src/components/CommentDeletedTombstone.js new file mode 100644 index 000000000..c04e97b88 --- /dev/null +++ b/client/coral-admin/src/components/CommentDeletedTombstone.js @@ -0,0 +1,9 @@ +import React from 'react'; +import styles from './CommentDeletedTombstone.css'; +import t from 'coral-framework/services/i18n'; + +const CommentDeletedTombstone = () => ( +
{t('framework.comment_is_deleted')}
+); + +export default CommentDeletedTombstone; diff --git a/client/coral-admin/src/components/UserDetailComment.js b/client/coral-admin/src/components/UserDetailComment.js index f2ff2e932..41fb87b28 100644 --- a/client/coral-admin/src/components/UserDetailComment.js +++ b/client/coral-admin/src/components/UserDetailComment.js @@ -12,6 +12,7 @@ import CommentAnimatedEdit from './CommentAnimatedEdit'; import CommentLabels from '../containers/CommentLabels'; import ApproveButton from './ApproveButton'; import RejectButton from 'coral-admin/src/components/RejectButton'; +import CommentDeletedTombstone from './CommentDeletedTombstone'; import t, { timeago } from 'coral-framework/services/i18n'; @@ -43,6 +44,19 @@ class UserDetailComment extends React.Component { body: comment.body, }; + if (!comment.body) { + return ( +
  • + +
  • + ); + } + return (
  • + +
  • + ); + } + return (
  • ` ${queue}: comments(query: { + excludeDeleted: true, statuses: ${ queueConfig[queue].statuses ? `[${queueConfig[queue].statuses.join(', ')}],` @@ -458,6 +459,7 @@ const withModQueueQuery = withQuery( ${Object.keys(queueConfig).map( queue => ` ${queue}Count: commentCount(query: { + excludeDeleted: true, statuses: ${ queueConfig[queue].statuses ? `[${queueConfig[queue].statuses.join(', ')}],` diff --git a/graph/loaders/comments.js b/graph/loaders/comments.js index 37d40a219..b42a636bc 100644 --- a/graph/loaders/comments.js +++ b/graph/loaders/comments.js @@ -94,6 +94,7 @@ const getCommentCountByQuery = (ctx, options) => { author_id, tags, action_type, + excludeDeleted, } = options; // If user queries for statuses other than NONE and/or ACCEPTED statuses, it needs @@ -120,6 +121,12 @@ const getCommentCountByQuery = (ctx, options) => { query.merge({ author_id }); } + if (excludeDeleted) { + // The null query matches documents that either contain the `deleted_at` + // field whose value is null or that do not contain the `deleted_at` field. + query.merge({ deleted_at: null }); + } + if (ctx.user != null && ctx.user.can(SEARCH_OTHERS_COMMENTS) && action_type) { query.merge({ [`action_counts.${sc(action_type.toLowerCase())}`]: { @@ -328,11 +335,12 @@ const getCommentsByQuery = async ( sortOrder, sortBy, excludeIgnored, + excludeDeleted, tags, action_type, } ) => { - let comments = CommentModel.find(); + const query = CommentModel.find(); // Enforce that the limit must be gte 0 if this option is not true. if (!ALLOW_NO_LIMIT_QUERIES && limit < 0) { @@ -350,11 +358,17 @@ const getCommentsByQuery = async ( } if (statuses) { - comments = comments.where({ status: { $in: statuses } }); + query.merge({ status: { $in: statuses } }); + } + + if (excludeDeleted) { + // The null query matches documents that either contain the `deleted_at` + // field whose value is null or that do not contain the `deleted_at` field. + query.merge({ deleted_at: null }); } if (ctx.user != null && ctx.user.can(SEARCH_OTHERS_COMMENTS) && action_type) { - comments = comments.where({ + query.merge({ [`action_counts.${sc(action_type.toLowerCase())}`]: { $gt: 0, }, @@ -362,7 +376,7 @@ const getCommentsByQuery = async ( } if (ids) { - comments = comments.find({ + query.merge({ id: { $in: ids, }, @@ -370,7 +384,7 @@ const getCommentsByQuery = async ( } if (tags) { - comments = comments.find({ + query.merge({ 'tags.tag.name': { $in: tags, }, @@ -383,17 +397,17 @@ const getCommentsByQuery = async ( (ctx.user.can(SEARCH_OTHERS_COMMENTS) || ctx.user.id === author_id) && author_id != null ) { - comments = comments.where({ author_id }); + query.merge({ author_id }); } if (asset_id) { - comments = comments.where({ asset_id }); + query.merge({ asset_id }); } // We perform the undefined check because, null, is a valid state for the // search to be with, which indicates that it is at depth 0. if (parent_id !== undefined) { - comments = comments.where({ parent_id }); + query.merge({ parent_id }); } if ( @@ -402,12 +416,12 @@ const getCommentsByQuery = async ( ctx.user.ignoresUsers && ctx.user.ignoresUsers.length > 0 ) { - comments = comments.where({ + query.merge({ author_id: { $nin: ctx.user.ignoresUsers }, }); } - return executeWithSort(ctx, comments, { cursor, sortOrder, sortBy, limit }); + return executeWithSort(ctx, query, { cursor, sortOrder, sortBy, limit }); }; /** diff --git a/graph/mutators/action.js b/graph/mutators/action.js index d759449b0..5557e1a9b 100644 --- a/graph/mutators/action.js +++ b/graph/mutators/action.js @@ -14,8 +14,15 @@ const getActionItem = async (ctx, { item_id, item_type }) => { const { loaders: { Comments, Users } } = ctx; switch (item_type) { - case 'COMMENTS': - return Comments.get.load(item_id); + case 'COMMENTS': { + // Get a comment by ID, unless the comment is deleted, then return null. + const comment = await Comments.get.load(item_id); + if (comment.deleted_at) { + return null; + } + + return comment; + } case 'USERS': return Users.getByID.load(item_id); default: diff --git a/graph/typeDefs.graphql b/graph/typeDefs.graphql index b5c193e3d..03c97ce58 100644 --- a/graph/typeDefs.graphql +++ b/graph/typeDefs.graphql @@ -418,6 +418,9 @@ input CommentsQuery { # Exclude comments ignored by the requesting user excludeIgnored: Boolean + + # excludeDeleted when true will exclude deleted comments from the response. + excludeDeleted: Boolean = false } input RepliesQuery { @@ -434,6 +437,9 @@ input RepliesQuery { # Exclude comments ignored by the requesting user excludeIgnored: Boolean + + # excludeDeleted when true will exclude deleted comments from the response. + excludeDeleted: Boolean = false } # CommentCountQuery allows the ability to query comment counts by specific @@ -463,6 +469,9 @@ input CommentCountQuery { # Filter by a specific tag name. tags: [String!] + + # excludeDeleted when true will exclude deleted comments from the count. + excludeDeleted: Boolean = false } # UserCountQuery allows the ability to query user counts by specific @@ -519,7 +528,7 @@ type Comment { replies(query: RepliesQuery = {}): CommentConnection! # replyCount is the number of replies with a depth of 1. Only direct replies - # to this comment are counted. + # to this comment are counted. Deleted comments are included in this count. replyCount: Int # Actions completed on the parent. Requires the `ADMIN` role. diff --git a/services/comments.js b/services/comments.js index 4b436b5f4..19b611089 100644 --- a/services/comments.js +++ b/services/comments.js @@ -39,9 +39,12 @@ module.exports = { const created_at = new Date(); // Check to see if we are replying to a comment, and if that comment is - // visible. + // visible and that it's not deleted. if (parent_id !== null) { - const parent = await CommentModel.findOne({ id: parent_id }); + const parent = await CommentModel.findOne({ + id: parent_id, + deleted_at: null, + }); if (parent === null || !parent.visible) { throw new ErrParentDoesNotVisible(); } @@ -94,6 +97,7 @@ module.exports = { status: { $in: EDITABLE_STATUSES, }, + deleted_at: null, }; // Establish the edit window (if it exists) and add the condition to the @@ -186,8 +190,10 @@ module.exports = { */ pushStatus: async (id, status, assigned_by = null) => { const created_at = new Date(); + + // Update the comment unless the comment was deleted. const originalComment = await CommentModel.findOneAndUpdate( - { id }, + { id, deleted_at: null }, { $push: { status_history: { diff --git a/services/tags.js b/services/tags.js index b4e70cb29..49d271c0b 100644 --- a/services/tags.js +++ b/services/tags.js @@ -13,6 +13,10 @@ const updateModel = async (item_type, query, update) => { switch (item_type) { case 'COMMENTS': Model = Comment; + + // Don't allow adding tags to deleted comments. + query.deleted_at = null; + break; case 'ASSETS': Model = Asset;