mirror of
https://github.com/docmost/docmost.git
synced 2025-11-13 09:42:37 +10:00
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:
@ -213,7 +213,18 @@
|
|||||||
"Comment deleted successfully": "Comment deleted successfully",
|
"Comment deleted successfully": "Comment deleted successfully",
|
||||||
"Failed to delete comment": "Failed to delete comment",
|
"Failed to delete comment": "Failed to delete comment",
|
||||||
"Comment resolved successfully": "Comment resolved successfully",
|
"Comment resolved successfully": "Comment resolved successfully",
|
||||||
|
"Comment re-opened successfully": "Comment re-opened successfully",
|
||||||
|
"Comment unresolved successfully": "Comment unresolved successfully",
|
||||||
"Failed to resolve comment": "Failed to resolve comment",
|
"Failed to resolve comment": "Failed to resolve comment",
|
||||||
|
"Resolve comment": "Resolve comment",
|
||||||
|
"Unresolve comment": "Unresolve comment",
|
||||||
|
"Resolve Comment Thread": "Resolve Comment Thread",
|
||||||
|
"Unresolve Comment Thread": "Unresolve Comment Thread",
|
||||||
|
"Are you sure you want to resolve this comment thread? This will mark it as completed.": "Are you sure you want to resolve this comment thread? This will mark it as completed.",
|
||||||
|
"Are you sure you want to unresolve this comment thread?": "Are you sure you want to unresolve this comment thread?",
|
||||||
|
"Resolved": "Resolved",
|
||||||
|
"No active comments.": "No active comments.",
|
||||||
|
"No resolved comments.": "No resolved comments.",
|
||||||
"Revoke invitation": "Revoke invitation",
|
"Revoke invitation": "Revoke invitation",
|
||||||
"Revoke": "Revoke",
|
"Revoke": "Revoke",
|
||||||
"Don't": "Don't",
|
"Don't": "Don't",
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { Box, ScrollArea, Text } from "@mantine/core";
|
import { Box, ScrollArea, Text } from "@mantine/core";
|
||||||
import CommentList from "@/features/comment/components/comment-list.tsx";
|
import CommentListWithTabs from "@/features/comment/components/comment-list-with-tabs.tsx";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||||
import React, { ReactNode } from "react";
|
import React, { ReactNode } from "react";
|
||||||
@ -18,7 +18,7 @@ export default function Aside() {
|
|||||||
|
|
||||||
switch (tab) {
|
switch (tab) {
|
||||||
case "comments":
|
case "comments":
|
||||||
component = <CommentList />;
|
component = <CommentListWithTabs />;
|
||||||
title = "Comments";
|
title = "Comments";
|
||||||
break;
|
break;
|
||||||
case "toc":
|
case "toc":
|
||||||
@ -38,6 +38,9 @@ export default function Aside() {
|
|||||||
{t(title)}
|
{t(title)}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
|
{tab === "comments" ? (
|
||||||
|
<CommentListWithTabs />
|
||||||
|
) : (
|
||||||
<ScrollArea
|
<ScrollArea
|
||||||
style={{ height: "85vh" }}
|
style={{ height: "85vh" }}
|
||||||
scrollbarSize={5}
|
scrollbarSize={5}
|
||||||
@ -45,6 +48,7 @@ export default function Aside() {
|
|||||||
>
|
>
|
||||||
<div style={{ paddingBottom: "200px" }}>{component}</div>
|
<div style={{ paddingBottom: "200px" }}>{component}</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
67
apps/client/src/ee/comment/components/resolve-comment.tsx
Normal file
67
apps/client/src/ee/comment/components/resolve-comment.tsx
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import { ActionIcon, Tooltip } from "@mantine/core";
|
||||||
|
import { IconCircleCheck, IconCircleCheckFilled } from "@tabler/icons-react";
|
||||||
|
import { useResolveCommentMutation } from "@/ee/comment/queries/comment-query";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Editor } from "@tiptap/react";
|
||||||
|
|
||||||
|
interface ResolveCommentProps {
|
||||||
|
editor: Editor;
|
||||||
|
commentId: string;
|
||||||
|
pageId: string;
|
||||||
|
resolvedAt?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResolveComment({
|
||||||
|
editor,
|
||||||
|
commentId,
|
||||||
|
pageId,
|
||||||
|
resolvedAt,
|
||||||
|
}: ResolveCommentProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const resolveCommentMutation = useResolveCommentMutation();
|
||||||
|
|
||||||
|
const isResolved = resolvedAt != null;
|
||||||
|
const iconColor = isResolved ? "green" : "gray";
|
||||||
|
|
||||||
|
const handleResolveToggle = async () => {
|
||||||
|
try {
|
||||||
|
await resolveCommentMutation.mutateAsync({
|
||||||
|
commentId,
|
||||||
|
pageId,
|
||||||
|
resolved: !isResolved,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (editor) {
|
||||||
|
editor.commands.setCommentResolved(commentId, !isResolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to toggle resolved state:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
label={isResolved ? t("Re-Open comment") : t("Resolve comment")}
|
||||||
|
position="top"
|
||||||
|
>
|
||||||
|
<ActionIcon
|
||||||
|
onClick={handleResolveToggle}
|
||||||
|
variant="subtle"
|
||||||
|
color={isResolved ? "green" : "gray"}
|
||||||
|
size="sm"
|
||||||
|
loading={resolveCommentMutation.isPending}
|
||||||
|
disabled={resolveCommentMutation.isPending}
|
||||||
|
>
|
||||||
|
{isResolved ? (
|
||||||
|
<IconCircleCheckFilled size={18} />
|
||||||
|
) : (
|
||||||
|
<IconCircleCheck size={18} />
|
||||||
|
)}
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ResolveComment;
|
||||||
87
apps/client/src/ee/comment/queries/comment-query.ts
Normal file
87
apps/client/src/ee/comment/queries/comment-query.ts
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import {
|
||||||
|
useMutation,
|
||||||
|
useQueryClient,
|
||||||
|
} from "@tanstack/react-query";
|
||||||
|
import { resolveComment } from "@/features/comment/services/comment-service";
|
||||||
|
import {
|
||||||
|
IComment,
|
||||||
|
IResolveComment,
|
||||||
|
} from "@/features/comment/types/comment.types";
|
||||||
|
import { notifications } from "@mantine/notifications";
|
||||||
|
import { IPagination } from "@/lib/types.ts";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useQueryEmit } from "@/features/websocket/use-query-emit";
|
||||||
|
import { RQ_KEY } from "@/features/comment/queries/comment-query";
|
||||||
|
|
||||||
|
export function useResolveCommentMutation() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const emit = useQueryEmit();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: IResolveComment) => resolveComment(data),
|
||||||
|
onMutate: async (variables) => {
|
||||||
|
await queryClient.cancelQueries({ queryKey: RQ_KEY(variables.pageId) });
|
||||||
|
const previousComments = queryClient.getQueryData(RQ_KEY(variables.pageId));
|
||||||
|
queryClient.setQueryData(RQ_KEY(variables.pageId), (old: IPagination<IComment>) => {
|
||||||
|
if (!old || !old.items) return old;
|
||||||
|
const updatedItems = old.items.map((comment) =>
|
||||||
|
comment.id === variables.commentId
|
||||||
|
? {
|
||||||
|
...comment,
|
||||||
|
resolvedAt: variables.resolved ? new Date() : null,
|
||||||
|
resolvedById: variables.resolved ? 'optimistic-user' : null,
|
||||||
|
resolvedBy: variables.resolved ? { id: 'optimistic-user', name: 'Resolving...', avatarUrl: null } : null
|
||||||
|
}
|
||||||
|
: comment,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...old,
|
||||||
|
items: updatedItems,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return { previousComments };
|
||||||
|
},
|
||||||
|
onError: (err, variables, context) => {
|
||||||
|
if (context?.previousComments) {
|
||||||
|
queryClient.setQueryData(RQ_KEY(variables.pageId), context.previousComments);
|
||||||
|
}
|
||||||
|
notifications.show({
|
||||||
|
message: t("Failed to resolve comment"),
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: (data: IComment, variables) => {
|
||||||
|
const pageId = data.pageId;
|
||||||
|
const currentComments = queryClient.getQueryData(
|
||||||
|
RQ_KEY(pageId),
|
||||||
|
) as IPagination<IComment>;
|
||||||
|
if (currentComments && currentComments.items) {
|
||||||
|
const updatedComments = currentComments.items.map((comment) =>
|
||||||
|
comment.id === variables.commentId
|
||||||
|
? { ...comment, resolvedAt: data.resolvedAt, resolvedById: data.resolvedById, resolvedBy: data.resolvedBy }
|
||||||
|
: comment,
|
||||||
|
);
|
||||||
|
queryClient.setQueryData(RQ_KEY(pageId), {
|
||||||
|
...currentComments,
|
||||||
|
items: updatedComments,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
emit({
|
||||||
|
operation: "resolveComment",
|
||||||
|
pageId: pageId,
|
||||||
|
commentId: variables.commentId,
|
||||||
|
resolved: variables.resolved,
|
||||||
|
resolvedAt: data.resolvedAt,
|
||||||
|
resolvedById: data.resolvedById,
|
||||||
|
resolvedBy: data.resolvedBy,
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({ queryKey: RQ_KEY(pageId) });
|
||||||
|
notifications.show({
|
||||||
|
message: variables.resolved
|
||||||
|
? t("Comment resolved successfully")
|
||||||
|
: t("Comment re-opened successfully")
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { Group, Text, Box } from "@mantine/core";
|
import { Group, Text, Box, Badge } from "@mantine/core";
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import classes from "./comment.module.css";
|
import classes from "./comment.module.css";
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
@ -7,22 +7,34 @@ import CommentEditor from "@/features/comment/components/comment-editor";
|
|||||||
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms";
|
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms";
|
||||||
import CommentActions from "@/features/comment/components/comment-actions";
|
import CommentActions from "@/features/comment/components/comment-actions";
|
||||||
import CommentMenu from "@/features/comment/components/comment-menu";
|
import CommentMenu from "@/features/comment/components/comment-menu";
|
||||||
|
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee";
|
||||||
|
import ResolveComment from "@/ee/comment/components/resolve-comment";
|
||||||
import { useHover } from "@mantine/hooks";
|
import { useHover } from "@mantine/hooks";
|
||||||
import {
|
import {
|
||||||
useDeleteCommentMutation,
|
useDeleteCommentMutation,
|
||||||
useUpdateCommentMutation,
|
useUpdateCommentMutation,
|
||||||
} from "@/features/comment/queries/comment-query";
|
} from "@/features/comment/queries/comment-query";
|
||||||
|
import { useResolveCommentMutation } from "@/ee/comment/queries/comment-query";
|
||||||
import { IComment } from "@/features/comment/types/comment.types";
|
import { IComment } from "@/features/comment/types/comment.types";
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||||
import { useQueryEmit } from "@/features/websocket/use-query-emit";
|
import { useQueryEmit } from "@/features/websocket/use-query-emit";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface CommentListItemProps {
|
interface CommentListItemProps {
|
||||||
comment: IComment;
|
comment: IComment;
|
||||||
pageId: string;
|
pageId: string;
|
||||||
|
canComment: boolean;
|
||||||
|
userSpaceRole?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function CommentListItem({ comment, pageId }: CommentListItemProps) {
|
function CommentListItem({
|
||||||
|
comment,
|
||||||
|
pageId,
|
||||||
|
canComment,
|
||||||
|
userSpaceRole,
|
||||||
|
}: CommentListItemProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { hovered, ref } = useHover();
|
const { hovered, ref } = useHover();
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
@ -30,11 +42,13 @@ function CommentListItem({ comment, pageId }: CommentListItemProps) {
|
|||||||
const [content, setContent] = useState<string>(comment.content);
|
const [content, setContent] = useState<string>(comment.content);
|
||||||
const updateCommentMutation = useUpdateCommentMutation();
|
const updateCommentMutation = useUpdateCommentMutation();
|
||||||
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
|
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
|
||||||
|
const resolveCommentMutation = useResolveCommentMutation();
|
||||||
const [currentUser] = useAtom(currentUserAtom);
|
const [currentUser] = useAtom(currentUserAtom);
|
||||||
const emit = useQueryEmit();
|
const emit = useQueryEmit();
|
||||||
|
const isCloudEE = useIsCloudEE();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setContent(comment.content)
|
setContent(comment.content);
|
||||||
}, [comment]);
|
}, [comment]);
|
||||||
|
|
||||||
async function handleUpdateComment() {
|
async function handleUpdateComment() {
|
||||||
@ -72,8 +86,35 @@ function CommentListItem({ comment, pageId }: CommentListItemProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleResolveComment() {
|
||||||
|
if (!isCloudEE) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const isResolved = comment.resolvedAt != null;
|
||||||
|
|
||||||
|
await resolveCommentMutation.mutateAsync({
|
||||||
|
commentId: comment.id,
|
||||||
|
pageId: comment.pageId,
|
||||||
|
resolved: !isResolved,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (editor) {
|
||||||
|
editor.commands.setCommentResolved(comment.id, !isResolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
emit({
|
||||||
|
operation: "invalidateComment",
|
||||||
|
pageId: pageId,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to toggle resolved state:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleCommentClick(comment: IComment) {
|
function handleCommentClick(comment: IComment) {
|
||||||
const el = document.querySelector(`.comment-mark[data-comment-id="${comment.id}"]`);
|
const el = document.querySelector(
|
||||||
|
`.comment-mark[data-comment-id="${comment.id}"]`,
|
||||||
|
);
|
||||||
if (el) {
|
if (el) {
|
||||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
el.classList.add("comment-highlight");
|
el.classList.add("comment-highlight");
|
||||||
@ -106,28 +147,42 @@ function CommentListItem({ comment, pageId }: CommentListItemProps) {
|
|||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<div style={{ visibility: hovered ? "visible" : "hidden" }}>
|
<div style={{ visibility: hovered ? "visible" : "hidden" }}>
|
||||||
{/*!comment.parentCommentId && (
|
{!comment.parentCommentId && canComment && isCloudEE && (
|
||||||
<ResolveComment commentId={comment.id} pageId={comment.pageId} resolvedAt={comment.resolvedAt} />
|
<ResolveComment
|
||||||
)*/}
|
editor={editor}
|
||||||
|
commentId={comment.id}
|
||||||
|
pageId={comment.pageId}
|
||||||
|
resolvedAt={comment.resolvedAt}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{currentUser?.user?.id === comment.creatorId && (
|
{(currentUser?.user?.id === comment.creatorId || userSpaceRole === 'admin') && (
|
||||||
<CommentMenu
|
<CommentMenu
|
||||||
onEditComment={handleEditToggle}
|
onEditComment={handleEditToggle}
|
||||||
onDeleteComment={handleDeleteComment}
|
onDeleteComment={handleDeleteComment}
|
||||||
|
onResolveComment={handleResolveComment}
|
||||||
|
canEdit={currentUser?.user?.id === comment.creatorId}
|
||||||
|
isResolved={comment.resolvedAt != null}
|
||||||
|
isParentComment={!comment.parentCommentId}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
<Group gap="xs">
|
||||||
<Text size="xs" fw={500} c="dimmed">
|
<Text size="xs" fw={500} c="dimmed">
|
||||||
{timeAgo(comment.createdAt)}
|
{timeAgo(comment.createdAt)}
|
||||||
</Text>
|
</Text>
|
||||||
|
</Group>
|
||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
{!comment.parentCommentId && comment?.selection && (
|
{!comment.parentCommentId && comment?.selection && (
|
||||||
<Box className={classes.textSelection} onClick={() => handleCommentClick(comment)}>
|
<Box
|
||||||
|
className={classes.textSelection}
|
||||||
|
onClick={() => handleCommentClick(comment)}
|
||||||
|
>
|
||||||
<Text size="sm">{comment?.selection}</Text>
|
<Text size="sm">{comment?.selection}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -0,0 +1,318 @@
|
|||||||
|
import React, { useState, useRef, useCallback, memo, useMemo } from "react";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import { Divider, Paper, Tabs, Badge, Text, ScrollArea } from "@mantine/core";
|
||||||
|
import CommentListItem from "@/features/comment/components/comment-list-item";
|
||||||
|
import {
|
||||||
|
useCommentsQuery,
|
||||||
|
useCreateCommentMutation,
|
||||||
|
} from "@/features/comment/queries/comment-query";
|
||||||
|
import CommentEditor from "@/features/comment/components/comment-editor";
|
||||||
|
import CommentActions from "@/features/comment/components/comment-actions";
|
||||||
|
import { useFocusWithin } from "@mantine/hooks";
|
||||||
|
import { IComment } from "@/features/comment/types/comment.types.ts";
|
||||||
|
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||||
|
import { IPagination } from "@/lib/types.ts";
|
||||||
|
import { extractPageSlugId } from "@/lib";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useQueryEmit } from "@/features/websocket/use-query-emit";
|
||||||
|
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee";
|
||||||
|
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
||||||
|
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
|
||||||
|
import {
|
||||||
|
SpaceCaslAction,
|
||||||
|
SpaceCaslSubject,
|
||||||
|
} from "@/features/space/permissions/permissions.type.ts";
|
||||||
|
|
||||||
|
function CommentListWithTabs() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { pageSlug } = useParams();
|
||||||
|
const { data: page } = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
|
||||||
|
const {
|
||||||
|
data: comments,
|
||||||
|
isLoading: isCommentsLoading,
|
||||||
|
isError,
|
||||||
|
} = useCommentsQuery({ pageId: page?.id, limit: 100 });
|
||||||
|
const createCommentMutation = useCreateCommentMutation();
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const emit = useQueryEmit();
|
||||||
|
const isCloudEE = useIsCloudEE();
|
||||||
|
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
|
||||||
|
|
||||||
|
const spaceRules = space?.membership?.permissions;
|
||||||
|
const spaceAbility = useSpaceAbility(spaceRules);
|
||||||
|
|
||||||
|
const canComment: boolean = spaceAbility.can(
|
||||||
|
SpaceCaslAction.Manage,
|
||||||
|
SpaceCaslSubject.Page
|
||||||
|
);
|
||||||
|
|
||||||
|
// Separate active and resolved comments
|
||||||
|
const { activeComments, resolvedComments } = useMemo(() => {
|
||||||
|
if (!comments?.items) {
|
||||||
|
return { activeComments: [], resolvedComments: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentComments = comments.items.filter(
|
||||||
|
(comment: IComment) => comment.parentCommentId === null
|
||||||
|
);
|
||||||
|
|
||||||
|
const active = parentComments.filter(
|
||||||
|
(comment: IComment) => !comment.resolvedAt
|
||||||
|
);
|
||||||
|
const resolved = parentComments.filter(
|
||||||
|
(comment: IComment) => comment.resolvedAt
|
||||||
|
);
|
||||||
|
|
||||||
|
return { activeComments: active, resolvedComments: resolved };
|
||||||
|
}, [comments]);
|
||||||
|
|
||||||
|
const handleAddReply = useCallback(
|
||||||
|
async (commentId: string, content: string) => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
const commentData = {
|
||||||
|
pageId: page?.id,
|
||||||
|
parentCommentId: commentId,
|
||||||
|
content: JSON.stringify(content),
|
||||||
|
};
|
||||||
|
|
||||||
|
await createCommentMutation.mutateAsync(commentData);
|
||||||
|
|
||||||
|
emit({
|
||||||
|
operation: "invalidateComment",
|
||||||
|
pageId: page?.id,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to post comment:", error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[createCommentMutation, page?.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderComments = useCallback(
|
||||||
|
(comment: IComment) => (
|
||||||
|
<Paper
|
||||||
|
shadow="sm"
|
||||||
|
radius="md"
|
||||||
|
p="sm"
|
||||||
|
mb="sm"
|
||||||
|
withBorder
|
||||||
|
key={comment.id}
|
||||||
|
data-comment-id={comment.id}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<CommentListItem
|
||||||
|
comment={comment}
|
||||||
|
pageId={page?.id}
|
||||||
|
canComment={canComment}
|
||||||
|
userSpaceRole={space?.membership?.role}
|
||||||
|
/>
|
||||||
|
<MemoizedChildComments
|
||||||
|
comments={comments}
|
||||||
|
parentId={comment.id}
|
||||||
|
pageId={page?.id}
|
||||||
|
canComment={canComment}
|
||||||
|
userSpaceRole={space?.membership?.role}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!comment.resolvedAt && canComment && (
|
||||||
|
<>
|
||||||
|
<Divider my={4} />
|
||||||
|
<CommentEditorWithActions
|
||||||
|
commentId={comment.id}
|
||||||
|
onSave={handleAddReply}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
),
|
||||||
|
[comments, handleAddReply, isLoading, space?.membership?.role]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isCommentsLoading) {
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return <div>{t("Error loading comments.")}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalComments = activeComments.length + resolvedComments.length;
|
||||||
|
|
||||||
|
// If not cloud/enterprise, show simple list without tabs
|
||||||
|
if (!isCloudEE) {
|
||||||
|
if (totalComments === 0) {
|
||||||
|
return <>{t("No comments yet.")}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollArea style={{ height: "85vh" }} scrollbarSize={5} type="scroll">
|
||||||
|
<div style={{ paddingBottom: "200px" }}>
|
||||||
|
{comments?.items
|
||||||
|
.filter((comment: IComment) => comment.parentCommentId === null)
|
||||||
|
.map((comment) => (
|
||||||
|
<Paper
|
||||||
|
shadow="sm"
|
||||||
|
radius="md"
|
||||||
|
p="sm"
|
||||||
|
mb="sm"
|
||||||
|
withBorder
|
||||||
|
key={comment.id}
|
||||||
|
data-comment-id={comment.id}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<CommentListItem
|
||||||
|
comment={comment}
|
||||||
|
pageId={page?.id}
|
||||||
|
canComment={canComment}
|
||||||
|
userSpaceRole={space?.membership?.role}
|
||||||
|
/>
|
||||||
|
<MemoizedChildComments
|
||||||
|
comments={comments}
|
||||||
|
parentId={comment.id}
|
||||||
|
pageId={page?.id}
|
||||||
|
canComment={canComment}
|
||||||
|
userSpaceRole={space?.membership?.role}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Paper>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ height: "85vh", display: "flex", flexDirection: "column", marginTop: '-15px' }}>
|
||||||
|
<Tabs defaultValue="open" variant="default" style={{ flex: "0 0 auto" }}>
|
||||||
|
<Tabs.List justify="center">
|
||||||
|
<Tabs.Tab
|
||||||
|
value="open"
|
||||||
|
leftSection={
|
||||||
|
<Badge size="sm" variant="light" color="blue">
|
||||||
|
{activeComments.length}
|
||||||
|
</Badge>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("Open")}
|
||||||
|
</Tabs.Tab>
|
||||||
|
<Tabs.Tab
|
||||||
|
value="resolved"
|
||||||
|
leftSection={
|
||||||
|
<Badge size="sm" variant="light" color="green">
|
||||||
|
{resolvedComments.length}
|
||||||
|
</Badge>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("Resolved")}
|
||||||
|
</Tabs.Tab>
|
||||||
|
</Tabs.List>
|
||||||
|
|
||||||
|
<ScrollArea
|
||||||
|
style={{ flex: "1 1 auto", height: "calc(85vh - 60px)" }}
|
||||||
|
scrollbarSize={5}
|
||||||
|
type="scroll"
|
||||||
|
>
|
||||||
|
<div style={{ paddingBottom: "200px" }}>
|
||||||
|
<Tabs.Panel value="open" pt="xs">
|
||||||
|
{activeComments.length === 0 ? (
|
||||||
|
<Text size="sm" c="dimmed" ta="center" py="md">
|
||||||
|
{t("No open comments.")}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
activeComments.map(renderComments)
|
||||||
|
)}
|
||||||
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
<Tabs.Panel value="resolved" pt="xs">
|
||||||
|
{resolvedComments.length === 0 ? (
|
||||||
|
<Text size="sm" c="dimmed" ta="center" py="md">
|
||||||
|
{t("No resolved comments.")}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
resolvedComments.map(renderComments)
|
||||||
|
)}
|
||||||
|
</Tabs.Panel>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChildCommentsProps {
|
||||||
|
comments: IPagination<IComment>;
|
||||||
|
parentId: string;
|
||||||
|
pageId: string;
|
||||||
|
canComment: boolean;
|
||||||
|
userSpaceRole?: string;
|
||||||
|
}
|
||||||
|
const ChildComments = ({
|
||||||
|
comments,
|
||||||
|
parentId,
|
||||||
|
pageId,
|
||||||
|
canComment,
|
||||||
|
userSpaceRole,
|
||||||
|
}: ChildCommentsProps) => {
|
||||||
|
const getChildComments = useCallback(
|
||||||
|
(parentId: string) =>
|
||||||
|
comments.items.filter(
|
||||||
|
(comment: IComment) => comment.parentCommentId === parentId
|
||||||
|
),
|
||||||
|
[comments.items]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{getChildComments(parentId).map((childComment) => (
|
||||||
|
<div key={childComment.id}>
|
||||||
|
<CommentListItem
|
||||||
|
comment={childComment}
|
||||||
|
pageId={pageId}
|
||||||
|
canComment={canComment}
|
||||||
|
userSpaceRole={userSpaceRole}
|
||||||
|
/>
|
||||||
|
<MemoizedChildComments
|
||||||
|
comments={comments}
|
||||||
|
parentId={childComment.id}
|
||||||
|
pageId={pageId}
|
||||||
|
canComment={canComment}
|
||||||
|
userSpaceRole={userSpaceRole}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const MemoizedChildComments = memo(ChildComments);
|
||||||
|
|
||||||
|
const CommentEditorWithActions = ({ commentId, onSave, isLoading }) => {
|
||||||
|
const [content, setContent] = useState("");
|
||||||
|
const { ref, focused } = useFocusWithin();
|
||||||
|
const commentEditorRef = useRef(null);
|
||||||
|
|
||||||
|
const handleSave = useCallback(() => {
|
||||||
|
onSave(commentId, content);
|
||||||
|
setContent("");
|
||||||
|
commentEditorRef.current?.clearContent();
|
||||||
|
}, [commentId, content, onSave]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref}>
|
||||||
|
<CommentEditor
|
||||||
|
ref={commentEditorRef}
|
||||||
|
onUpdate={setContent}
|
||||||
|
onSave={handleSave}
|
||||||
|
editable={true}
|
||||||
|
/>
|
||||||
|
{focused && <CommentActions onSave={handleSave} isLoading={isLoading} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CommentListWithTabs;
|
||||||
@ -1,162 +0,0 @@
|
|||||||
import React, { useState, useRef, useCallback, memo } from "react";
|
|
||||||
import { useParams } from "react-router-dom";
|
|
||||||
import { Divider, Paper } from "@mantine/core";
|
|
||||||
import CommentListItem from "@/features/comment/components/comment-list-item";
|
|
||||||
import {
|
|
||||||
useCommentsQuery,
|
|
||||||
useCreateCommentMutation,
|
|
||||||
} from "@/features/comment/queries/comment-query";
|
|
||||||
import CommentEditor from "@/features/comment/components/comment-editor";
|
|
||||||
import CommentActions from "@/features/comment/components/comment-actions";
|
|
||||||
import { useFocusWithin } from "@mantine/hooks";
|
|
||||||
import { IComment } from "@/features/comment/types/comment.types.ts";
|
|
||||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
|
||||||
import { IPagination } from "@/lib/types.ts";
|
|
||||||
import { extractPageSlugId } from "@/lib";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useQueryEmit } from "@/features/websocket/use-query-emit";
|
|
||||||
|
|
||||||
function CommentList() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { pageSlug } = useParams();
|
|
||||||
const { data: page } = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
|
|
||||||
const {
|
|
||||||
data: comments,
|
|
||||||
isLoading: isCommentsLoading,
|
|
||||||
isError,
|
|
||||||
} = useCommentsQuery({ pageId: page?.id, limit: 100 });
|
|
||||||
const createCommentMutation = useCreateCommentMutation();
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const emit = useQueryEmit();
|
|
||||||
|
|
||||||
const handleAddReply = useCallback(
|
|
||||||
async (commentId: string, content: string) => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
const commentData = {
|
|
||||||
pageId: page?.id,
|
|
||||||
parentCommentId: commentId,
|
|
||||||
content: JSON.stringify(content),
|
|
||||||
};
|
|
||||||
|
|
||||||
await createCommentMutation.mutateAsync(commentData);
|
|
||||||
|
|
||||||
emit({
|
|
||||||
operation: "invalidateComment",
|
|
||||||
pageId: page?.id,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to post comment:", error);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[createCommentMutation, page?.id],
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderComments = useCallback(
|
|
||||||
(comment: IComment) => (
|
|
||||||
<Paper
|
|
||||||
shadow="sm"
|
|
||||||
radius="md"
|
|
||||||
p="sm"
|
|
||||||
mb="sm"
|
|
||||||
withBorder
|
|
||||||
key={comment.id}
|
|
||||||
data-comment-id={comment.id}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<CommentListItem comment={comment} pageId={page?.id} />
|
|
||||||
<MemoizedChildComments comments={comments} parentId={comment.id} pageId={page?.id} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Divider my={4} />
|
|
||||||
|
|
||||||
<CommentEditorWithActions
|
|
||||||
commentId={comment.id}
|
|
||||||
onSave={handleAddReply}
|
|
||||||
isLoading={isLoading}
|
|
||||||
/>
|
|
||||||
</Paper>
|
|
||||||
),
|
|
||||||
[comments, handleAddReply, isLoading],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isCommentsLoading) {
|
|
||||||
return <></>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isError) {
|
|
||||||
return <div>{t("Error loading comments.")}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!comments || comments.items.length === 0) {
|
|
||||||
return <>{t("No comments yet.")}</>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{comments.items
|
|
||||||
.filter((comment) => comment.parentCommentId === null)
|
|
||||||
.map(renderComments)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ChildCommentsProps {
|
|
||||||
comments: IPagination<IComment>;
|
|
||||||
parentId: string;
|
|
||||||
pageId: string;
|
|
||||||
}
|
|
||||||
const ChildComments = ({ comments, parentId, pageId }: ChildCommentsProps) => {
|
|
||||||
const getChildComments = useCallback(
|
|
||||||
(parentId: string) =>
|
|
||||||
comments.items.filter(
|
|
||||||
(comment: IComment) => comment.parentCommentId === parentId,
|
|
||||||
),
|
|
||||||
[comments.items],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{getChildComments(parentId).map((childComment) => (
|
|
||||||
<div key={childComment.id}>
|
|
||||||
<CommentListItem comment={childComment} pageId={pageId} />
|
|
||||||
<MemoizedChildComments
|
|
||||||
comments={comments}
|
|
||||||
parentId={childComment.id}
|
|
||||||
pageId={pageId}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const MemoizedChildComments = memo(ChildComments);
|
|
||||||
|
|
||||||
const CommentEditorWithActions = ({ commentId, onSave, isLoading }) => {
|
|
||||||
const [content, setContent] = useState("");
|
|
||||||
const { ref, focused } = useFocusWithin();
|
|
||||||
const commentEditorRef = useRef(null);
|
|
||||||
|
|
||||||
const handleSave = useCallback(() => {
|
|
||||||
onSave(commentId, content);
|
|
||||||
setContent("");
|
|
||||||
commentEditorRef.current?.clearContent();
|
|
||||||
}, [commentId, content, onSave]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={ref}>
|
|
||||||
<CommentEditor
|
|
||||||
ref={commentEditorRef}
|
|
||||||
onUpdate={setContent}
|
|
||||||
onSave={handleSave}
|
|
||||||
editable={true}
|
|
||||||
/>
|
|
||||||
{focused && <CommentActions onSave={handleSave} isLoading={isLoading} />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CommentList;
|
|
||||||
@ -1,15 +1,28 @@
|
|||||||
import { ActionIcon, Menu } from "@mantine/core";
|
import { ActionIcon, Menu, Tooltip } from "@mantine/core";
|
||||||
import { IconDots, IconEdit, IconTrash } from "@tabler/icons-react";
|
import { IconDots, IconEdit, IconTrash, IconCircleCheck, IconCircleCheckFilled } from "@tabler/icons-react";
|
||||||
import { modals } from "@mantine/modals";
|
import { modals } from "@mantine/modals";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee";
|
||||||
|
|
||||||
type CommentMenuProps = {
|
type CommentMenuProps = {
|
||||||
onEditComment: () => void;
|
onEditComment: () => void;
|
||||||
onDeleteComment: () => void;
|
onDeleteComment: () => void;
|
||||||
|
onResolveComment?: () => void;
|
||||||
|
canEdit?: boolean;
|
||||||
|
isResolved?: boolean;
|
||||||
|
isParentComment?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function CommentMenu({ onEditComment, onDeleteComment }: CommentMenuProps) {
|
function CommentMenu({
|
||||||
|
onEditComment,
|
||||||
|
onDeleteComment,
|
||||||
|
onResolveComment,
|
||||||
|
canEdit = true,
|
||||||
|
isResolved = false,
|
||||||
|
isParentComment = false
|
||||||
|
}: CommentMenuProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const isCloudEE = useIsCloudEE();
|
||||||
|
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
const openDeleteModal = () =>
|
const openDeleteModal = () =>
|
||||||
@ -30,9 +43,34 @@ function CommentMenu({ onEditComment, onDeleteComment }: CommentMenuProps) {
|
|||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
|
|
||||||
<Menu.Dropdown>
|
<Menu.Dropdown>
|
||||||
|
{canEdit && (
|
||||||
<Menu.Item onClick={onEditComment} leftSection={<IconEdit size={14} />}>
|
<Menu.Item onClick={onEditComment} leftSection={<IconEdit size={14} />}>
|
||||||
{t("Edit comment")}
|
{t("Edit comment")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
)}
|
||||||
|
{isParentComment && (
|
||||||
|
isCloudEE ? (
|
||||||
|
<Menu.Item
|
||||||
|
onClick={onResolveComment}
|
||||||
|
leftSection={
|
||||||
|
isResolved ?
|
||||||
|
<IconCircleCheckFilled size={14} /> :
|
||||||
|
<IconCircleCheck size={14} />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isResolved ? t("Re-open comment") : t("Resolve comment")}
|
||||||
|
</Menu.Item>
|
||||||
|
) : (
|
||||||
|
<Tooltip label={t("Available in enterprise edition")} position="left">
|
||||||
|
<Menu.Item
|
||||||
|
disabled
|
||||||
|
leftSection={<IconCircleCheck size={14} />}
|
||||||
|
>
|
||||||
|
{t("Resolve comment")}
|
||||||
|
</Menu.Item>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
)}
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<IconTrash size={14} />}
|
leftSection={<IconTrash size={14} />}
|
||||||
onClick={openDeleteModal}
|
onClick={openDeleteModal}
|
||||||
|
|||||||
@ -1,47 +0,0 @@
|
|||||||
import { ActionIcon } from "@mantine/core";
|
|
||||||
import { IconCircleCheck } from "@tabler/icons-react";
|
|
||||||
import { modals } from "@mantine/modals";
|
|
||||||
import { useResolveCommentMutation } from "@/features/comment/queries/comment-query";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
function ResolveComment({ commentId, pageId, resolvedAt }) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const resolveCommentMutation = useResolveCommentMutation();
|
|
||||||
|
|
||||||
const isResolved = resolvedAt != null;
|
|
||||||
const iconColor = isResolved ? "green" : "gray";
|
|
||||||
|
|
||||||
//@ts-ignore
|
|
||||||
const openConfirmModal = () =>
|
|
||||||
modals.openConfirmModal({
|
|
||||||
title: t("Are you sure you want to resolve this comment thread?"),
|
|
||||||
centered: true,
|
|
||||||
labels: { confirm: t("Confirm"), cancel: t("Cancel") },
|
|
||||||
onConfirm: handleResolveToggle,
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleResolveToggle = async () => {
|
|
||||||
try {
|
|
||||||
await resolveCommentMutation.mutateAsync({
|
|
||||||
commentId,
|
|
||||||
resolved: !isResolved,
|
|
||||||
});
|
|
||||||
//TODO: remove comment mark
|
|
||||||
// Remove comment thread from state on resolve
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to toggle resolved state:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ActionIcon
|
|
||||||
onClick={openConfirmModal}
|
|
||||||
variant="default"
|
|
||||||
style={{ border: "none" }}
|
|
||||||
>
|
|
||||||
<IconCircleCheck size={20} stroke={2} color={iconColor} />
|
|
||||||
</ActionIcon>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ResolveComment;
|
|
||||||
@ -8,13 +8,11 @@ import {
|
|||||||
createComment,
|
createComment,
|
||||||
deleteComment,
|
deleteComment,
|
||||||
getPageComments,
|
getPageComments,
|
||||||
resolveComment,
|
|
||||||
updateComment,
|
updateComment,
|
||||||
} from "@/features/comment/services/comment-service";
|
} from "@/features/comment/services/comment-service";
|
||||||
import {
|
import {
|
||||||
ICommentParams,
|
ICommentParams,
|
||||||
IComment,
|
IComment,
|
||||||
IResolveComment,
|
|
||||||
} from "@/features/comment/types/comment.types";
|
} from "@/features/comment/types/comment.types";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { IPagination } from "@/lib/types.ts";
|
import { IPagination } from "@/lib/types.ts";
|
||||||
@ -108,34 +106,4 @@ export function useDeleteCommentMutation(pageId?: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useResolveCommentMutation() {
|
// EE: useResolveCommentMutation has been moved to @/ee/comment/queries/comment-query
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (data: IResolveComment) => resolveComment(data),
|
|
||||||
onSuccess: (data: IComment, variables) => {
|
|
||||||
const currentComments = queryClient.getQueryData(
|
|
||||||
RQ_KEY(data.pageId),
|
|
||||||
) as IComment[];
|
|
||||||
|
|
||||||
/*
|
|
||||||
if (currentComments) {
|
|
||||||
const updatedComments = currentComments.map((comment) =>
|
|
||||||
comment.id === variables.commentId
|
|
||||||
? { ...comment, ...data }
|
|
||||||
: comment,
|
|
||||||
);
|
|
||||||
queryClient.setQueryData(RQ_KEY(data.pageId), updatedComments);
|
|
||||||
}*/
|
|
||||||
|
|
||||||
notifications.show({ message: t("Comment resolved successfully") });
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
notifications.show({
|
|
||||||
message: t("Failed to resolve comment"),
|
|
||||||
color: "red",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@ -16,6 +16,7 @@ export interface IComment {
|
|||||||
editedAt?: Date;
|
editedAt?: Date;
|
||||||
deletedAt?: Date;
|
deletedAt?: Date;
|
||||||
creator: IUser;
|
creator: IUser;
|
||||||
|
resolvedBy?: IUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ICommentData {
|
export interface ICommentData {
|
||||||
@ -28,6 +29,7 @@ export interface ICommentData {
|
|||||||
|
|
||||||
export interface IResolveComment {
|
export interface IResolveComment {
|
||||||
commentId: string;
|
commentId: string;
|
||||||
|
pageId: string;
|
||||||
resolved: boolean;
|
resolved: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -75,7 +75,7 @@ export default function PageEditor({
|
|||||||
const [isLocalSynced, setLocalSynced] = useState(false);
|
const [isLocalSynced, setLocalSynced] = useState(false);
|
||||||
const [isRemoteSynced, setRemoteSynced] = useState(false);
|
const [isRemoteSynced, setRemoteSynced] = useState(false);
|
||||||
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
|
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
|
||||||
yjsConnectionStatusAtom,
|
yjsConnectionStatusAtom
|
||||||
);
|
);
|
||||||
const menuContainerRef = useRef(null);
|
const menuContainerRef = useRef(null);
|
||||||
const documentName = `page.${pageId}`;
|
const documentName = `page.${pageId}`;
|
||||||
@ -262,7 +262,7 @@ export default function PageEditor({
|
|||||||
debouncedUpdateContent(editorJson);
|
debouncedUpdateContent(editorJson);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
[pageId, editable, remoteProvider],
|
[pageId, editable, remoteProvider]
|
||||||
);
|
);
|
||||||
|
|
||||||
const debouncedUpdateContent = useDebouncedCallback((newContent: any) => {
|
const debouncedUpdateContent = useDebouncedCallback((newContent: any) => {
|
||||||
@ -278,7 +278,12 @@ export default function PageEditor({
|
|||||||
}, 3000);
|
}, 3000);
|
||||||
|
|
||||||
const handleActiveCommentEvent = (event) => {
|
const handleActiveCommentEvent = (event) => {
|
||||||
const { commentId } = event.detail;
|
const { commentId, resolved } = event.detail;
|
||||||
|
|
||||||
|
if (resolved) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setActiveCommentId(commentId);
|
setActiveCommentId(commentId);
|
||||||
setAsideState({ tab: "comments", isAsideOpen: true });
|
setAsideState({ tab: "comments", isAsideOpen: true });
|
||||||
|
|
||||||
@ -295,7 +300,7 @@ export default function PageEditor({
|
|||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener(
|
document.removeEventListener(
|
||||||
"ACTIVE_COMMENT_EVENT",
|
"ACTIVE_COMMENT_EVENT",
|
||||||
handleActiveCommentEvent,
|
handleActiveCommentEvent
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@ -142,6 +142,11 @@
|
|||||||
.comment-mark {
|
.comment-mark {
|
||||||
background: rgba(255, 215, 0, 0.14);
|
background: rgba(255, 215, 0, 0.14);
|
||||||
border-bottom: 2px solid rgb(166, 158, 12);
|
border-bottom: 2px solid rgb(166, 158, 12);
|
||||||
|
|
||||||
|
&.resolved {
|
||||||
|
background: none;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.comment-highlight {
|
.comment-highlight {
|
||||||
@ -187,7 +192,7 @@
|
|||||||
mask-size: 100% 100%;
|
mask-size: 100% 100%;
|
||||||
background-color: currentColor;
|
background-color: currentColor;
|
||||||
|
|
||||||
& -open {
|
&-open {
|
||||||
--svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23000' d='M10 3v2H5v14h14v-5h2v6a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zm7.586 2H13V3h8v8h-2V6.414l-7 7L10.586 12z'/%3E%3C/svg%3E");
|
--svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23000' d='M10 3v2H5v14h14v-5h2v6a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zm7.586 2H13V3h8v8h-2V6.414l-7 7L10.586 12z'/%3E%3C/svg%3E");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -63,6 +63,20 @@ export type RefetchRootTreeNodeEvent = {
|
|||||||
spaceId: string;
|
spaceId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ResolveCommentEvent = {
|
||||||
|
operation: "resolveComment";
|
||||||
|
pageId: string;
|
||||||
|
commentId: string;
|
||||||
|
resolved: boolean;
|
||||||
|
resolvedAt?: Date;
|
||||||
|
resolvedById?: string;
|
||||||
|
resolvedBy?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
avatarUrl?: string | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export type WebSocketEvent =
|
export type WebSocketEvent =
|
||||||
| InvalidateEvent
|
| InvalidateEvent
|
||||||
| InvalidateCommentsEvent
|
| InvalidateCommentsEvent
|
||||||
@ -71,4 +85,5 @@ export type WebSocketEvent =
|
|||||||
| AddTreeNodeEvent
|
| AddTreeNodeEvent
|
||||||
| MoveTreeNodeEvent
|
| MoveTreeNodeEvent
|
||||||
| DeleteTreeNodeEvent
|
| DeleteTreeNodeEvent
|
||||||
| RefetchRootTreeNodeEvent;
|
| RefetchRootTreeNodeEvent
|
||||||
|
| ResolveCommentEvent;
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import {
|
|||||||
} from "../page/queries/page-query";
|
} from "../page/queries/page-query";
|
||||||
import { RQ_KEY } from "../comment/queries/comment-query";
|
import { RQ_KEY } from "../comment/queries/comment-query";
|
||||||
import { queryClient } from "@/main.tsx";
|
import { queryClient } from "@/main.tsx";
|
||||||
|
import { IComment } from "@/features/comment/types/comment.types";
|
||||||
|
|
||||||
export const useQuerySubscription = () => {
|
export const useQuerySubscription = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@ -96,6 +97,30 @@ export const useQuerySubscription = () => {
|
|||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "resolveComment": {
|
||||||
|
const currentComments = queryClient.getQueryData(
|
||||||
|
RQ_KEY(data.pageId),
|
||||||
|
) as IPagination<IComment>;
|
||||||
|
|
||||||
|
if (currentComments && currentComments.items) {
|
||||||
|
const updatedComments = currentComments.items.map((comment) =>
|
||||||
|
comment.id === data.commentId
|
||||||
|
? {
|
||||||
|
...comment,
|
||||||
|
resolvedAt: data.resolvedAt,
|
||||||
|
resolvedById: data.resolvedById,
|
||||||
|
resolvedBy: data.resolvedBy
|
||||||
|
}
|
||||||
|
: comment,
|
||||||
|
);
|
||||||
|
|
||||||
|
queryClient.setQueryData(RQ_KEY(data.pageId), {
|
||||||
|
...currentComments,
|
||||||
|
items: updatedComments,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [queryClient, socket]);
|
}, [queryClient, socket]);
|
||||||
|
|||||||
7
apps/client/src/hooks/use-is-cloud-ee.tsx
Normal file
7
apps/client/src/hooks/use-is-cloud-ee.tsx
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { isCloud } from "@/lib/config";
|
||||||
|
import { useLicense } from "@/ee/hooks/use-license";
|
||||||
|
|
||||||
|
export const useIsCloudEE = () => {
|
||||||
|
const { hasLicenseKey } = useLicense();
|
||||||
|
return isCloud() || !!hasLicenseKey;
|
||||||
|
};
|
||||||
@ -53,9 +53,11 @@ export class CommentController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return this.commentService.create(
|
return this.commentService.create(
|
||||||
user.id,
|
{
|
||||||
page.id,
|
userId: user.id,
|
||||||
workspace.id,
|
page,
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
},
|
||||||
createCommentDto,
|
createCommentDto,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -67,7 +69,6 @@ export class CommentController {
|
|||||||
@Body()
|
@Body()
|
||||||
pagination: PaginationOptions,
|
pagination: PaginationOptions,
|
||||||
@AuthUser() user: User,
|
@AuthUser() user: User,
|
||||||
// @AuthWorkspace() workspace: Workspace,
|
|
||||||
) {
|
) {
|
||||||
const page = await this.pageRepo.findById(input.pageId);
|
const page = await this.pageRepo.findById(input.pageId);
|
||||||
if (!page) {
|
if (!page) {
|
||||||
@ -89,12 +90,7 @@ export class CommentController {
|
|||||||
throw new NotFoundException('Comment not found');
|
throw new NotFoundException('Comment not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const page = await this.pageRepo.findById(comment.pageId);
|
const ability = await this.spaceAbility.createForUser(user, comment.spaceId);
|
||||||
if (!page) {
|
|
||||||
throw new NotFoundException('Page not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
|
||||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
||||||
throw new ForbiddenException();
|
throw new ForbiddenException();
|
||||||
}
|
}
|
||||||
@ -103,19 +99,76 @@ export class CommentController {
|
|||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('update')
|
@Post('update')
|
||||||
update(@Body() updateCommentDto: UpdateCommentDto, @AuthUser() user: User) {
|
async update(@Body() dto: UpdateCommentDto, @AuthUser() user: User) {
|
||||||
//TODO: only comment creators can update their comments
|
const comment = await this.commentRepo.findById(dto.commentId);
|
||||||
return this.commentService.update(
|
if (!comment) {
|
||||||
updateCommentDto.commentId,
|
throw new NotFoundException('Comment not found');
|
||||||
updateCommentDto,
|
}
|
||||||
|
|
||||||
|
const ability = await this.spaceAbility.createForUser(
|
||||||
user,
|
user,
|
||||||
|
comment.spaceId,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// must be a space member with edit permission
|
||||||
|
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'You must have space edit permission to edit comments',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.commentService.update(comment, dto, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('delete')
|
@Post('delete')
|
||||||
remove(@Body() input: CommentIdDto, @AuthUser() user: User) {
|
async delete(@Body() input: CommentIdDto, @AuthUser() user: User) {
|
||||||
// TODO: only comment creators and admins can delete their comments
|
const comment = await this.commentRepo.findById(input.commentId);
|
||||||
return this.commentService.remove(input.commentId, user);
|
if (!comment) {
|
||||||
|
throw new NotFoundException('Comment not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const ability = await this.spaceAbility.createForUser(
|
||||||
|
user,
|
||||||
|
comment.spaceId,
|
||||||
|
);
|
||||||
|
|
||||||
|
// must be a space member with edit permission
|
||||||
|
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is the comment owner
|
||||||
|
const isOwner = comment.creatorId === user.id;
|
||||||
|
|
||||||
|
if (isOwner) {
|
||||||
|
/*
|
||||||
|
// Check if comment has children from other users
|
||||||
|
const hasChildrenFromOthers =
|
||||||
|
await this.commentRepo.hasChildrenFromOtherUsers(comment.id, user.id);
|
||||||
|
|
||||||
|
// Owner can delete if no children from other users
|
||||||
|
if (!hasChildrenFromOthers) {
|
||||||
|
await this.commentRepo.deleteComment(comment.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If has children from others, only space admin can delete
|
||||||
|
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'Only space admins can delete comments with replies from other users',
|
||||||
|
);
|
||||||
|
}*/
|
||||||
|
await this.commentRepo.deleteComment(comment.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Space admin can delete any comment
|
||||||
|
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'You can only delete your own comments or must be a space admin',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await this.commentRepo.deleteComment(comment.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,21 +7,24 @@ import {
|
|||||||
import { CreateCommentDto } from './dto/create-comment.dto';
|
import { CreateCommentDto } from './dto/create-comment.dto';
|
||||||
import { UpdateCommentDto } from './dto/update-comment.dto';
|
import { UpdateCommentDto } from './dto/update-comment.dto';
|
||||||
import { CommentRepo } from '@docmost/db/repos/comment/comment.repo';
|
import { CommentRepo } from '@docmost/db/repos/comment/comment.repo';
|
||||||
import { Comment, User } from '@docmost/db/types/entity.types';
|
import { Comment, Page, User } from '@docmost/db/types/entity.types';
|
||||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||||
import { PaginationResult } from '@docmost/db/pagination/pagination';
|
import { PaginationResult } from '@docmost/db/pagination/pagination';
|
||||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||||
|
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CommentService {
|
export class CommentService {
|
||||||
constructor(
|
constructor(
|
||||||
private commentRepo: CommentRepo,
|
private commentRepo: CommentRepo,
|
||||||
private pageRepo: PageRepo,
|
private pageRepo: PageRepo,
|
||||||
|
private spaceMemberRepo: SpaceMemberRepo,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async findById(commentId: string) {
|
async findById(commentId: string) {
|
||||||
const comment = await this.commentRepo.findById(commentId, {
|
const comment = await this.commentRepo.findById(commentId, {
|
||||||
includeCreator: true,
|
includeCreator: true,
|
||||||
|
includeResolvedBy: true,
|
||||||
});
|
});
|
||||||
if (!comment) {
|
if (!comment) {
|
||||||
throw new NotFoundException('Comment not found');
|
throw new NotFoundException('Comment not found');
|
||||||
@ -30,11 +33,10 @@ export class CommentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async create(
|
async create(
|
||||||
userId: string,
|
opts: { userId: string; page: Page; workspaceId: string },
|
||||||
pageId: string,
|
|
||||||
workspaceId: string,
|
|
||||||
createCommentDto: CreateCommentDto,
|
createCommentDto: CreateCommentDto,
|
||||||
) {
|
) {
|
||||||
|
const { userId, page, workspaceId } = opts;
|
||||||
const commentContent = JSON.parse(createCommentDto.content);
|
const commentContent = JSON.parse(createCommentDto.content);
|
||||||
|
|
||||||
if (createCommentDto.parentCommentId) {
|
if (createCommentDto.parentCommentId) {
|
||||||
@ -42,7 +44,7 @@ export class CommentService {
|
|||||||
createCommentDto.parentCommentId,
|
createCommentDto.parentCommentId,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!parentComment || parentComment.pageId !== pageId) {
|
if (!parentComment || parentComment.pageId !== page.id) {
|
||||||
throw new BadRequestException('Parent comment not found');
|
throw new BadRequestException('Parent comment not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -51,17 +53,16 @@ export class CommentService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const createdComment = await this.commentRepo.insertComment({
|
return await this.commentRepo.insertComment({
|
||||||
pageId: pageId,
|
pageId: page.id,
|
||||||
content: commentContent,
|
content: commentContent,
|
||||||
selection: createCommentDto?.selection?.substring(0, 250),
|
selection: createCommentDto?.selection?.substring(0, 250),
|
||||||
type: 'inline',
|
type: 'inline',
|
||||||
parentCommentId: createCommentDto?.parentCommentId,
|
parentCommentId: createCommentDto?.parentCommentId,
|
||||||
creatorId: userId,
|
creatorId: userId,
|
||||||
workspaceId: workspaceId,
|
workspaceId: workspaceId,
|
||||||
|
spaceId: page.spaceId,
|
||||||
});
|
});
|
||||||
|
|
||||||
return createdComment;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async findByPageId(
|
async findByPageId(
|
||||||
@ -74,26 +75,16 @@ export class CommentService {
|
|||||||
throw new BadRequestException('Page not found');
|
throw new BadRequestException('Page not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const pageComments = await this.commentRepo.findPageComments(
|
return await this.commentRepo.findPageComments(pageId, pagination);
|
||||||
pageId,
|
|
||||||
pagination,
|
|
||||||
);
|
|
||||||
|
|
||||||
return pageComments;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(
|
async update(
|
||||||
commentId: string,
|
comment: Comment,
|
||||||
updateCommentDto: UpdateCommentDto,
|
updateCommentDto: UpdateCommentDto,
|
||||||
authUser: User,
|
authUser: User,
|
||||||
): Promise<Comment> {
|
): Promise<Comment> {
|
||||||
const commentContent = JSON.parse(updateCommentDto.content);
|
const commentContent = JSON.parse(updateCommentDto.content);
|
||||||
|
|
||||||
const comment = await this.commentRepo.findById(commentId);
|
|
||||||
if (!comment) {
|
|
||||||
throw new NotFoundException('Comment not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (comment.creatorId !== authUser.id) {
|
if (comment.creatorId !== authUser.id) {
|
||||||
throw new ForbiddenException('You can only edit your own comments');
|
throw new ForbiddenException('You can only edit your own comments');
|
||||||
}
|
}
|
||||||
@ -104,26 +95,14 @@ export class CommentService {
|
|||||||
{
|
{
|
||||||
content: commentContent,
|
content: commentContent,
|
||||||
editedAt: editedAt,
|
editedAt: editedAt,
|
||||||
|
updatedAt: editedAt,
|
||||||
},
|
},
|
||||||
commentId,
|
comment.id,
|
||||||
);
|
);
|
||||||
comment.content = commentContent;
|
comment.content = commentContent;
|
||||||
comment.editedAt = editedAt;
|
comment.editedAt = editedAt;
|
||||||
|
comment.updatedAt = editedAt;
|
||||||
|
|
||||||
return comment;
|
return comment;
|
||||||
}
|
}
|
||||||
|
|
||||||
async remove(commentId: string, authUser: User): Promise<void> {
|
|
||||||
const comment = await this.commentRepo.findById(commentId);
|
|
||||||
|
|
||||||
if (!comment) {
|
|
||||||
throw new NotFoundException('Comment not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (comment.creatorId !== authUser.id) {
|
|
||||||
throw new ForbiddenException('You can only delete your own comments');
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.commentRepo.deleteComment(commentId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,61 @@
|
|||||||
|
import { type Kysely, sql } from 'kysely';
|
||||||
|
|
||||||
|
export async function up(db: Kysely<any>): Promise<void> {
|
||||||
|
// Add last_edited_by_id column to comments table
|
||||||
|
await db.schema
|
||||||
|
.alterTable('comments')
|
||||||
|
.addColumn('last_edited_by_id', 'uuid', (col) =>
|
||||||
|
col.references('users.id').onDelete('set null'),
|
||||||
|
)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
// Add resolved_by_id column to comments table
|
||||||
|
await db.schema
|
||||||
|
.alterTable('comments')
|
||||||
|
.addColumn('resolved_by_id', 'uuid', (col) =>
|
||||||
|
col.references('users.id').onDelete('set null'),
|
||||||
|
)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
// Add updated_at timestamp column to comments table
|
||||||
|
await db.schema
|
||||||
|
.alterTable('comments')
|
||||||
|
.addColumn('updated_at', 'timestamptz', (col) =>
|
||||||
|
col.notNull().defaultTo(sql`now()`),
|
||||||
|
)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
// Add space_id column to comments table
|
||||||
|
await db.schema
|
||||||
|
.alterTable('comments')
|
||||||
|
.addColumn('space_id', 'uuid', (col) =>
|
||||||
|
col.references('spaces.id').onDelete('cascade'),
|
||||||
|
)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
// Backfill space_id from the related pages
|
||||||
|
await db
|
||||||
|
.updateTable('comments as c')
|
||||||
|
.set((eb) => ({
|
||||||
|
space_id: eb.ref('p.space_id'),
|
||||||
|
}))
|
||||||
|
.from('pages as p')
|
||||||
|
.whereRef('c.page_id', '=', 'p.id')
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
// Make space_id NOT NULL after populating data
|
||||||
|
await db.schema
|
||||||
|
.alterTable('comments')
|
||||||
|
.alterColumn('space_id', (col) => col.setNotNull())
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(db: Kysely<any>): Promise<void> {
|
||||||
|
await db.schema
|
||||||
|
.alterTable('comments')
|
||||||
|
.dropColumn('last_edited_by_id')
|
||||||
|
.execute();
|
||||||
|
await db.schema.alterTable('comments').dropColumn('resolved_by_id').execute();
|
||||||
|
await db.schema.alterTable('comments').dropColumn('updated_at').execute();
|
||||||
|
await db.schema.alterTable('comments').dropColumn('space_id').execute();
|
||||||
|
}
|
||||||
@ -20,12 +20,13 @@ export class CommentRepo {
|
|||||||
// todo, add workspaceId
|
// todo, add workspaceId
|
||||||
async findById(
|
async findById(
|
||||||
commentId: string,
|
commentId: string,
|
||||||
opts?: { includeCreator: boolean },
|
opts?: { includeCreator: boolean; includeResolvedBy: boolean },
|
||||||
): Promise<Comment> {
|
): Promise<Comment> {
|
||||||
return await this.db
|
return await this.db
|
||||||
.selectFrom('comments')
|
.selectFrom('comments')
|
||||||
.selectAll('comments')
|
.selectAll('comments')
|
||||||
.$if(opts?.includeCreator, (qb) => qb.select(this.withCreator))
|
.$if(opts?.includeCreator, (qb) => qb.select(this.withCreator))
|
||||||
|
.$if(opts?.includeResolvedBy, (qb) => qb.select(this.withResolvedBy))
|
||||||
.where('id', '=', commentId)
|
.where('id', '=', commentId)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
}
|
}
|
||||||
@ -35,6 +36,7 @@ export class CommentRepo {
|
|||||||
.selectFrom('comments')
|
.selectFrom('comments')
|
||||||
.selectAll('comments')
|
.selectAll('comments')
|
||||||
.select((eb) => this.withCreator(eb))
|
.select((eb) => this.withCreator(eb))
|
||||||
|
.select((eb) => this.withResolvedBy(eb))
|
||||||
.where('pageId', '=', pageId)
|
.where('pageId', '=', pageId)
|
||||||
.orderBy('createdAt', 'asc');
|
.orderBy('createdAt', 'asc');
|
||||||
|
|
||||||
@ -80,7 +82,37 @@ export class CommentRepo {
|
|||||||
).as('creator');
|
).as('creator');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
withResolvedBy(eb: ExpressionBuilder<DB, 'comments'>) {
|
||||||
|
return jsonObjectFrom(
|
||||||
|
eb
|
||||||
|
.selectFrom('users')
|
||||||
|
.select(['users.id', 'users.name', 'users.avatarUrl'])
|
||||||
|
.whereRef('users.id', '=', 'comments.resolvedById'),
|
||||||
|
).as('resolvedBy');
|
||||||
|
}
|
||||||
|
|
||||||
async deleteComment(commentId: string): Promise<void> {
|
async deleteComment(commentId: string): Promise<void> {
|
||||||
await this.db.deleteFrom('comments').where('id', '=', commentId).execute();
|
await this.db.deleteFrom('comments').where('id', '=', commentId).execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async hasChildren(commentId: string): Promise<boolean> {
|
||||||
|
const result = await this.db
|
||||||
|
.selectFrom('comments')
|
||||||
|
.select((eb) => eb.fn.count('id').as('count'))
|
||||||
|
.where('parentCommentId', '=', commentId)
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
return Number(result?.count) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async hasChildrenFromOtherUsers(commentId: string, userId: string): Promise<boolean> {
|
||||||
|
const result = await this.db
|
||||||
|
.selectFrom('comments')
|
||||||
|
.select((eb) => eb.fn.count('id').as('count'))
|
||||||
|
.where('parentCommentId', '=', commentId)
|
||||||
|
.where('creatorId', '!=', userId)
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
return Number(result?.count) > 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
4
apps/server/src/database/types/db.d.ts
vendored
4
apps/server/src/database/types/db.d.ts
vendored
@ -119,11 +119,15 @@ export interface Comments {
|
|||||||
deletedAt: Timestamp | null;
|
deletedAt: Timestamp | null;
|
||||||
editedAt: Timestamp | null;
|
editedAt: Timestamp | null;
|
||||||
id: Generated<string>;
|
id: Generated<string>;
|
||||||
|
lastEditedById: string | null;
|
||||||
pageId: string;
|
pageId: string;
|
||||||
parentCommentId: string | null;
|
parentCommentId: string | null;
|
||||||
resolvedAt: Timestamp | null;
|
resolvedAt: Timestamp | null;
|
||||||
|
resolvedById: string | null;
|
||||||
selection: string | null;
|
selection: string | null;
|
||||||
|
spaceId: string;
|
||||||
type: string | null;
|
type: string | null;
|
||||||
|
updatedAt: Generated<Timestamp>;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Submodule apps/server/src/ee updated: 6475894542...35e3440f42
@ -1,5 +1,6 @@
|
|||||||
import { Mark, mergeAttributes } from "@tiptap/core";
|
import { Mark, mergeAttributes } from "@tiptap/core";
|
||||||
import { commentDecoration } from "./comment-decoration";
|
import { commentDecoration } from "./comment-decoration";
|
||||||
|
import { Plugin } from "@tiptap/pm/state";
|
||||||
|
|
||||||
export interface ICommentOptions {
|
export interface ICommentOptions {
|
||||||
HTMLAttributes: Record<string, any>;
|
HTMLAttributes: Record<string, any>;
|
||||||
@ -19,6 +20,7 @@ declare module "@tiptap/core" {
|
|||||||
unsetCommentDecoration: () => ReturnType;
|
unsetCommentDecoration: () => ReturnType;
|
||||||
setComment: (commentId: string) => ReturnType;
|
setComment: (commentId: string) => ReturnType;
|
||||||
unsetComment: (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 [
|
return [
|
||||||
{
|
{
|
||||||
tag: "span[data-comment-id]",
|
tag: "span[data-comment-id]",
|
||||||
getAttrs: (el) =>
|
getAttrs: (el) => {
|
||||||
!!(el as HTMLSpanElement).getAttribute("data-comment-id")?.trim() &&
|
const element = el as HTMLSpanElement;
|
||||||
null,
|
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) =>
|
(commentId) =>
|
||||||
({ commands }) => {
|
({ commands }) => {
|
||||||
if (!commentId) return false;
|
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:
|
unsetComment:
|
||||||
(commentId) =>
|
(commentId) =>
|
||||||
@ -101,7 +124,7 @@ export const Comment = Mark.create<ICommentOptions, ICommentStorage>({
|
|||||||
const commentMark = node.marks.find(
|
const commentMark = node.marks.find(
|
||||||
(mark) =>
|
(mark) =>
|
||||||
mark.type.name === this.name &&
|
mark.type.name === this.name &&
|
||||||
mark.attrs.commentId === commentId,
|
mark.attrs.commentId === commentId
|
||||||
);
|
);
|
||||||
|
|
||||||
if (commentMark) {
|
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);
|
return dispatch?.(tr);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@ -116,13 +170,15 @@ export const Comment = Mark.create<ICommentOptions, ICommentStorage>({
|
|||||||
|
|
||||||
renderHTML({ HTMLAttributes }) {
|
renderHTML({ HTMLAttributes }) {
|
||||||
const commentId = HTMLAttributes?.["data-comment-id"] || null;
|
const commentId = HTMLAttributes?.["data-comment-id"] || null;
|
||||||
|
const resolved = HTMLAttributes?.["data-resolved"] || false;
|
||||||
|
|
||||||
if (typeof window === "undefined" || typeof document === "undefined") {
|
if (typeof window === "undefined" || typeof document === "undefined") {
|
||||||
return [
|
return [
|
||||||
"span",
|
"span",
|
||||||
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
|
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
|
||||||
class: 'comment-mark',
|
class: resolved ? "comment-mark resolved" : "comment-mark",
|
||||||
"data-comment-id": commentId,
|
"data-comment-id": commentId,
|
||||||
|
...(resolved && { "data-resolved": "true" }),
|
||||||
}),
|
}),
|
||||||
0,
|
0,
|
||||||
];
|
];
|
||||||
@ -131,9 +187,14 @@ export const Comment = Mark.create<ICommentOptions, ICommentStorage>({
|
|||||||
const elem = document.createElement("span");
|
const elem = document.createElement("span");
|
||||||
|
|
||||||
Object.entries(
|
Object.entries(
|
||||||
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
|
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)
|
||||||
).forEach(([attr, val]) => elem.setAttribute(attr, val));
|
).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) => {
|
elem.addEventListener("click", (e) => {
|
||||||
const selection = document.getSelection();
|
const selection = document.getSelection();
|
||||||
if (selection.type === "Range") return;
|
if (selection.type === "Range") return;
|
||||||
@ -141,7 +202,7 @@ export const Comment = Mark.create<ICommentOptions, ICommentStorage>({
|
|||||||
this.storage.activeCommentId = commentId;
|
this.storage.activeCommentId = commentId;
|
||||||
const commentEventClick = new CustomEvent("ACTIVE_COMMENT_EVENT", {
|
const commentEventClick = new CustomEvent("ACTIVE_COMMENT_EVENT", {
|
||||||
bubbles: true,
|
bubbles: true,
|
||||||
detail: { commentId },
|
detail: { commentId, resolved },
|
||||||
});
|
});
|
||||||
|
|
||||||
elem.dispatchEvent(commentEventClick);
|
elem.dispatchEvent(commentEventClick);
|
||||||
@ -150,9 +211,7 @@ export const Comment = Mark.create<ICommentOptions, ICommentStorage>({
|
|||||||
return elem;
|
return elem;
|
||||||
},
|
},
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
addProseMirrorPlugins(): Plugin[] {
|
addProseMirrorPlugins(): Plugin[] {
|
||||||
// @ts-ignore
|
|
||||||
return [commentDecoration()];
|
return [commentDecoration()];
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user