feat(EE): resolve comments (#1420)

* feat: resolve comment (EE)

* Add resolve to comment mark in editor (EE)

* comment ui permissions

* sticky comment state tabs (EE)

* cleanup

* feat: add space_id to comments and allow space admins to delete any comment

- Add space_id column to comments table with data migration from pages
- Add last_edited_by_id, resolved_by_id, and updated_at columns to comments
- Update comment deletion permissions to allow space admins to delete any comment
- Backfill space_id on old comments

* fix foreign keys
This commit is contained in:
Philip Okugbe
2025-07-29 21:36:48 +01:00
committed by GitHub
parent ec12e80423
commit ca9558b246
23 changed files with 927 additions and 341 deletions

View File

@ -1,5 +1,6 @@
import { Mark, mergeAttributes } from "@tiptap/core";
import { commentDecoration } from "./comment-decoration";
import { Plugin } from "@tiptap/pm/state";
export interface ICommentOptions {
HTMLAttributes: Record<string, any>;
@ -19,6 +20,7 @@ declare module "@tiptap/core" {
unsetCommentDecoration: () => ReturnType;
setComment: (commentId: string) => ReturnType;
unsetComment: (commentId: string) => ReturnType;
setCommentResolved: (commentId: string, resolved: boolean) => ReturnType;
};
}
}
@ -53,6 +55,17 @@ export const Comment = Mark.create<ICommentOptions, ICommentStorage>({
};
},
},
resolved: {
default: false,
parseHTML: (element) => element.hasAttribute("data-resolved"),
renderHTML: (attributes) => {
if (!attributes.resolved) return {};
return {
"data-resolved": "true",
};
},
},
};
},
@ -60,9 +73,18 @@ export const Comment = Mark.create<ICommentOptions, ICommentStorage>({
return [
{
tag: "span[data-comment-id]",
getAttrs: (el) =>
!!(el as HTMLSpanElement).getAttribute("data-comment-id")?.trim() &&
null,
getAttrs: (el) => {
const element = el as HTMLSpanElement;
const commentId = element.getAttribute("data-comment-id")?.trim();
const resolved = element.hasAttribute("data-resolved");
if (!commentId) return false;
return {
commentId,
resolved,
};
},
},
];
},
@ -87,7 +109,8 @@ export const Comment = Mark.create<ICommentOptions, ICommentStorage>({
(commentId) =>
({ commands }) => {
if (!commentId) return false;
return commands.setMark(this.name, { commentId });
// Just add the new mark, do not remove existing ones
return commands.setMark(this.name, { commentId, resolved: false });
},
unsetComment:
(commentId) =>
@ -101,7 +124,7 @@ export const Comment = Mark.create<ICommentOptions, ICommentStorage>({
const commentMark = node.marks.find(
(mark) =>
mark.type.name === this.name &&
mark.attrs.commentId === commentId,
mark.attrs.commentId === commentId
);
if (commentMark) {
@ -109,6 +132,37 @@ export const Comment = Mark.create<ICommentOptions, ICommentStorage>({
}
});
return dispatch?.(tr);
},
setCommentResolved:
(commentId, resolved) =>
({ tr, dispatch }) => {
if (!commentId) return false;
tr.doc.descendants((node, pos) => {
const from = pos;
const to = pos + node.nodeSize;
const commentMark = node.marks.find(
(mark) =>
mark.type.name === this.name &&
mark.attrs.commentId === commentId
);
if (commentMark) {
// Remove the existing mark and add a new one with updated resolved state
tr = tr.removeMark(from, to, commentMark);
tr = tr.addMark(
from,
to,
this.type.create({
commentId: commentMark.attrs.commentId,
resolved: resolved,
})
);
}
});
return dispatch?.(tr);
},
};
@ -116,13 +170,15 @@ export const Comment = Mark.create<ICommentOptions, ICommentStorage>({
renderHTML({ HTMLAttributes }) {
const commentId = HTMLAttributes?.["data-comment-id"] || null;
const resolved = HTMLAttributes?.["data-resolved"] || false;
if (typeof window === "undefined" || typeof document === "undefined") {
return [
"span",
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
class: 'comment-mark',
class: resolved ? "comment-mark resolved" : "comment-mark",
"data-comment-id": commentId,
...(resolved && { "data-resolved": "true" }),
}),
0,
];
@ -131,9 +187,14 @@ export const Comment = Mark.create<ICommentOptions, ICommentStorage>({
const elem = document.createElement("span");
Object.entries(
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)
).forEach(([attr, val]) => elem.setAttribute(attr, val));
// Add resolved class if the comment is resolved
if (resolved) {
elem.classList.add("resolved");
}
elem.addEventListener("click", (e) => {
const selection = document.getSelection();
if (selection.type === "Range") return;
@ -141,7 +202,7 @@ export const Comment = Mark.create<ICommentOptions, ICommentStorage>({
this.storage.activeCommentId = commentId;
const commentEventClick = new CustomEvent("ACTIVE_COMMENT_EVENT", {
bubbles: true,
detail: { commentId },
detail: { commentId, resolved },
});
elem.dispatchEvent(commentEventClick);
@ -150,9 +211,7 @@ export const Comment = Mark.create<ICommentOptions, ICommentStorage>({
return elem;
},
// @ts-ignore
addProseMirrorPlugins(): Plugin[] {
// @ts-ignore
return [commentDecoration()];
},
});