diff --git a/client/src/components/cards/Card/ProjectContent.jsx b/client/src/components/cards/Card/ProjectContent.jsx index 4d678bb7..3582b73a 100644 --- a/client/src/components/cards/Card/ProjectContent.jsx +++ b/client/src/components/cards/Card/ProjectContent.jsx @@ -49,11 +49,6 @@ const ProjectContent = React.memo(({ cardId }) => { [], ); - const selectCommentsTotalByCardId = useMemo( - () => selectors.makeSelectCommentsTotalByCardId(), - [], - ); - const selectAttachmentById = useMemo(() => selectors.makeSelectAttachmentById(), []); const card = useSelector((state) => selectCardById(state, cardId)); @@ -75,8 +70,6 @@ const ProjectContent = React.memo(({ cardId }) => { selectNotificationsTotalByCardId(state, cardId), ); - const commentsTotal = useSelector((state) => selectCommentsTotalByCardId(state, cardId)); - const coverUrl = useSelector((state) => { const attachment = selectAttachmentById(state, card.coverAttachmentId); return attachment && attachment.data.thumbnailUrls.outside360; @@ -121,9 +114,9 @@ const ProjectContent = React.memo(({ cardId }) => { card.description || card.dueDate || card.stopwatch || + card.commentsTotal > 0 || attachmentsTotal > 0 || notificationsTotal > 0 || - commentsTotal > 0 || listName; const isCompact = @@ -234,11 +227,11 @@ const ProjectContent = React.memo(({ cardId }) => { )} - {commentsTotal > 0 && ( + {card.commentsTotal > 0 && ( - {commentsTotal} + {card.commentsTotal} )} diff --git a/client/src/models/Card.js b/client/src/models/Card.js index 8fe5b4bb..6da30db5 100755 --- a/client/src/models/Card.js +++ b/client/src/models/Card.js @@ -20,6 +20,9 @@ export default class extends BaseModel { description: attr(), dueDate: attr(), stopwatch: attr(), + commentsTotal: attr({ + getDefault: () => 0, + }), createdAt: attr({ getDefault: () => new Date(), }), diff --git a/client/src/models/Comment.js b/client/src/models/Comment.js index cda99f60..eea83d5c 100755 --- a/client/src/models/Comment.js +++ b/client/src/models/Comment.js @@ -42,31 +42,47 @@ export default class extends BaseModel { break; case ActionTypes.COMMENT_CREATE: - case ActionTypes.COMMENT_CREATE_HANDLE: - case ActionTypes.COMMENT_UPDATE__SUCCESS: - case ActionTypes.COMMENT_UPDATE_HANDLE: - Comment.upsert(payload.comment); + case ActionTypes.COMMENT_CREATE_HANDLE: { + const commentModel = Comment.upsert(payload.comment); + + if (commentModel.card) { + commentModel.card.commentsTotal += 1; + } break; + } case ActionTypes.COMMENT_CREATE__SUCCESS: Comment.withId(payload.localId).delete(); Comment.upsert(payload.comment); break; - case ActionTypes.COMMENT_CREATE__FAILURE: - Comment.withId(payload.localId).delete(); + case ActionTypes.COMMENT_CREATE__FAILURE: { + const commentModel = Comment.withId(payload.localId); + commentModel.delete(); + + if (commentModel.card) { + commentModel.card.commentsTotal -= 1; + } break; + } case ActionTypes.COMMENT_UPDATE: Comment.withId(payload.id).update(payload.data); break; - case ActionTypes.COMMENT_DELETE: - Comment.withId(payload.id).delete(); + case ActionTypes.COMMENT_UPDATE__SUCCESS: + case ActionTypes.COMMENT_UPDATE_HANDLE: + Comment.upsert(payload.comment); break; - case ActionTypes.COMMENT_DELETE__SUCCESS: - case ActionTypes.COMMENT_DELETE_HANDLE: { + case ActionTypes.COMMENT_DELETE: { + const commentModel = Comment.withId(payload.id); + commentModel.delete(); + commentModel.card.commentsTotal -= 1; + + break; + } + case ActionTypes.COMMENT_DELETE__SUCCESS: { const commentModel = Comment.withId(payload.comment.id); if (commentModel) { @@ -75,6 +91,19 @@ export default class extends BaseModel { break; } + case ActionTypes.COMMENT_DELETE_HANDLE: { + const commentModel = Comment.withId(payload.comment.id); + + if (commentModel) { + commentModel.delete(); + + if (commentModel.card) { + commentModel.card.commentsTotal -= 1; + } + } + + break; + } default: } } diff --git a/client/src/selectors/comments.js b/client/src/selectors/comments.js index b1142f6f..38aab580 100644 --- a/client/src/selectors/comments.js +++ b/client/src/selectors/comments.js @@ -28,23 +28,7 @@ export const makeSelectCommentById = () => export const selectCommentById = makeSelectCommentById(); -export const makeSelectCommentsTotalByCardId = () => - createSelector( - orm, - (_, cardId) => cardId, - ({ Card }, cardId) => { - const cardModel = Card.withId(cardId); - - if (!cardModel) { - return 0; - } - - return cardModel.comments.count(); - }, - ); - export default { makeSelectCommentById, selectCommentById, - makeSelectCommentsTotalByCardId, }; diff --git a/server/api/hooks/query-methods/models/Comment.js b/server/api/hooks/query-methods/models/Comment.js index 91f0712f..7d4a528f 100644 --- a/server/api/hooks/query-methods/models/Comment.js +++ b/server/api/hooks/query-methods/models/Comment.js @@ -10,7 +10,25 @@ const defaultFind = (criteria, { limit } = {}) => /* Query methods */ -const createOne = (values) => Comment.create({ ...values }).fetch(); +const createOne = (values) => + sails.getDatastore().transaction(async (db) => { + const comment = await Comment.create({ ...values }) + .fetch() + .usingConnection(db); + + const queryResult = await sails + .sendNativeQuery( + 'UPDATE card SET comments_total = comments_total + 1, updated_at = $1 WHERE id = $2', + [new Date().toISOString(), comment.cardId], + ) + .usingConnection(db); + + if (queryResult.rowCount === 0) { + throw 'cardNotFound'; + } + + return comment; + }); const getByIds = (ids) => defaultFind(ids); @@ -35,9 +53,65 @@ const update = (criteria, values) => Comment.update(criteria).set(values).fetch( const updateOne = (criteria, values) => Comment.updateOne(criteria).set({ ...values }); // eslint-disable-next-line no-underscore-dangle -const delete_ = (criteria) => Comment.destroy(criteria).fetch(); +const delete_ = (criteria) => + sails.getDatastore().transaction(async (db) => { + const comments = await Comment.destroy(criteria).fetch().usingConnection(db); -const deleteOne = (criteria) => Comment.destroyOne(criteria); + if (comments.length > 0) { + const commentsByCardId = _.groupBy(comments, 'cardId'); + + const cardIdsByTotal = Object.entries(commentsByCardId).reduce( + (result, [cardId, commentsItem]) => ({ + ...result, + [commentsItem.length]: [...(result[commentsItem.length] || []), cardId], + }), + {}, + ); + + const queryValues = []; + let query = 'UPDATE card SET comments_total = comments_total - CASE '; + + Object.entries(cardIdsByTotal).forEach(([total, cardIds]) => { + const inValues = cardIds.map((cardId) => { + queryValues.push(cardId); + return `$${queryValues.length}`; + }); + + queryValues.push(total); + query += `WHEN id IN (${inValues.join(', ')}) THEN $${queryValues.length}::int `; + }); + + const inValues = Object.keys(commentsByCardId).map((cardId) => { + queryValues.push(cardId); + return `$${queryValues.length}`; + }); + + queryValues.push(new Date().toISOString()); + query += `END, updated_at = $${queryValues.length} WHERE id IN (${inValues.join(', ')})`; + + await sails.sendNativeQuery(query, queryValues).usingConnection(db); + } + + return comments; + }); + +const deleteOne = (criteria) => + sails.getDatastore().transaction(async (db) => { + const comment = await Comment.destroyOne(criteria).usingConnection(db); + + const queryResult = await sails + .sendNativeQuery( + 'UPDATE card SET comments_total = comments_total - 1, updated_at = $1 WHERE id = $2', + [new Date().toISOString(), comment.cardId], + ) + .usingConnection(db); + + if (queryResult.rowCount === 0) { + throw 'cardNotFound'; + } + + return comment; + }); module.exports = { createOne, diff --git a/server/api/models/Card.js b/server/api/models/Card.js index 02cf67a5..dc3073c2 100755 --- a/server/api/models/Card.js +++ b/server/api/models/Card.js @@ -48,6 +48,11 @@ module.exports = { stopwatch: { type: 'json', }, + commentsTotal: { + type: 'number', + defaultsTo: 0, + columnName: 'comments_total', + }, listChangedAt: { type: 'ref', columnName: 'list_changed_at', diff --git a/server/db/migrations/20250523131647_add_comments_counter.js b/server/db/migrations/20250523131647_add_comments_counter.js new file mode 100644 index 00000000..2108832a --- /dev/null +++ b/server/db/migrations/20250523131647_add_comments_counter.js @@ -0,0 +1,32 @@ +/*! + * Copyright (c) 2024 PLANKA Software GmbH + * Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md + */ + +exports.up = async (knex) => { + await knex.schema.alterTable('card', (table) => { + /* Columns */ + + table.integer('comments_total').notNullable().defaultTo(0); + }); + + await knex.raw(` + UPDATE card + SET comments_total = comments_total_by_card_id.comments_total + FROM ( + SELECT card_id, COUNT(*) as comments_total + FROM comment + GROUP BY card_id + ) AS comments_total_by_card_id + WHERE card.id = comments_total_by_card_id.card_id + `); + + return knex.schema.alterTable('card', (table) => { + table.integer('comments_total').notNullable().alter(); + }); +}; + +exports.down = (knex) => + knex.schema.table('card', (table) => { + table.dropColumn('comments_total'); + });