[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:
parent
a5ffbffed9
commit
869c755065
36 changed files with 577 additions and 360 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -501,6 +501,8 @@
|
|||
"re_generate_key": "キーを再生成",
|
||||
"export": "エクスポート",
|
||||
"member": "{count, plural, other{# メンバー}}",
|
||||
"edited": "編集済み",
|
||||
"bot": "ボット",
|
||||
|
||||
"project_view": {
|
||||
"sort_by": {
|
||||
|
|
|
|||
|
|
@ -501,6 +501,8 @@
|
|||
"re_generate_key": "키 다시 생성",
|
||||
"export": "내보내기",
|
||||
"member": "{count, plural, one{# 멤버} other{# 멤버}}",
|
||||
"edited": "수정됨",
|
||||
"bot": "봇",
|
||||
|
||||
"project_view": {
|
||||
"sort_by": {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -499,6 +499,8 @@
|
|||
"re_generate_key": "Перегенерировать ключ",
|
||||
"export": "Экспорт",
|
||||
"member": "{count, plural, one{# участник} few{# участника} other{# участников}}",
|
||||
"edited": "Редактировано",
|
||||
"bot": "Бот",
|
||||
|
||||
"project_view": {
|
||||
"sort_by": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -499,6 +499,9 @@
|
|||
"re_generate_key": "Повторно згенерувати ключ",
|
||||
"export": "Експортувати",
|
||||
"member": "{count, plural, one{# учасник} few{# учасники} other{# учасників}}",
|
||||
"edited": "Редагувано",
|
||||
"bot": "Бот",
|
||||
|
||||
"project_view": {
|
||||
"sort_by": {
|
||||
"created_at": "Створено",
|
||||
|
|
|
|||
|
|
@ -501,6 +501,8 @@
|
|||
"re_generate_key": "重新生成密钥",
|
||||
"export": "导出",
|
||||
"member": "{count, plural, other{# 成员}}",
|
||||
"edited": "已编辑",
|
||||
"bot": "机器人",
|
||||
|
||||
"project_view": {
|
||||
"sort_by": {
|
||||
|
|
|
|||
|
|
@ -501,6 +501,8 @@
|
|||
"re_generate_key": "重新產生金鑰",
|
||||
"export": "匯出",
|
||||
"member": "{count, plural, one{# 位成員} other{# 位成員}}",
|
||||
"edited": "已編輯",
|
||||
"bot": "機器人",
|
||||
|
||||
"project_view": {
|
||||
"sort_by": {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
76
web/ce/components/comments/comment-block.tsx
Normal file
76
web/ce/components/comments/comment-block.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
1
web/ce/components/comments/index.ts
Normal file
1
web/ce/components/comments/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./comment-block";
|
||||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
});
|
||||
67
web/core/components/comments/comment-reaction.tsx
Normal file
67
web/core/components/comments/comment-reaction.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
63
web/core/components/comments/comments.tsx
Normal file
63
web/core/components/comments/comments.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
1
web/core/components/comments/index.ts
Normal file
1
web/core/components/comments/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./comments";
|
||||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ type LiteTextReadOnlyEditorWrapperProps = MakeOptional<
|
|||
> & {
|
||||
workspaceId: string;
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
projectId?: string;
|
||||
};
|
||||
|
||||
export const LiteTextReadOnlyEditor = React.forwardRef<EditorReadOnlyRefApi, LiteTextReadOnlyEditorWrapperProps>(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
export * from "./comment-block";
|
||||
export * from "./comment-card";
|
||||
export * from "./comment-create";
|
||||
export * from "./root";
|
||||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
|
|
|||
1
web/ee/components/comments/index.ts
Normal file
1
web/ee/components/comments/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "ce/components/comments";
|
||||
Loading…
Add table
Add a link
Reference in a new issue