chore: issue comment reaction

This commit is contained in:
gurusainath 2024-01-22 16:30:24 +05:30
parent 8f8b106a89
commit 8c63a9d7cd
9 changed files with 232 additions and 82 deletions

View File

@ -1,20 +1,20 @@
export type TIssueCommentReaction = { export type TIssueCommentReaction = {
id: string; id: string;
comment: string;
actor: string;
reaction: string;
workspace: string;
project: string;
created_at: Date; created_at: Date;
updated_at: Date; updated_at: Date;
reaction: string;
created_by: string; created_by: string;
updated_by: string; updated_by: string;
project: string;
workspace: string;
actor: string;
comment: string;
}; };
export type TIssueCommentReactionMap = { export type TIssueCommentReactionMap = {
[issue_id: string]: TIssueCommentReaction; [reaction_id: string]: TIssueCommentReaction;
}; };
export type TIssueCommentReactionIdMap = { export type TIssueCommentReactionIdMap = {
[issue_id: string]: string[]; [comment_id: string]: { [reaction: string]: string[] };
}; };

View File

@ -13,7 +13,7 @@ export type TIssueReaction = {
}; };
export type TIssueReactionMap = { export type TIssueReactionMap = {
[issue_id: string]: TIssueReaction; [reaction_id: string]: TIssueReaction;
}; };
export type TIssueReactionIdMap = { export type TIssueReactionIdMap = {

View File

@ -6,6 +6,7 @@ import { useIssueDetail, useMention, useUser } from "hooks/store";
// components // components
import { IssueCommentBlock } from "./comment-block"; import { IssueCommentBlock } from "./comment-block";
import { LiteTextEditorWithRef, LiteReadOnlyEditorWithRef } from "@plane/lite-text-editor"; import { LiteTextEditorWithRef, LiteReadOnlyEditorWithRef } from "@plane/lite-text-editor";
import { IssueCommentReaction } from "../../reactions/issue-comment";
// ui // ui
import { CustomMenu } from "@plane/ui"; import { CustomMenu } from "@plane/ui";
// services // services
@ -28,7 +29,7 @@ type TIssueCommentCard = {
}; };
export const IssueCommentCard: FC<TIssueCommentCard> = (props) => { export const IssueCommentCard: FC<TIssueCommentCard> = (props) => {
const { commentId, activityOperations, ends, showAccessSpecifier = true } = props; const { workspaceSlug, commentId, activityOperations, ends, showAccessSpecifier = true } = props;
// hooks // hooks
const { const {
comment: { getCommentById }, comment: { getCommentById },
@ -67,7 +68,7 @@ export const IssueCommentCard: FC<TIssueCommentCard> = (props) => {
isEditing && setFocus("comment_html"); isEditing && setFocus("comment_html");
}, [isEditing, setFocus]); }, [isEditing, setFocus]);
if (!comment) return <></>; if (!comment || !currentUser) return <></>;
return ( return (
<IssueCommentBlock <IssueCommentBlock
commentId={commentId} commentId={commentId}
@ -161,7 +162,13 @@ export const IssueCommentCard: FC<TIssueCommentCard> = (props) => {
customClassName="text-xs border border-custom-border-200 bg-custom-background-100" customClassName="text-xs border border-custom-border-200 bg-custom-background-100"
mentionHighlights={mentionHighlights} mentionHighlights={mentionHighlights}
/> />
{/* <CommentReaction projectId={comment.project} commentId={comment.id} /> */}
<IssueCommentReaction
workspaceSlug={workspaceSlug}
projectId={comment?.project_detail?.id}
commentId={comment.id}
currentUser={currentUser}
/>
</div> </div>
</> </>
</IssueCommentBlock> </IssueCommentBlock>

View File

@ -40,8 +40,6 @@ export type TActivityOperations = {
createComment: (data: Partial<TIssueComment>) => Promise<void>; createComment: (data: Partial<TIssueComment>) => Promise<void>;
updateComment: (commentId: string, data: Partial<TIssueComment>) => Promise<void>; updateComment: (commentId: string, data: Partial<TIssueComment>) => Promise<void>;
removeComment: (commentId: string) => Promise<void>; removeComment: (commentId: string) => Promise<void>;
createCommentReaction: (commentId: string, reaction: string) => Promise<void>;
removeCommentReaction: (commentId: string, reaction: string) => Promise<void>;
}; };
export const IssueActivity: FC<TIssueActivity> = observer((props) => { export const IssueActivity: FC<TIssueActivity> = observer((props) => {
@ -106,52 +104,8 @@ export const IssueActivity: FC<TIssueActivity> = observer((props) => {
}); });
} }
}, },
createCommentReaction: async (commentId: string, reaction: string) => {
try {
if (!workspaceSlug || !projectId) throw new Error("Missing fields");
await createCommentReaction(workspaceSlug, projectId, commentId, reaction);
setToastAlert({
title: "Comment reaction added successfully.",
type: "success",
message: "Comment reaction added successfully.",
});
} catch (error) {
setToastAlert({
title: "Comment reaction addition failed.",
type: "error",
message: "Comment reaction addition failed. Please try again later.",
});
}
},
removeCommentReaction: async (commentId: string, reaction: string) => {
try {
if (!workspaceSlug || !projectId) throw new Error("Missing fields");
await removeCommentReaction(workspaceSlug, projectId, commentId, reaction);
setToastAlert({
title: "Comment reaction removed successfully.",
type: "success",
message: "Comment reaction removed successfully.",
});
} catch (error) {
setToastAlert({
title: "Comment reaction removal failed.",
type: "error",
message: "Comment reaction removal failed. Please try again later.",
});
}
},
}), }),
[ [workspaceSlug, projectId, issueId, createComment, updateComment, removeComment, setToastAlert]
workspaceSlug,
projectId,
issueId,
createComment,
updateComment,
removeComment,
createCommentReaction,
removeCommentReaction,
setToastAlert,
]
); );
const componentCommonProps = { const componentCommonProps = {
@ -167,7 +121,7 @@ export const IssueActivity: FC<TIssueActivity> = observer((props) => {
<div className="text-lg text-custom-text-100">Comments/Activity</div> <div className="text-lg text-custom-text-100">Comments/Activity</div>
{/* rendering activity */} {/* rendering activity */}
<div className="space-y-2"> <div className="space-y-3">
<div className="relative flex items-center gap-1"> <div className="relative flex items-center gap-1">
{activityTabs.map((tab) => ( {activityTabs.map((tab) => (
<div <div
@ -190,25 +144,25 @@ export const IssueActivity: FC<TIssueActivity> = observer((props) => {
<div className="min-h-[200px]"> <div className="min-h-[200px]">
{activityTab === "all" ? ( {activityTab === "all" ? (
<> <div className="space-y-3">
<IssueActivityCommentRoot {...componentCommonProps} activityOperations={activityOperations} /> <IssueActivityCommentRoot {...componentCommonProps} activityOperations={activityOperations} />
<IssueCommentCreateUpdate <IssueCommentCreateUpdate
workspaceSlug={workspaceSlug} workspaceSlug={workspaceSlug}
activityOperations={activityOperations} activityOperations={activityOperations}
disabled={disabled} disabled={disabled}
/> />
</> </div>
) : activityTab === "activity" ? ( ) : activityTab === "activity" ? (
<IssueActivityRoot {...componentCommonProps} /> <IssueActivityRoot {...componentCommonProps} />
) : ( ) : (
<> <div className="space-y-3">
<IssueCommentRoot {...componentCommonProps} activityOperations={activityOperations} /> <IssueCommentRoot {...componentCommonProps} activityOperations={activityOperations} />
<IssueCommentCreateUpdate <IssueCommentCreateUpdate
workspaceSlug={workspaceSlug} workspaceSlug={workspaceSlug}
activityOperations={activityOperations} activityOperations={activityOperations}
disabled={disabled} disabled={disabled}
/> />
</> </div>
)} )}
</div> </div>
</div> </div>

View File

@ -0,0 +1,118 @@
import { FC, useMemo } from "react";
import { observer } from "mobx-react-lite";
// components
import { ReactionSelector } from "./reaction-selector";
// hooks
import { useIssueDetail } from "hooks/store";
import useToast from "hooks/use-toast";
// types
import { IUser } from "@plane/types";
import { renderEmoji } from "helpers/emoji.helper";
export type TIssueCommentReaction = {
workspaceSlug: string;
projectId: string;
commentId: string;
currentUser: IUser;
};
export const IssueCommentReaction: FC<TIssueCommentReaction> = observer((props) => {
const { workspaceSlug, projectId, commentId, currentUser } = props;
// hooks
const {
commentReaction: { getCommentReactionsByCommentId, commentReactionsByUser },
createCommentReaction,
removeCommentReaction,
} = useIssueDetail();
const { setToastAlert } = useToast();
const reactionIds = getCommentReactionsByCommentId(commentId);
const userReactions = commentReactionsByUser(commentId, currentUser.id).map((r) => r.reaction);
const issueCommentReactionOperations = useMemo(
() => ({
create: async (reaction: string) => {
try {
if (!workspaceSlug || !projectId || !commentId) throw new Error("Missing fields");
await createCommentReaction(workspaceSlug, projectId, commentId, reaction);
setToastAlert({
title: "Reaction created successfully",
type: "success",
message: "Reaction created successfully",
});
} catch (error) {
setToastAlert({
title: "Reaction creation failed",
type: "error",
message: "Reaction creation failed",
});
}
},
remove: async (reaction: string) => {
try {
if (!workspaceSlug || !projectId || !commentId || !currentUser?.id) throw new Error("Missing fields");
removeCommentReaction(workspaceSlug, projectId, commentId, reaction, currentUser.id);
setToastAlert({
title: "Reaction removed successfully",
type: "success",
message: "Reaction removed successfully",
});
} catch (error) {
setToastAlert({
title: "Reaction remove failed",
type: "error",
message: "Reaction remove failed",
});
}
},
react: async (reaction: string) => {
if (userReactions.includes(reaction)) await issueCommentReactionOperations.remove(reaction);
else await issueCommentReactionOperations.create(reaction);
},
}),
[
workspaceSlug,
projectId,
commentId,
currentUser,
createCommentReaction,
removeCommentReaction,
setToastAlert,
userReactions,
]
);
return (
<div className="mt-4 relative flex items-center gap-1.5">
<ReactionSelector
size="md"
position="top"
value={userReactions}
onSelect={issueCommentReactionOperations.react}
/>
{reactionIds &&
Object.keys(reactionIds || {}).map(
(reaction) =>
reactionIds[reaction]?.length > 0 && (
<>
<button
type="button"
onClick={() => issueCommentReactionOperations.react(reaction)}
key={reaction}
className={`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"
}`}
>
<span>{renderEmoji(reaction)}</span>
<span className={userReactions.includes(reaction) ? "text-custom-primary-100" : ""}>
{(reactionIds || {})[reaction].length}{" "}
</span>
</button>
</>
)
)}
</div>
);
});

View File

@ -50,7 +50,7 @@ export const IssueReaction: FC<TIssueReaction> = observer((props) => {
}, },
remove: async (reaction: string) => { remove: async (reaction: string) => {
try { try {
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing fields"); if (!workspaceSlug || !projectId || !issueId || !currentUser?.id) throw new Error("Missing fields");
await removeReaction(workspaceSlug, projectId, issueId, reaction, currentUser.id); await removeReaction(workspaceSlug, projectId, issueId, reaction, currentUser.id);
setToastAlert({ setToastAlert({
title: "Reaction removed successfully", title: "Reaction removed successfully",

View File

@ -101,6 +101,7 @@ export class IssueCommentStore implements IIssueCommentStore {
return uniq(concat(_commentIds, commentIds)); return uniq(concat(_commentIds, commentIds));
}); });
comments.forEach((comment) => { comments.forEach((comment) => {
this.rootIssueDetail.commentReaction.applyCommentReactions(comment.id, comment?.comment_reactions || []);
set(this.commentMap, comment.id, comment); set(this.commentMap, comment.id, comment);
}); });
this.loader = undefined; this.loader = undefined;

View File

@ -1,10 +1,16 @@
import { action, makeObservable, observable, runInAction } from "mobx"; import { action, makeObservable, observable, runInAction } from "mobx";
import set from "lodash/set"; import set from "lodash/set";
import update from "lodash/update";
import concat from "lodash/concat";
import find from "lodash/find";
import pull from "lodash/pull";
// services // services
import { IssueReactionService } from "services/issue"; import { IssueReactionService } from "services/issue";
// types // types
import { IIssueDetail } from "./root.store"; import { IIssueDetail } from "./root.store";
import { TIssueCommentReaction, TIssueCommentReactionIdMap, TIssueCommentReactionMap } from "@plane/types"; import { TIssueCommentReaction, TIssueCommentReactionIdMap, TIssueCommentReactionMap } from "@plane/types";
// helpers
import { groupReactions } from "helpers/emoji.helper";
export interface IIssueCommentReactionStoreActions { export interface IIssueCommentReactionStoreActions {
// actions // actions
@ -13,6 +19,7 @@ export interface IIssueCommentReactionStoreActions {
projectId: string, projectId: string,
commentId: string commentId: string
) => Promise<TIssueCommentReaction[]>; ) => Promise<TIssueCommentReaction[]>;
applyCommentReactions: (commentId: string, commentReactions: TIssueCommentReaction[]) => void;
createCommentReaction: ( createCommentReaction: (
workspaceSlug: string, workspaceSlug: string,
projectId: string, projectId: string,
@ -23,7 +30,8 @@ export interface IIssueCommentReactionStoreActions {
workspaceSlug: string, workspaceSlug: string,
projectId: string, projectId: string,
commentId: string, commentId: string,
reaction: string reaction: string,
userId: string
) => Promise<any>; ) => Promise<any>;
} }
@ -32,8 +40,9 @@ export interface IIssueCommentReactionStore extends IIssueCommentReactionStoreAc
commentReactions: TIssueCommentReactionIdMap; commentReactions: TIssueCommentReactionIdMap;
commentReactionMap: TIssueCommentReactionMap; commentReactionMap: TIssueCommentReactionMap;
// helper methods // helper methods
getCommentReactionsByCommentId: (commentId: string) => string[] | undefined; getCommentReactionsByCommentId: (commentId: string) => { [reaction_id: string]: string[] } | undefined;
getCommentReactionById: (reactionId: string) => TIssueCommentReaction | undefined; getCommentReactionById: (reactionId: string) => TIssueCommentReaction | undefined;
commentReactionsByUser: (commentId: string, userId: string) => TIssueCommentReaction[];
} }
export class IssueCommentReactionStore implements IIssueCommentReactionStore { export class IssueCommentReactionStore implements IIssueCommentReactionStore {
@ -52,6 +61,7 @@ export class IssueCommentReactionStore implements IIssueCommentReactionStore {
commentReactionMap: observable, commentReactionMap: observable,
// actions // actions
fetchCommentReactions: action, fetchCommentReactions: action,
applyCommentReactions: action,
createCommentReaction: action, createCommentReaction: action,
removeCommentReaction: action, removeCommentReaction: action,
}); });
@ -72,25 +82,66 @@ export class IssueCommentReactionStore implements IIssueCommentReactionStore {
return this.commentReactionMap[reactionId] ?? undefined; return this.commentReactionMap[reactionId] ?? undefined;
}; };
commentReactionsByUser = (commentId: string, userId: string) => {
if (!commentId || !userId) return [];
const reactions = this.getCommentReactionsByCommentId(commentId);
if (!reactions) return [];
const _userReactions: TIssueCommentReaction[] = [];
Object.keys(reactions).forEach((reaction) => {
reactions[reaction].map((reactionId) => {
const currentReaction = this.getCommentReactionById(reactionId);
if (currentReaction && currentReaction.actor === userId) _userReactions.push(currentReaction);
});
});
return _userReactions;
};
// actions // actions
fetchCommentReactions = async (workspaceSlug: string, projectId: string, commentId: string) => { fetchCommentReactions = async (workspaceSlug: string, projectId: string, commentId: string) => {
try { try {
const reactions = await this.issueReactionService.listIssueCommentReactions(workspaceSlug, projectId, commentId); const response = await this.issueReactionService.listIssueCommentReactions(workspaceSlug, projectId, commentId);
const groupedReactions = groupReactions(response || [], "reaction");
const commentReactionIdsMap: { [reaction: string]: string[] } = {};
Object.keys(groupedReactions).map((reactionId) => {
const reactionIds = (groupedReactions[reactionId] || []).map((reaction) => reaction.id);
commentReactionIdsMap[reactionId] = reactionIds;
});
const reactionIds = reactions.map((reaction) => reaction.id);
runInAction(() => { runInAction(() => {
set(this.commentReactions, commentId, reactionIds); set(this.commentReactions, commentId, commentReactionIdsMap);
reactions.forEach((reaction) => { response.forEach((reaction) => set(this.commentReactionMap, reaction.id, reaction));
set(this.commentReactionMap, reaction.id, reaction);
});
}); });
return reactions; return response;
} catch (error) { } catch (error) {
throw error; throw error;
} }
}; };
applyCommentReactions = (commentId: string, commentReactions: TIssueCommentReaction[]) => {
const groupedReactions = groupReactions(commentReactions || [], "reaction");
const commentReactionIdsMap: { [reaction: string]: string[] } = {};
Object.keys(groupedReactions).map((reactionId) => {
const reactionIds = (groupedReactions[reactionId] || []).map((reaction) => reaction.id);
commentReactionIdsMap[reactionId] = reactionIds;
});
runInAction(() => {
set(this.commentReactions, commentId, commentReactionIdsMap);
commentReactions.forEach((reaction) => set(this.commentReactionMap, reaction.id, reaction));
});
return;
};
createCommentReaction = async (workspaceSlug: string, projectId: string, commentId: string, reaction: string) => { createCommentReaction = async (workspaceSlug: string, projectId: string, commentId: string, reaction: string) => {
try { try {
const response = await this.issueReactionService.createIssueCommentReaction(workspaceSlug, projectId, commentId, { const response = await this.issueReactionService.createIssueCommentReaction(workspaceSlug, projectId, commentId, {
@ -98,7 +149,10 @@ export class IssueCommentReactionStore implements IIssueCommentReactionStore {
}); });
runInAction(() => { runInAction(() => {
this.commentReactions[commentId].push(response.id); update(this.commentReactions, [commentId, reaction], (reactionId) => {
if (!reactionId) return [response.id];
return concat(reactionId, response.id);
});
set(this.commentReactionMap, response.id, response); set(this.commentReactionMap, response.id, response);
}); });
@ -108,14 +162,23 @@ export class IssueCommentReactionStore implements IIssueCommentReactionStore {
} }
}; };
removeCommentReaction = async (workspaceSlug: string, projectId: string, commentId: string, reaction: string) => { removeCommentReaction = async (
workspaceSlug: string,
projectId: string,
commentId: string,
reaction: string,
userId: string
) => {
try { try {
const reactionIndex = this.commentReactions[commentId].findIndex((_reaction) => _reaction === reaction); const userReactions = this.commentReactionsByUser(commentId, userId);
if (reactionIndex >= 0) const currentReaction = find(userReactions, { actor: userId, reaction: reaction });
if (currentReaction && currentReaction.id) {
runInAction(() => { runInAction(() => {
this.commentReactions[commentId].splice(reactionIndex, 1); pull(this.commentReactions[commentId][reaction], currentReaction.id);
delete this.commentReactionMap[reaction]; delete this.commentReactionMap[reaction];
}); });
}
const response = await this.issueReactionService.deleteIssueCommentReaction( const response = await this.issueReactionService.deleteIssueCommentReaction(
workspaceSlug, workspaceSlug,

View File

@ -16,7 +16,7 @@ import {
IIssueCommentReactionStoreActions, IIssueCommentReactionStoreActions,
} from "./comment_reaction.store"; } from "./comment_reaction.store";
import { TIssue, TIssueComment, TIssueLink, TIssueRelationTypes } from "@plane/types"; import { TIssue, TIssueComment, TIssueCommentReaction, TIssueLink, TIssueRelationTypes } from "@plane/types";
export type TPeekIssue = { export type TPeekIssue = {
workspaceSlug: string; workspaceSlug: string;
@ -238,8 +238,15 @@ export class IssueDetail implements IIssueDetail {
// comment reaction // comment reaction
fetchCommentReactions = async (workspaceSlug: string, projectId: string, commentId: string) => fetchCommentReactions = async (workspaceSlug: string, projectId: string, commentId: string) =>
this.commentReaction.fetchCommentReactions(workspaceSlug, projectId, commentId); this.commentReaction.fetchCommentReactions(workspaceSlug, projectId, commentId);
applyCommentReactions = async (commentId: string, commentReactions: TIssueCommentReaction[]) =>
this.commentReaction.applyCommentReactions(commentId, commentReactions);
createCommentReaction = async (workspaceSlug: string, projectId: string, commentId: string, reaction: string) => createCommentReaction = async (workspaceSlug: string, projectId: string, commentId: string, reaction: string) =>
this.commentReaction.createCommentReaction(workspaceSlug, projectId, commentId, reaction); this.commentReaction.createCommentReaction(workspaceSlug, projectId, commentId, reaction);
removeCommentReaction = async (workspaceSlug: string, projectId: string, commentId: string, reaction: string) => removeCommentReaction = async (
this.commentReaction.removeCommentReaction(workspaceSlug, projectId, commentId, reaction); workspaceSlug: string,
projectId: string,
commentId: string,
reaction: string,
userId: string
) => this.commentReaction.removeCommentReaction(workspaceSlug, projectId, commentId, reaction, userId);
} }