sticky comment state tabs (EE)

This commit is contained in:
Philipinho
2025-07-03 22:03:57 -07:00
parent 67f8bcfeca
commit eb9cbabeba
3 changed files with 99 additions and 80 deletions

View File

@ -38,13 +38,17 @@ export default function Aside() {
{t(title)} {t(title)}
</Text> </Text>
<ScrollArea {tab === "comments" ? (
style={{ height: "85vh" }} <CommentListWithTabs />
scrollbarSize={5} ) : (
type="scroll" <ScrollArea
> style={{ height: "85vh" }}
<div style={{ paddingBottom: "200px" }}>{component}</div> scrollbarSize={5}
</ScrollArea> type="scroll"
>
<div style={{ paddingBottom: "200px" }}>{component}</div>
</ScrollArea>
)}
</> </>
)} )}
</Box> </Box>

View File

@ -1,6 +1,6 @@
import React, { useState, useRef, useCallback, memo, useMemo } from "react"; import React, { useState, useRef, useCallback, memo, useMemo } from "react";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { Divider, Paper, Tabs, Badge, Text } from "@mantine/core"; import { Divider, Paper, Tabs, Badge, Text, ScrollArea } from "@mantine/core";
import CommentListItem from "@/features/comment/components/comment-list-item"; import CommentListItem from "@/features/comment/components/comment-list-item";
import { import {
useCommentsQuery, useCommentsQuery,
@ -43,7 +43,7 @@ function CommentListWithTabs() {
const canComment: boolean = spaceAbility.can( const canComment: boolean = spaceAbility.can(
SpaceCaslAction.Manage, SpaceCaslAction.Manage,
SpaceCaslSubject.Page, SpaceCaslSubject.Page
); );
// Separate active and resolved comments // Separate active and resolved comments
@ -53,14 +53,14 @@ function CommentListWithTabs() {
} }
const parentComments = comments.items.filter( const parentComments = comments.items.filter(
(comment: IComment) => comment.parentCommentId === null, (comment: IComment) => comment.parentCommentId === null
); );
const active = parentComments.filter( const active = parentComments.filter(
(comment: IComment) => !comment.resolvedAt, (comment: IComment) => !comment.resolvedAt
); );
const resolved = parentComments.filter( const resolved = parentComments.filter(
(comment: IComment) => comment.resolvedAt, (comment: IComment) => comment.resolvedAt
); );
return { activeComments: active, resolvedComments: resolved }; return { activeComments: active, resolvedComments: resolved };
@ -88,7 +88,7 @@ function CommentListWithTabs() {
setIsLoading(false); setIsLoading(false);
} }
}, },
[createCommentMutation, page?.id], [createCommentMutation, page?.id]
); );
const renderComments = useCallback( const renderComments = useCallback(
@ -128,7 +128,7 @@ function CommentListWithTabs() {
)} )}
</Paper> </Paper>
), ),
[comments, handleAddReply, isLoading], [comments, handleAddReply, isLoading]
); );
if (isCommentsLoading) { if (isCommentsLoading) {
@ -141,66 +141,78 @@ function CommentListWithTabs() {
const totalComments = activeComments.length + resolvedComments.length; const totalComments = activeComments.length + resolvedComments.length;
if (totalComments === 0) {
return <>{t("No comments yet.")}</>;
}
// If not cloud/enterprise, show simple list without tabs // If not cloud/enterprise, show simple list without tabs
if (!isCloudEE) { if (!isCloudEE) {
if (totalComments === 0) {
return <>{t("No comments yet.")}</>;
}
return ( return (
<div> <ScrollArea style={{ height: "85vh" }} scrollbarSize={5} type="scroll">
{comments?.items <div style={{ paddingBottom: "200px" }}>
.filter((comment: IComment) => comment.parentCommentId === null) {comments?.items
.map(renderComments)} .filter((comment: IComment) => comment.parentCommentId === null)
</div> .map(renderComments)}
</div>
</ScrollArea>
); );
} }
return ( return (
<Tabs defaultValue="open" variant="default"> <div style={{ height: "85vh", display: "flex", flexDirection: "column", marginTop: '-15px' }}>
<Tabs.List justify="center"> <Tabs defaultValue="open" variant="default" style={{ flex: "0 0 auto" }}>
<Tabs.Tab <Tabs.List justify="center">
value="open" <Tabs.Tab
leftSection={ value="open"
<Badge size="xs" variant="light" color="blue"> leftSection={
{activeComments.length} <Badge size="sm" variant="light" color="blue">
</Badge> {activeComments.length}
} </Badge>
> }
{t("Open")} >
</Tabs.Tab> {t("Open")}
<Tabs.Tab </Tabs.Tab>
value="resolved" <Tabs.Tab
leftSection={ value="resolved"
<Badge size="xs" variant="light" color="green"> leftSection={
{resolvedComments.length} <Badge size="sm" variant="light" color="green">
</Badge> {resolvedComments.length}
} </Badge>
> }
{t("Resolved")} >
</Tabs.Tab> {t("Resolved")}
</Tabs.List> </Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="open" pt="xs"> <ScrollArea
{activeComments.length === 0 ? ( style={{ flex: "1 1 auto", height: "calc(85vh - 60px)" }}
<Text size="sm" c="dimmed" ta="center" py="md"> scrollbarSize={5}
{t("No open comments.")} type="scroll"
</Text> >
) : ( <div style={{ paddingBottom: "200px" }}>
activeComments.map(renderComments) <Tabs.Panel value="open" pt="xs">
)} {activeComments.length === 0 ? (
</Tabs.Panel> <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"> <Tabs.Panel value="resolved" pt="xs">
{resolvedComments.length === 0 ? ( {resolvedComments.length === 0 ? (
<Text size="sm" c="dimmed" ta="center" py="md"> <Text size="sm" c="dimmed" ta="center" py="md">
{t("No resolved comments.")} {t("No resolved comments.")}
</Text> </Text>
) : ( ) : (
resolvedComments.map(renderComments) resolvedComments.map(renderComments)
)} )}
</Tabs.Panel> </Tabs.Panel>
</Tabs> </div>
</ScrollArea>
</Tabs>
</div>
); );
} }
@ -219,9 +231,9 @@ const ChildComments = ({
const getChildComments = useCallback( const getChildComments = useCallback(
(parentId: string) => (parentId: string) =>
comments.items.filter( comments.items.filter(
(comment: IComment) => comment.parentCommentId === parentId, (comment: IComment) => comment.parentCommentId === parentId
), ),
[comments.items], [comments.items]
); );
return ( return (

View File

@ -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>;
@ -123,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) {
@ -145,16 +146,20 @@ 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) {
// Remove the existing mark and add a new one with updated resolved state // Remove the existing mark and add a new one with updated resolved state
tr = tr.removeMark(from, to, commentMark); tr = tr.removeMark(from, to, commentMark);
tr = tr.addMark(from, to, this.type.create({ tr = tr.addMark(
commentId: commentMark.attrs.commentId, from,
resolved: resolved, to,
})); this.type.create({
commentId: commentMark.attrs.commentId,
resolved: resolved,
})
);
} }
}); });
@ -173,7 +178,7 @@ export const Comment = Mark.create<ICommentOptions, ICommentStorage>({
return [ return [
"span", "span",
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
class: resolved ? 'comment-mark resolved' : 'comment-mark', class: resolved ? "comment-mark resolved" : "comment-mark",
"data-comment-id": commentId, "data-comment-id": commentId,
...(resolved && { "data-resolved": "true" }), ...(resolved && { "data-resolved": "true" }),
}), }),
@ -184,12 +189,12 @@ 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 // Add resolved class if the comment is resolved
if (resolved) { if (resolved) {
elem.classList.add('resolved'); elem.classList.add("resolved");
} }
elem.addEventListener("click", (e) => { elem.addEventListener("click", (e) => {
@ -208,9 +213,7 @@ export const Comment = Mark.create<ICommentOptions, ICommentStorage>({
return elem; return elem;
}, },
// @ts-ignore
addProseMirrorPlugins(): Plugin[] { addProseMirrorPlugins(): Plugin[] {
// @ts-ignore
return [commentDecoration()]; return [commentDecoration()];
}, },
}); });