[WEB-3698] fix: comments refactor (#6759)

* fix: comments refactor

* fix: add edited at

* chore: add edited_at validation at issue comment update

* fix: comment mentions

* fix: edited at

* fix: css

* fix: added bulk asset upload api

* fix: projectId prop fixed

* fix: css

* fix: refactor

* fix: translation

---------

Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
This commit is contained in:
Akshita Goyal 2025-03-27 17:28:52 +05:30 committed by GitHub
parent a5ffbffed9
commit 869c755065
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 577 additions and 360 deletions

View file

@ -105,7 +105,13 @@ class IssueCommentViewSet(BaseViewSet):
issue_comment, data=request.data, partial=True
)
if serializer.is_valid():
serializer.save()
if (
"comment_html" in request.data
and request.data["comment_html"] != issue_comment.comment_html
):
serializer.save(edited_at=timezone.now())
else:
serializer.save()
issue_activity.delay(
type="comment.activity.updated",
requested_data=requested_data,

View file

@ -331,6 +331,8 @@
"re_generate_key": "Re-generate key",
"export": "Export",
"member": "{count, plural, one{# member} other{# members}}",
"edited": "edited",
"bot": "Bot",
"project_view": {
"sort_by": {

View file

@ -503,6 +503,8 @@
"re_generate_key": "Regenerar clave",
"export": "Exportar",
"member": "{count, plural, one{# miembro} other{# miembros}}",
"edited": "Modificado",
"bot": "Bot",
"project_view": {
"sort_by": {

View file

@ -501,6 +501,8 @@
"re_generate_key": "Régénérer la clé",
"export": "Exporter",
"member": "{count, plural, one{# membre} other{# membres}}",
"edited": "Modifié",
"bot": "Bot",
"project_view": {
"sort_by": {

View file

@ -500,6 +500,8 @@
"re_generate_key": "Rigenera chiave",
"export": "Esporta",
"member": "{count, plural, one {# membro} other {# membri}}",
"edited": "Modificato",
"bot": "Bot",
"project_view": {
"sort_by": {

View file

@ -501,6 +501,8 @@
"re_generate_key": "キーを再生成",
"export": "エクスポート",
"member": "{count, plural, other{# メンバー}}",
"edited": "編集済み",
"bot": "ボット",
"project_view": {
"sort_by": {

View file

@ -501,6 +501,8 @@
"re_generate_key": "키 다시 생성",
"export": "내보내기",
"member": "{count, plural, one{# 멤버} other{# 멤버}}",
"edited": "수정됨",
"bot": "봇",
"project_view": {
"sort_by": {

View file

@ -499,6 +499,9 @@
"re_generate_key": "Wygeneruj klucz ponownie",
"export": "Eksportuj",
"member": "{count, plural, one{# członek} few{# członkowie} other{# członków}}",
"edited": "Edytowano",
"bot": "Bot",
"project_view": {
"sort_by": {
"created_at": "Utworzono dnia",

View file

@ -499,6 +499,8 @@
"re_generate_key": "Перегенерировать ключ",
"export": "Экспорт",
"member": "{count, plural, one{# участник} few{# участника} other{# участников}}",
"edited": "Редактировано",
"bot": "Бот",
"project_view": {
"sort_by": {

View file

@ -499,6 +499,8 @@
"re_generate_key": "Znova generovať kľúč",
"export": "Exportovať",
"member": "{count, plural, one{# člen} few{# členovia} other{# členov}}",
"edited": "Upravené",
"bot": "Bot",
"project_view": {
"sort_by": {

View file

@ -499,6 +499,9 @@
"re_generate_key": "Повторно згенерувати ключ",
"export": "Експортувати",
"member": "{count, plural, one{# учасник} few{# учасники} other{# учасників}}",
"edited": "Редагувано",
"bot": "Бот",
"project_view": {
"sort_by": {
"created_at": "Створено",

View file

@ -501,6 +501,8 @@
"re_generate_key": "重新生成密钥",
"export": "导出",
"member": "{count, plural, other{# 成员}}",
"edited": "已编辑",
"bot": "机器人",
"project_view": {
"sort_by": {

View file

@ -501,6 +501,8 @@
"re_generate_key": "重新產生金鑰",
"export": "匯出",
"member": "{count, plural, one{# 位成員} other{# 位成員}}",
"edited": "已編輯",
"bot": "機器人",
"project_view": {
"sort_by": {

View file

@ -5,7 +5,15 @@ import {
TIssueActivityUserDetail,
} from "./base";
import { EIssueCommentAccessSpecifier } from "../../enums";
import { TFileSignedURLResponse } from "../../file";
import { IUserLite } from "../../users";
export type TCommentReaction = {
id: string;
reaction: string;
actor: string;
actor_detail: IUserLite;
};
export type TIssueComment = {
id: string;
workspace: string;
@ -17,6 +25,7 @@ export type TIssueComment = {
actor: string;
actor_detail: TIssueActivityUserDetail;
created_at: string;
edited_at?: string | undefined;
updated_at: string;
created_by: string | undefined;
updated_by: string | undefined;
@ -30,6 +39,23 @@ export type TIssueComment = {
access: EIssueCommentAccessSpecifier;
};
export type TCommentsOperations = {
createComment: (data: Partial<TIssueComment>) => Promise<void>;
updateComment: (commentId: string, data: Partial<TIssueComment>) => Promise<void>;
removeComment: (commentId: string) => Promise<void>;
uploadCommentAsset: (blockId: string, file: File, commentId?: string) => Promise<TFileSignedURLResponse>;
addCommentReaction: (commentId: string, reactionEmoji: string) => Promise<void>;
deleteCommentReaction: (commentId: string, reactionEmoji: string, userReactions: TCommentReaction[]) => Promise<void>;
react: (commentId: string, reactionEmoji: string, userReactions: string[]) => Promise<void>;
reactionIds: (commentId: string) =>
| {
[reaction: string]: string[];
}
| undefined;
userReactions: (commentId: string) => string[] | undefined;
getReactionUsers: (reaction: string, reactionIds: Record<string, string[]>) => string;
};
export type TIssueCommentMap = {
[issue_id: string]: TIssueComment;
};

View file

@ -0,0 +1,76 @@
import { FC, ReactNode, useRef } from "react";
import { observer } from "mobx-react";
// plane imports
import { useTranslation } from "@plane/i18n";
import { TIssueComment } from "@plane/types";
import { Avatar, Tooltip } from "@plane/ui";
import { calculateTimeAgo, cn, getFileURL, renderFormattedDate } from "@plane/utils";
// hooks
import { renderFormattedTime } from "@/helpers/date-time.helper";
import { useMember } from "@/hooks/store";
type TCommentBlock = {
comment: TIssueComment;
ends: "top" | "bottom" | undefined;
quickActions: ReactNode;
children: ReactNode;
};
export const CommentBlock: FC<TCommentBlock> = observer((props) => {
const { comment, ends, quickActions, children } = props;
const commentBlockRef = useRef<HTMLDivElement>(null);
// store hooks
const { getUserDetails } = useMember();
const { t } = useTranslation();
const userDetails = getUserDetails(comment?.actor);
if (!comment || !userDetails) return <></>;
return (
<div
className={`relative flex gap-3 ${ends === "top" ? `pb-2` : ends === "bottom" ? `pt-2` : `py-2`}`}
ref={commentBlockRef}
>
<div
className="absolute left-[13px] top-0 bottom-0 w-0.5 transition-border duration-1000 bg-custom-background-80"
aria-hidden
/>
<div
className={cn(
"flex-shrink-0 relative w-7 h-6 rounded-full transition-border duration-1000 flex justify-center items-center z-[3] uppercase font-medium"
)}
>
<Avatar
size="base"
name={userDetails?.display_name}
src={getFileURL(userDetails?.avatar_url)}
className="flex-shrink-0"
/>
</div>
<div className="flex flex-col gap-3 truncate flex-grow">
<div className="flex w-full gap-2">
<div className="flex-1 flex flex-wrap items-center gap-1">
<div className="text-xs font-medium">
{comment?.actor_detail?.is_bot
? comment?.actor_detail?.first_name + ` ${t("bot")}`
: comment?.actor_detail?.display_name || userDetails.display_name}
</div>
<div className="text-xs text-custom-text-300">
commented{" "}
<Tooltip
tooltipContent={`${renderFormattedDate(comment.created_at)} at ${renderFormattedTime(comment.created_at)}`}
position="bottom"
>
<span className="text-custom-text-350">
{calculateTimeAgo(comment.updated_at)}
{comment.edited_at && ` (${t("edited")})`}
</span>
</Tooltip>
</div>
</div>
<div className="flex-shrink-0 ">{quickActions}</div>
</div>
<div className="text-base mb-2">{children}</div>
</div>
</div>
);
});

View file

@ -0,0 +1 @@
export * from "./comment-block";

View file

@ -4,66 +4,50 @@ import { FC, useEffect, useRef, useState } from "react";
import { observer } from "mobx-react";
import { useForm } from "react-hook-form";
import { Check, Globe2, Lock, Pencil, Trash2, X } from "lucide-react";
// plane constants
// PLane
import { EIssueCommentAccessSpecifier } from "@plane/constants";
// plane editor
import { EditorReadOnlyRefApi, EditorRefApi } from "@plane/editor";
// plane i18n
import { useTranslation } from "@plane/i18n";
// plane types
import { TIssueComment } from "@plane/types";
// plane ui
import { TIssueComment, TCommentsOperations } from "@plane/types";
import { CustomMenu } from "@plane/ui";
// plane utils
import { cn } from "@plane/utils";
// components
import { LiteTextEditor, LiteTextReadOnlyEditor } from "@/components/editor";
// helpers
import { isCommentEmpty } from "@/helpers/string.helper";
// hooks
import { useIssueDetail, useUser, useWorkspace } from "@/hooks/store";
// components
import { IssueCommentReaction } from "../../reactions/issue-comment";
import { TActivityOperations } from "../root";
import { IssueCommentBlock } from "./comment-block";
import { useUser } from "@/hooks/store";
//
import { CommentBlock } from "@/plane-web/components/comments";
import { CommentReactions } from "./comment-reaction";
type TIssueCommentCard = {
projectId: string;
issueId: string;
type TCommentCard = {
workspaceSlug: string;
commentId: string;
activityOperations: TActivityOperations;
comment: TIssueComment | undefined;
activityOperations: TCommentsOperations;
ends: "top" | "bottom" | undefined;
showAccessSpecifier?: boolean;
disabled?: boolean;
projectId?: string;
};
export const IssueCommentCard: FC<TIssueCommentCard> = observer((props) => {
export const CommentCard: FC<TCommentCard> = observer((props) => {
const {
workspaceSlug,
projectId,
issueId,
commentId,
comment,
activityOperations,
ends,
showAccessSpecifier = false,
disabled = false,
projectId,
} = props;
const { t } = useTranslation();
// states
const [isEditing, setIsEditing] = useState(false);
// refs
const editorRef = useRef<EditorRefApi>(null);
const showEditorRef = useRef<EditorReadOnlyRefApi>(null);
// state
const [isEditing, setIsEditing] = useState(false);
// store hooks
const {
comment: { getCommentById },
} = useIssueDetail();
const { data: currentUser } = useUser();
// derived values
const comment = getCommentById(commentId);
const workspaceStore = useWorkspace();
const workspaceId = workspaceStore.getWorkspaceBySlug(comment?.workspace_detail?.slug as string)?.id as string;
// form info
const {
formState: { isSubmitting },
@ -75,13 +59,17 @@ export const IssueCommentCard: FC<TIssueCommentCard> = observer((props) => {
defaultValues: { comment_html: comment?.comment_html },
});
// derived values
const workspaceId = comment?.workspace;
const commentHTML = watch("comment_html");
const isEmpty = isCommentEmpty(commentHTML);
const isEmpty = isCommentEmpty(commentHTML ?? undefined);
const isEditorReadyToDiscard = editorRef.current?.isEditorReadyToDiscard();
const isSubmitButtonDisabled = isSubmitting || !isEditorReadyToDiscard;
const isDisabled = isSubmitting || isEmpty || isSubmitButtonDisabled;
// helpers
const onEnter = async (formData: Partial<TIssueComment>) => {
if (isSubmitting || !comment) return;
setIsEditing(false);
await activityOperations.updateComment(comment.id, formData);
@ -96,11 +84,11 @@ export const IssueCommentCard: FC<TIssueCommentCard> = observer((props) => {
}
}, [isEditing, setFocus]);
if (!comment || !currentUser) return <></>;
if (!comment || !currentUser || !workspaceId) return <></>;
return (
<IssueCommentBlock
commentId={commentId}
<CommentBlock
comment={comment}
quickActions={
<>
{!disabled && currentUser?.id === comment.actor && (
@ -156,8 +144,6 @@ export const IssueCommentCard: FC<TIssueCommentCard> = observer((props) => {
>
<LiteTextEditor
workspaceId={workspaceId}
projectId={projectId}
issue_id={issueId}
workspaceSlug={workspaceSlug}
ref={editorRef}
id={comment.id}
@ -174,6 +160,7 @@ export const IssueCommentCard: FC<TIssueCommentCard> = observer((props) => {
const { asset_id } = await activityOperations.uploadCommentAsset(blockId, file, comment.id);
return asset_id;
}}
projectId={projectId?.toString() ?? ""}
/>
</div>
<div className="flex gap-1 self-end">
@ -181,15 +168,14 @@ export const IssueCommentCard: FC<TIssueCommentCard> = observer((props) => {
<button
type="button"
onClick={handleSubmit(onEnter)}
disabled={isSubmitButtonDisabled}
className={cn(
"group rounded border border-green-500 text-green-500 hover:text-white bg-green-500/20 hover:bg-green-500 p-2 shadow-md duration-300",
{
"pointer-events-none": isSubmitButtonDisabled,
}
)}
disabled={isDisabled}
className={`group rounded border border-green-500 bg-green-500/20 p-2 shadow-md duration-300 ${
isEmpty ? "cursor-not-allowed bg-gray-200" : "hover:bg-green-500"
}`}
>
<Check className="size-3" />
<Check
className={`h-3 w-3 text-green-500 duration-300 ${isEmpty ? "text-black" : "group-hover:text-white"}`}
/>
</button>
)}
<button
@ -201,7 +187,7 @@ export const IssueCommentCard: FC<TIssueCommentCard> = observer((props) => {
</button>
</div>
</form>
<div className={`relative ${isEditing ? "hidden" : ""}`}>
<div className={`relative flex flex-col gap-2 ${isEditing ? "hidden" : ""}`}>
{showAccessSpecifier && (
<div className="absolute right-2.5 top-2.5 z-[1] text-custom-text-300">
{comment.access === EIssueCommentAccessSpecifier.INTERNAL ? (
@ -217,18 +203,13 @@ export const IssueCommentCard: FC<TIssueCommentCard> = observer((props) => {
initialValue={comment.comment_html ?? ""}
workspaceId={workspaceId}
workspaceSlug={workspaceSlug}
projectId={projectId}
/>
<IssueCommentReaction
workspaceSlug={workspaceSlug}
projectId={comment?.project_detail?.id}
commentId={comment.id}
currentUser={currentUser}
disabled={disabled}
editorClassName="[&>*]:!py-0 [&>*]:!text-sm"
containerClassName="!py-1"
projectId={(projectId as string) ?? ""}
/>
<CommentReactions comment={comment} disabled={disabled} activityOperations={activityOperations} />
</div>
</>
</IssueCommentBlock>
</CommentBlock>
);
});

View file

@ -1,40 +1,39 @@
import { FC, useRef, useState } from "react";
import { observer } from "mobx-react";
import { useForm, Controller } from "react-hook-form";
// plane constants
import { EIssueCommentAccessSpecifier } from "@plane/constants";
// plane editor
import { EditorRefApi } from "@plane/editor";
// types
import { TIssueComment } from "@plane/types";
// components
import { TIssueComment, TCommentsOperations } from "@plane/types";
import { LiteTextEditor } from "@/components/editor";
// constants
// helpers
import { cn } from "@/helpers/common.helper";
import { isCommentEmpty } from "@/helpers/string.helper";
// hooks
import { useIssueDetail, useWorkspace } from "@/hooks/store";
// services
import { useWorkspace } from "@/hooks/store";
import { FileService } from "@/services/file.service";
const fileService = new FileService();
// editor
import { TActivityOperations } from "../root";
type TIssueCommentCreate = {
projectId: string;
type TCommentCreate = {
entityId: string;
workspaceSlug: string;
activityOperations: TActivityOperations;
showAccessSpecifier?: boolean;
issueId: string;
activityOperations: TCommentsOperations;
showToolbarInitially?: boolean;
projectId?: string;
};
export const IssueCommentCreate: FC<TIssueCommentCreate> = (props) => {
const { workspaceSlug, projectId, issueId, activityOperations, showAccessSpecifier = false } = props;
// services
const fileService = new FileService();
export const CommentCreate: FC<TCommentCreate> = observer((props) => {
const { workspaceSlug, entityId, activityOperations, showToolbarInitially = false, projectId } = props;
// states
const [uploadedAssetIds, setUploadedAssetIds] = useState<string[]>([]);
// refs
const editorRef = useRef<EditorRefApi>(null);
// store hooks
const workspaceStore = useWorkspace();
const { peekIssue } = useIssueDetail();
// derived values
const workspaceId = workspaceStore.getWorkspaceBySlug(workspaceSlug as string)?.id as string;
// form info
@ -51,13 +50,19 @@ export const IssueCommentCreate: FC<TIssueCommentCreate> = (props) => {
});
const onSubmit = async (formData: Partial<TIssueComment>) => {
await activityOperations
activityOperations
.createComment(formData)
.then(async (res) => {
.then(async () => {
if (uploadedAssetIds.length > 0) {
await fileService.updateBulkProjectAssetsUploadStatus(workspaceSlug, projectId, res.id, {
asset_ids: uploadedAssetIds,
});
if (projectId) {
await fileService.updateBulkProjectAssetsUploadStatus(workspaceSlug, projectId.toString(), entityId, {
asset_ids: uploadedAssetIds,
});
} else {
await fileService.updateBulkWorkspaceAssetsUploadStatus(workspaceSlug, entityId, {
asset_ids: uploadedAssetIds,
});
}
setUploadedAssetIds([]);
}
})
@ -70,13 +75,11 @@ export const IssueCommentCreate: FC<TIssueCommentCreate> = (props) => {
};
const commentHTML = watch("comment_html");
const isEmpty = isCommentEmpty(commentHTML);
const isEmpty = isCommentEmpty(commentHTML ?? undefined);
return (
<div
className={cn("sticky bottom-0 z-[4] bg-custom-background-100 sm:static", {
"-bottom-5": !peekIssue,
})}
className={cn("sticky bottom-0 z-[4] bg-custom-background-100 sm:static")}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey && !isEmpty && !isSubmitting)
handleSubmit(onSubmit)(e);
@ -92,10 +95,8 @@ export const IssueCommentCreate: FC<TIssueCommentCreate> = (props) => {
render={({ field: { value, onChange } }) => (
<LiteTextEditor
workspaceId={workspaceId}
id={"add_comment_" + issueId}
id={"add_comment_" + entityId}
value={"<p></p>"}
projectId={projectId}
issue_id={issueId}
workspaceSlug={workspaceSlug}
onEnterKeyPress={(e) => {
if (!isEmpty && !isSubmitting) {
@ -104,17 +105,18 @@ export const IssueCommentCreate: FC<TIssueCommentCreate> = (props) => {
}}
ref={editorRef}
initialValue={value ?? "<p></p>"}
containerClassName="min-h-[35px]"
containerClassName="min-h-min [&_p]:!p-0 [&_p]:!text-base"
onChange={(comment_json, comment_html) => onChange(comment_html)}
accessSpecifier={accessValue ?? EIssueCommentAccessSpecifier.INTERNAL}
handleAccessChange={onAccessChange}
showAccessSpecifier={showAccessSpecifier}
isSubmitting={isSubmitting}
uploadFile={async (blockId, file) => {
const { asset_id } = await activityOperations.uploadCommentAsset(blockId, file);
setUploadedAssetIds((prev) => [...prev, asset_id]);
return asset_id;
}}
showToolbarInitially={showToolbarInitially}
parentClassName="p-2"
/>
)}
/>
@ -122,4 +124,4 @@ export const IssueCommentCreate: FC<TIssueCommentCreate> = (props) => {
/>
</div>
);
};
});

View file

@ -0,0 +1,67 @@
"use client";
import { FC } from "react";
import { observer } from "mobx-react";
// Plane
import { TCommentsOperations, TIssueComment } from "@plane/types";
import { Tooltip } from "@plane/ui";
// components
import { ReactionSelector } from "@/components/issues";
// helpers
import { cn } from "@/helpers/common.helper";
import { renderEmoji } from "@/helpers/emoji.helper";
export type TProps = {
comment: TIssueComment;
disabled?: boolean;
activityOperations: TCommentsOperations;
};
export const CommentReactions: FC<TProps> = observer((props) => {
const { comment, activityOperations, disabled = false } = props;
const userReactions = activityOperations.userReactions(comment.id);
const reactionIds = activityOperations.reactionIds(comment.id);
if (!userReactions) return null;
return (
<div className="relative flex items-center gap-1.5">
{!disabled && (
<ReactionSelector
size="md"
position="top"
value={userReactions}
onSelect={(reactionEmoji) => activityOperations.react(comment.id, reactionEmoji, userReactions)}
/>
)}
{reactionIds &&
Object.keys(reactionIds || {}).map(
(reaction: string) =>
reactionIds[reaction]?.length > 0 && (
<>
<Tooltip tooltipContent={activityOperations.getReactionUsers(reaction, reactionIds)}>
<button
type="button"
onClick={() => !disabled && activityOperations.react(comment.id, reaction, userReactions)}
key={reaction}
className={cn(
"flex h-full items-center gap-1 rounded-md px-2 py-1 text-sm text-custom-text-100",
userReactions.includes(reaction) ? "bg-custom-primary-100/10" : "bg-custom-background-80",
{
"cursor-not-allowed": disabled,
}
)}
>
<span>{renderEmoji(reaction)}</span>
<span className={userReactions.includes(reaction) ? "text-custom-primary-100" : ""}>
{(reactionIds || {})[reaction].length}{" "}
</span>
</button>
</Tooltip>
</>
)
)}
</div>
);
});

View file

@ -0,0 +1,63 @@
"use client";
import React, { FC } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import { TCommentsOperations, TIssueComment } from "@plane/types";
// local components
import { CommentCard } from "./comment-card";
import { CommentCreate } from "./comment-create";
type TCommentsWrapper = {
projectId?: string;
entityId: string;
isEditingAllowed?: boolean;
activityOperations: TCommentsOperations;
comments: TIssueComment[] | string[];
getCommentById?: (activityId: string) => TIssueComment | undefined;
};
export const CommentsWrapper: FC<TCommentsWrapper> = observer((props) => {
const { entityId, activityOperations, comments, getCommentById, isEditingAllowed = true, projectId } = props;
// router
const { workspaceSlug: routerWorkspaceSlug } = useParams();
const workspaceSlug = routerWorkspaceSlug?.toString();
return (
<div className="relative flex flex-col gap-y-2 h-full overflow-hidden">
{isEditingAllowed && (
<CommentCreate
workspaceSlug={workspaceSlug}
entityId={entityId}
activityOperations={activityOperations}
projectId={projectId}
/>
)}
<div className="flex-grow py-4 overflow-y-auto">
{comments?.map((data, index) => {
let comment;
if (typeof data === "string") {
comment = getCommentById?.(data);
} else {
comment = data;
}
if (!comment) return null;
return (
<CommentCard
key={comment.id}
workspaceSlug={workspaceSlug}
comment={comment as TIssueComment}
activityOperations={activityOperations}
disabled={!isEditingAllowed}
ends={index === 0 ? "top" : index === comments.length - 1 ? "bottom" : undefined}
projectId={projectId}
/>
);
})}
</div>
</div>
);
});

View file

@ -0,0 +1 @@
export * from "./comments";

View file

@ -25,15 +25,17 @@ interface LiteTextEditorWrapperProps
extends MakeOptional<Omit<ILiteTextEditor, "fileHandler" | "mentionHandler">, "disabledExtensions"> {
workspaceSlug: string;
workspaceId: string;
projectId: string;
projectId?: string;
accessSpecifier?: EIssueCommentAccessSpecifier;
handleAccessChange?: (accessKey: EIssueCommentAccessSpecifier) => void;
showAccessSpecifier?: boolean;
showSubmitButton?: boolean;
isSubmitting?: boolean;
showToolbarInitially?: boolean;
showToolbar?: boolean;
uploadFile: TFileHandler["upload"];
issue_id?: string;
parentClassName?: string;
}
export const LiteTextEditor = React.forwardRef<EditorRefApi, LiteTextEditorWrapperProps>((props, ref) => {
@ -50,6 +52,8 @@ export const LiteTextEditor = React.forwardRef<EditorRefApi, LiteTextEditorWrapp
showSubmitButton = true,
isSubmitting = false,
showToolbarInitially = true,
showToolbar = true,
parentClassName = "",
placeholder = t("issue.comments.placeholder"),
uploadFile,
disabledExtensions: additionalDisabledExtensions,
@ -81,7 +85,7 @@ export const LiteTextEditor = React.forwardRef<EditorRefApi, LiteTextEditorWrapp
return (
<div
className={cn("relative border border-custom-border-200 rounded p-3")}
className={cn("relative border border-custom-border-200 rounded p-3", parentClassName)}
onFocus={() => !showToolbarInitially && setIsFocused(true)}
onBlur={() => !showToolbarInitially && setIsFocused(false)}
>
@ -107,31 +111,33 @@ export const LiteTextEditor = React.forwardRef<EditorRefApi, LiteTextEditorWrapp
containerClassName={cn(containerClassName, "relative")}
{...rest}
/>
<div
className={cn(
"transition-all duration-300 ease-out origin-top overflow-hidden",
isFocused ? "max-h-[200px] opacity-100 scale-y-100 mt-3" : "max-h-0 opacity-0 scale-y-0 invisible"
)}
>
<IssueCommentToolbar
accessSpecifier={accessSpecifier}
executeCommand={(item) => {
// TODO: update this while toolbar homogenization
// @ts-expect-error type mismatch here
editorRef?.executeMenuItemCommand({
itemKey: item.itemKey,
...item.extraProps,
});
}}
handleAccessChange={handleAccessChange}
handleSubmit={(e) => rest.onEnterKeyPress?.(e)}
isCommentEmpty={isEmpty}
isSubmitting={isSubmitting}
showAccessSpecifier={showAccessSpecifier}
editorRef={editorRef}
showSubmitButton={showSubmitButton}
/>
</div>
{showToolbar && (
<div
className={cn(
"transition-all duration-300 ease-out origin-top overflow-hidden",
isFocused ? "max-h-[200px] opacity-100 scale-y-100 mt-3" : "max-h-0 opacity-0 scale-y-0 invisible"
)}
>
<IssueCommentToolbar
accessSpecifier={accessSpecifier}
executeCommand={(item) => {
// TODO: update this while toolbar homogenization
// @ts-expect-error type mismatch here
editorRef?.executeMenuItemCommand({
itemKey: item.itemKey,
...item.extraProps,
});
}}
handleAccessChange={handleAccessChange}
handleSubmit={(e) => rest.onEnterKeyPress?.(e)}
isCommentEmpty={isEmpty}
isSubmitting={isSubmitting}
showAccessSpecifier={showAccessSpecifier}
editorRef={editorRef}
showSubmitButton={showSubmitButton}
/>
</div>
)}
</div>
);
});

View file

@ -19,7 +19,7 @@ type LiteTextReadOnlyEditorWrapperProps = MakeOptional<
> & {
workspaceId: string;
workspaceSlug: string;
projectId: string;
projectId?: string;
};
export const LiteTextReadOnlyEditor = React.forwardRef<EditorReadOnlyRefApi, LiteTextReadOnlyEditorWrapperProps>(

View file

@ -3,22 +3,21 @@ import { observer } from "mobx-react";
// constants
import { E_SORT_ORDER, TActivityFilters, filterActivityOnSelectedFilters } from "@plane/constants";
// hooks
import { TCommentsOperations } from "@plane/types";
import { CommentCard } from "@/components/comments/comment-card";
import { useIssueDetail } from "@/hooks/store";
// plane web components
import { IssueAdditionalPropertiesActivity } from "@/plane-web/components/issues";
import { IssueActivityWorklog } from "@/plane-web/components/issues/worklog/activity/root";
// components
import { IssueActivityItem } from "./activity/activity-list";
import { IssueCommentCard } from "./comments/comment-card";
// types
import { TActivityOperations } from "./root";
type TIssueActivityCommentRoot = {
workspaceSlug: string;
projectId: string;
issueId: string;
selectedFilters: TActivityFilters[];
activityOperations: TActivityOperations;
activityOperations: TCommentsOperations;
showAccessSpecifier?: boolean;
disabled?: boolean;
sortOrder: E_SORT_ORDER;
@ -38,7 +37,7 @@ export const IssueActivityCommentRoot: FC<TIssueActivityCommentRoot> = observer(
// hooks
const {
activity: { getActivityCommentByIssueId },
comment: {},
comment: { getCommentById },
} = useIssueDetail();
const activityComments = getActivityCommentByIssueId(issueId, sortOrder);
@ -49,18 +48,18 @@ export const IssueActivityCommentRoot: FC<TIssueActivityCommentRoot> = observer(
return (
<div>
{filteredActivityComments.map((activityComment, index) =>
activityComment.activity_type === "COMMENT" ? (
<IssueCommentCard
projectId={projectId}
issueId={issueId}
{filteredActivityComments.map((activityComment, index) => {
const comment = getCommentById(activityComment.id);
return activityComment.activity_type === "COMMENT" ? (
<CommentCard
key={activityComment.id}
workspaceSlug={workspaceSlug}
commentId={activityComment.id}
comment={comment}
activityOperations={activityOperations}
ends={index === 0 ? "top" : index === filteredActivityComments.length - 1 ? "bottom" : undefined}
showAccessSpecifier={showAccessSpecifier}
disabled={disabled}
projectId={projectId}
/>
) : activityComment.activity_type === "ACTIVITY" ? (
<IssueActivityItem
@ -82,8 +81,8 @@ export const IssueActivityCommentRoot: FC<TIssueActivityCommentRoot> = observer(
/>
) : (
<></>
)
)}
);
})}
</div>
);
});

View file

@ -53,7 +53,7 @@ export const IssueActivityBlockComponent: FC<TIssueActivityBlockComponent> = (pr
isMobile={isMobile}
tooltipContent={`${renderFormattedDate(activity.created_at)}, ${renderFormattedTime(activity.created_at)}`}
>
<span className="whitespace-nowrap"> {calculateTimeAgo(activity.created_at)}</span>
<span className="whitespace-nowrap text-custom-text-350"> {calculateTimeAgo(activity.created_at)}</span>
</Tooltip>
</span>
</div>

View file

@ -1,70 +0,0 @@
import { FC, ReactNode } from "react";
import { observer } from "mobx-react";
import { MessageCircle } from "lucide-react";
// helpers
import { calculateTimeAgo } from "@/helpers/date-time.helper";
import { getFileURL } from "@/helpers/file.helper";
// hooks
import { useIssueDetail } from "@/hooks/store";
type TIssueCommentBlock = {
commentId: string;
ends: "top" | "bottom" | undefined;
quickActions: ReactNode;
children: ReactNode;
};
export const IssueCommentBlock: FC<TIssueCommentBlock> = observer((props) => {
const { commentId, ends, quickActions, children } = props;
// hooks
const {
comment: { getCommentById },
} = useIssueDetail();
const comment = getCommentById(commentId);
if (!comment) return <></>;
return (
<div className={`relative flex gap-3 ${ends === "top" ? `pb-2` : ends === "bottom" ? `pt-2` : `py-2`}`}>
<div className="absolute left-[13px] top-0 bottom-0 w-0.5 bg-custom-background-80" aria-hidden />
<div className="flex-shrink-0 relative w-7 h-7 rounded-full flex justify-center items-center z-[3] bg-gray-500 text-white border border-white uppercase font-medium">
{comment.actor_detail?.avatar_url && comment.actor_detail?.avatar_url !== "" ? (
<img
src={getFileURL(comment.actor_detail?.avatar_url)}
alt={
comment.actor_detail?.is_bot
? comment.actor_detail?.first_name + " Bot"
: comment.actor_detail?.display_name
}
height={30}
width={30}
className="grid h-7 w-7 place-items-center rounded-full border-2 border-custom-border-200"
/>
) : (
<>
{comment.actor_detail?.is_bot
? comment.actor_detail?.first_name.charAt(0)
: comment.actor_detail?.display_name.charAt(0)}
</>
)}
<div className="absolute top-2 left-4 w-5 h-5 rounded-full overflow-hidden flex justify-center items-center bg-custom-background-80">
<MessageCircle className="w-3 h-3 text-custom-text-200" />
</div>
</div>
<div className="w-full truncate relative flex ">
<div className="w-full truncate space-y-1">
<div>
<div className="text-xs capitalize">
{comment.actor_detail?.is_bot
? comment.actor_detail?.first_name + " Bot"
: comment.actor_detail?.display_name}
</div>
<div className="text-xs text-custom-text-200">commented {calculateTimeAgo(comment.created_at)}</div>
</div>
<div>{children}</div>
</div>
<div className="flex-shrink-0 ">{quickActions}</div>
</div>
</div>
);
});

View file

@ -1,4 +0,0 @@
export * from "./comment-block";
export * from "./comment-card";
export * from "./comment-create";
export * from "./root";

View file

@ -1,63 +0,0 @@
import { FC } from "react";
import { observer } from "mobx-react";
// plane imports
import { useTranslation } from "@plane/i18n";
// components
import { SimpleEmptyState } from "@/components/empty-state";
// hooks
import { useIssueDetail } from "@/hooks/store";
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
// local components
import { TActivityOperations } from "../root";
import { IssueCommentCard } from "./comment-card";
type TIssueCommentRoot = {
projectId: string;
workspaceSlug: string;
issueId: string;
activityOperations: TActivityOperations;
showAccessSpecifier?: boolean;
disabled?: boolean;
};
export const IssueCommentRoot: FC<TIssueCommentRoot> = observer((props) => {
const { workspaceSlug, projectId, issueId, activityOperations, showAccessSpecifier, disabled } = props;
// hooks
const {
comment: { getCommentsByIssueId },
} = useIssueDetail();
const { t } = useTranslation();
// derived values
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/search/comments" });
const commentIds = getCommentsByIssueId(issueId);
if (!commentIds) return <></>;
return (
<div>
{commentIds.length > 0 ? (
commentIds.map((commentId, index) => (
<IssueCommentCard
projectId={projectId}
issueId={issueId}
key={commentId}
workspaceSlug={workspaceSlug}
commentId={commentId}
ends={index === 0 ? "top" : index === commentIds.length - 1 ? "bottom" : undefined}
activityOperations={activityOperations}
showAccessSpecifier={showAccessSpecifier}
disabled={disabled}
/>
))
) : (
<div className="flex items-center justify-center py-9">
<SimpleEmptyState
title={t("issue_comment.empty_state.general.title")}
description={t("issue_comment.empty_state.general.description")}
assetPath={resolvedPath}
/>
</div>
)}
</div>
);
});

View file

@ -0,0 +1,158 @@
import { useMemo } from "react";
import { useTranslation } from "@plane/i18n";
import { TCommentsOperations, TIssueComment } from "@plane/types";
import { EFileAssetType } from "@plane/types/src/enums";
import { setToast, TOAST_TYPE } from "@plane/ui";
import { formatTextList } from "@/helpers/issue.helper";
import { useEditorAsset, useIssueDetail, useMember, useUser } from "@/hooks/store";
export const useCommentOperations = (
workspaceSlug: string | undefined,
projectId: string | undefined,
issueId: string | undefined
): TCommentsOperations => {
// store hooks
const {
commentReaction: { getCommentReactionsByCommentId, commentReactionsByUser, getCommentReactionById },
createComment,
updateComment,
removeComment,
createCommentReaction,
removeCommentReaction,
} = useIssueDetail();
const { getUserDetails } = useMember();
const { uploadEditorAsset } = useEditorAsset();
const { data: currentUser } = useUser();
const { t } = useTranslation();
const operations = useMemo(() => {
// Define operations object with all methods
const ops = {
createComment: async (data: Partial<TIssueComment>) => {
try {
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing fields");
const comment = await createComment(workspaceSlug, projectId, issueId, data);
setToast({
title: t("common.success"),
type: TOAST_TYPE.SUCCESS,
message: t("issue.comments.create.success"),
});
return comment;
} catch {
setToast({
title: t("common.error.label"),
type: TOAST_TYPE.ERROR,
message: t("issue.comments.create.error"),
});
}
},
updateComment: async (commentId: string, data: Partial<TIssueComment>) => {
try {
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing fields");
await updateComment(workspaceSlug, projectId, issueId, commentId, data);
setToast({
title: t("common.success"),
type: TOAST_TYPE.SUCCESS,
message: t("issue.comments.update.success"),
});
} catch {
setToast({
title: t("common.error.label"),
type: TOAST_TYPE.ERROR,
message: t("issue.comments.update.error"),
});
}
},
removeComment: async (commentId: string) => {
try {
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing fields");
await removeComment(workspaceSlug, projectId, issueId, commentId);
setToast({
title: t("common.success"),
type: TOAST_TYPE.SUCCESS,
message: t("issue.comments.remove.success"),
});
} catch {
setToast({
title: t("common.error.label"),
type: TOAST_TYPE.ERROR,
message: t("issue.comments.remove.error"),
});
}
},
uploadCommentAsset: async (blockId: string, file: File, commentId?: string) => {
try {
if (!workspaceSlug || !projectId) throw new Error("Missing fields");
const res = await uploadEditorAsset({
blockId,
data: {
entity_identifier: commentId ?? "",
entity_type: EFileAssetType.COMMENT_DESCRIPTION,
},
file,
projectId,
workspaceSlug,
});
return res;
} catch (error) {
console.log("Error in uploading comment asset:", error);
throw new Error(t("issue.comments.upload.error"));
}
},
addCommentReaction: async (commentId: string, reaction: string) => {
try {
if (!workspaceSlug || !projectId || !commentId) throw new Error("Missing fields");
await createCommentReaction(workspaceSlug, projectId, commentId, reaction);
setToast({
title: "Success!",
type: TOAST_TYPE.SUCCESS,
message: "Reaction created successfully",
});
} catch (error) {
setToast({
title: "Error!",
type: TOAST_TYPE.ERROR,
message: "Reaction creation failed",
});
}
},
deleteCommentReaction: async (commentId: string, reaction: string) => {
try {
if (!workspaceSlug || !projectId || !commentId || !currentUser?.id) throw new Error("Missing fields");
removeCommentReaction(workspaceSlug, projectId, commentId, reaction, currentUser.id);
setToast({
title: "Success!",
type: TOAST_TYPE.SUCCESS,
message: "Reaction removed successfully",
});
} catch (error) {
setToast({
title: "Error!",
type: TOAST_TYPE.ERROR,
message: "Reaction remove failed",
});
}
},
react: async (commentId: string, reactionEmoji: string, userReactions: string[]) => {
if (userReactions.includes(reactionEmoji)) await ops.deleteCommentReaction(commentId, reactionEmoji);
else await ops.addCommentReaction(commentId, reactionEmoji);
},
reactionIds: (commentId: string) => getCommentReactionsByCommentId(commentId),
userReactions: (commentId: string) =>
currentUser ? commentReactionsByUser(commentId, currentUser?.id).map((r) => r.reaction) : [],
getReactionUsers: (reaction: string, reactionIds: Record<string, string[]>): string => {
const reactionUsers = (reactionIds?.[reaction] || [])
.map((reactionId) => {
const reactionDetails = getCommentReactionById(reactionId);
return reactionDetails ? getUserDetails(reactionDetails.actor)?.display_name : null;
})
.filter((displayName): displayName is string => !!displayName);
const formattedUsers = formatTextList(reactionUsers);
return formattedUsers;
},
};
return ops;
}, [workspaceSlug, projectId, issueId, createComment, updateComment, uploadEditorAsset, removeComment]);
return operations;
};

View file

@ -6,8 +6,5 @@ export * from "./activity-comment-root";
export * from "./activity/activity-list";
export * from "./activity-filter";
// issue comment
export * from "./comments";
// sort
export * from "./sort-root";

View file

@ -1,6 +1,6 @@
"use client";
import { FC, useMemo } from "react";
import { FC } from "react";
import { observer } from "mobx-react";
// plane package imports
import { E_SORT_ORDER, TActivityFilters, defaultActivityFilters, EUserPermissions } from "@plane/constants";
@ -9,16 +9,15 @@ import { useLocalStorage } from "@plane/hooks";
import { useTranslation } from "@plane/i18n";
//types
import { TFileSignedURLResponse, TIssueComment } from "@plane/types";
import { EFileAssetType } from "@plane/types/src/enums";
import { TOAST_TYPE, setToast } from "@plane/ui";
// components
import { IssueCommentCreate } from "@/components/issues";
import { CommentCreate } from "@/components/comments/comment-create";
import { ActivitySortRoot, IssueActivityCommentRoot } from "@/components/issues/issue-detail";
// constants
// hooks
import { useEditorAsset, useIssueDetail, useProject, useUser, useUserPermissions } from "@/hooks/store";
import { useIssueDetail, useProject, useUser, useUserPermissions } from "@/hooks/store";
// plane web components
import { ActivityFilterRoot, IssueActivityWorklogCreateButton } from "@/plane-web/components/issues/worklog";
import { useCommentOperations } from "./helper";
type TIssueActivity = {
workspaceSlug: string;
@ -48,14 +47,11 @@ export const IssueActivity: FC<TIssueActivity> = observer((props) => {
// store hooks
const {
issue: { getIssueById },
createComment,
updateComment,
removeComment,
} = useIssueDetail();
const { projectPermissionsByWorkspaceSlugAndProjectId } = useUserPermissions();
const { getProjectById } = useProject();
const { data: currentUser } = useUser();
const { uploadEditorAsset } = useEditorAsset();
// derived values
const issue = issueId ? getIssueById(issueId) : undefined;
const currentUserProjectRole = projectPermissionsByWorkspaceSlugAndProjectId(workspaceSlug, projectId);
@ -81,82 +77,8 @@ export const IssueActivity: FC<TIssueActivity> = observer((props) => {
setSortOrder(sortOrder === E_SORT_ORDER.ASC ? E_SORT_ORDER.DESC : E_SORT_ORDER.ASC);
};
const activityOperations: TActivityOperations = useMemo(
() => ({
createComment: async (data) => {
try {
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing fields");
const comment = await createComment(workspaceSlug, projectId, issueId, data);
setToast({
title: t("common.success"),
type: TOAST_TYPE.SUCCESS,
message: t("issue.comments.create.success"),
});
return comment;
} catch {
setToast({
title: t("common.error.label"),
type: TOAST_TYPE.ERROR,
message: t("issue.comments.create.error"),
});
}
},
updateComment: async (commentId, data) => {
try {
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing fields");
await updateComment(workspaceSlug, projectId, issueId, commentId, data);
setToast({
title: t("common.success"),
type: TOAST_TYPE.SUCCESS,
message: t("issue.comments.update.success"),
});
} catch {
setToast({
title: t("common.error.label"),
type: TOAST_TYPE.ERROR,
message: t("issue.comments.update.error"),
});
}
},
removeComment: async (commentId) => {
try {
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing fields");
await removeComment(workspaceSlug, projectId, issueId, commentId);
setToast({
title: t("common.success"),
type: TOAST_TYPE.SUCCESS,
message: t("issue.comments.remove.success"),
});
} catch {
setToast({
title: t("common.error.label"),
type: TOAST_TYPE.ERROR,
message: t("issue.comments.remove.error"),
});
}
},
uploadCommentAsset: async (blockId, file, commentId) => {
try {
if (!workspaceSlug || !projectId) throw new Error("Missing fields");
const res = await uploadEditorAsset({
blockId,
data: {
entity_identifier: commentId ?? "",
entity_type: EFileAssetType.COMMENT_DESCRIPTION,
},
file,
projectId,
workspaceSlug,
});
return res;
} catch (error) {
console.log("Error in uploading comment asset:", error);
throw new Error(t("issue.comments.upload.error"));
}
},
}),
[workspaceSlug, projectId, issueId, createComment, updateComment, uploadEditorAsset, removeComment]
);
// helper hooks
const activityOperations = useCommentOperations(workspaceSlug, projectId, issueId);
const project = getProjectById(projectId);
if (!project) return <></>;
@ -200,12 +122,12 @@ export const IssueActivity: FC<TIssueActivity> = observer((props) => {
sortOrder={sortOrder || E_SORT_ORDER.ASC}
/>
{!disabled && (
<IssueCommentCreate
issueId={issueId}
projectId={projectId}
<CommentCreate
workspaceSlug={workspaceSlug}
entityId={issueId}
activityOperations={activityOperations}
showAccessSpecifier={!!project.anchor}
showToolbarInitially
projectId={projectId}
/>
)}
</div>

View file

@ -109,6 +109,20 @@ export class FileService extends APIService {
});
}
async updateBulkWorkspaceAssetsUploadStatus(
workspaceSlug: string,
entityId: string,
data: {
asset_ids: string[];
}
): Promise<void> {
return this.post(`/api/assets/v2/workspaces/${workspaceSlug}/${entityId}/bulk/`, data)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async updateBulkProjectAssetsUploadStatus(
workspaceSlug: string,
projectId: string,

View file

@ -63,7 +63,7 @@ export class IssueCommentService extends APIService {
issueId: string,
commentId: string,
data: Partial<TIssueComment>
): Promise<void> {
): Promise<TIssueComment> {
return this.patch(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/${this.serviceType}/${issueId}/comments/${commentId}/`,
data

View file

@ -155,6 +155,11 @@ export class IssueCommentStore implements IIssueCommentStore {
data
);
runInAction(() => {
set(this.commentMap, [commentId, "updated_at"], response.updated_at);
set(this.commentMap, [commentId, "edited_at"], response.edited_at);
});
return response;
} catch (error) {
this.rootIssueDetail.activity.fetchActivities(workspaceSlug, projectId, issueId);

View file

@ -121,6 +121,7 @@ export class IssueCommentReactionStore implements IIssueCommentReactionStore {
return response;
} catch (error) {
console.log("error", error);
throw error;
}
};
@ -149,8 +150,9 @@ export class IssueCommentReactionStore implements IIssueCommentReactionStore {
reaction,
});
if (!this.commentReactions[commentId]) this.commentReactions[commentId] = {};
runInAction(() => {
update(this.commentReactions, [commentId, reaction], (reactionId) => {
update(this.commentReactions, `${commentId}.${reaction}`, (reactionId) => {
if (!reactionId) return [response.id];
return concat(reactionId, response.id);
});
@ -159,6 +161,7 @@ export class IssueCommentReactionStore implements IIssueCommentReactionStore {
return response;
} catch (error) {
console.log("error", error);
throw error;
}
};

View file

@ -0,0 +1 @@
export * from "ce/components/comments";