feat: issue & comment reaction (#1690)

* feat: developed reaction selector component

* feat add reaction on issue & issue comment

refactor: reaction selector component, made hooks to abstracted reaction logic & state by making custom hook

* fix: emoji.helper.tsx function

* refactor: reaction not working on inbox issue
This commit is contained in:
Dakshesh Jain 2023-07-27 18:55:03 +05:30 committed by GitHub
parent 5cfea3948f
commit 0cc4468091
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 686 additions and 0 deletions

View File

@ -4,4 +4,5 @@ export * from "./sidebar";
export * from "./theme";
export * from "./views";
export * from "./feeds";
export * from "./reaction-selector";
export * from "./image-picker-popover";

View File

@ -0,0 +1,85 @@
import { Fragment } from "react";
// headless ui
import { Popover, Transition } from "@headlessui/react";
// helper
import { renderEmoji } from "helpers/emoji.helper";
// icons
import { Icon } from "components/ui";
const reactionEmojis = [
"128077",
"128078",
"128516",
"128165",
"128533",
"129505",
"9992",
"128064",
];
interface Props {
size?: "sm" | "md" | "lg";
position?: "top" | "bottom";
value?: string | string[] | null;
onSelect: (emoji: string) => void;
}
export const ReactionSelector: React.FC<Props> = (props) => {
const { value, onSelect, position, size } = props;
return (
<Popover className="relative">
{({ open, close: closePopover }) => (
<>
<Popover.Button
className={`${
open ? "" : "text-opacity-90"
} group inline-flex items-center rounded-md bg-custom-background-80 focus:outline-none`}
>
<span
className={`flex justify-center items-center rounded-md ${
size === "sm" ? "w-6 h-6" : size === "md" ? "w-8 h-8" : "w-10 h-10"
}`}
>
<Icon iconName="add_reaction" className="text-custom-text-100 scale-125" />
</span>
</Popover.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel
className={`absolute -left-2 z-10 ${position === "top" ? "-top-12" : "-bottom-12"}`}
>
<div className="bg-custom-background-0 border rounded-md px-2 py-1.5">
<div className="flex gap-x-2">
{reactionEmojis.map((emoji) => (
<button
key={emoji}
type="button"
onClick={() => {
onSelect(emoji);
closePopover();
}}
className="flex h-5 w-5 select-none items-center justify-between text-sm"
>
{renderEmoji(emoji)}
</button>
))}
</div>
</div>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
);
};

View File

@ -19,6 +19,7 @@ import {
IssueActivitySection,
IssueDescriptionForm,
IssueDetailsSidebar,
IssueReaction,
} from "components/issues";
// ui
import { Loader } from "components/ui";
@ -303,6 +304,13 @@ export const InboxMainContent: React.FC = () => {
}
/>
</div>
<IssueReaction
projectId={projectId}
workspaceSlug={workspaceSlug}
issueId={issueDetails.id}
/>
<div className="space-y-5">
<h3 className="text-lg text-custom-text-100">Comments/Activity</h3>
<IssueActivitySection issueId={issueDetails.id} user={user} />

View File

@ -10,6 +10,7 @@ import { ChatBubbleLeftEllipsisIcon, CheckIcon, XMarkIcon } from "@heroicons/rea
import useUser from "hooks/use-user";
// ui
import { CustomMenu } from "components/ui";
import { CommentReaction } from "components/issues";
// helpers
import { timeAgo } from "helpers/date-time.helper";
// types
@ -138,6 +139,12 @@ export const CommentCard: React.FC<Props> = ({ comment, onSubmit, handleCommentD
customClassName="text-xs border border-custom-border-200 bg-custom-background-100"
ref={showEditorRef}
/>
<CommentReaction
workspaceSlug={comment?.workspace_detail?.slug}
projectId={comment.project}
commentId={comment.id}
/>
</div>
</div>
</div>

View File

@ -0,0 +1,80 @@
import React from "react";
// hooks
import useUser from "hooks/use-user";
import useCommentReaction from "hooks/use-comment-reaction";
// ui
import { ReactionSelector } from "components/core";
// helper
import { renderEmoji } from "helpers/emoji.helper";
type Props = {
workspaceSlug?: string | string[];
projectId?: string | string[];
commentId: string;
};
export const CommentReaction: React.FC<Props> = (props) => {
const { workspaceSlug, projectId, commentId } = props;
const { user } = useUser();
const {
commentReactions,
groupedReactions,
handleReactionCreate,
handleReactionDelete,
isLoading,
} = useCommentReaction(workspaceSlug, projectId, commentId);
const handleReactionClick = (reaction: string) => {
if (!workspaceSlug || !projectId || !commentId) return;
const isSelected = commentReactions?.some(
(r) => r.actor === user?.id && r.reaction === reaction
);
if (isSelected) {
handleReactionDelete(reaction);
} else {
handleReactionCreate(reaction);
}
};
return (
<div className="flex gap-1.5 items-center mt-2">
<ReactionSelector
size="md"
position="top"
value={
commentReactions
?.filter((reaction) => reaction.actor === user?.id)
.map((r) => r.reaction) || []
}
onSelect={handleReactionClick}
/>
{Object.keys(groupedReactions || {}).map(
(reaction) =>
groupedReactions?.[reaction]?.length &&
groupedReactions[reaction].length > 0 && (
<button
type="button"
onClick={() => {
handleReactionClick(reaction);
}}
key={reaction}
className={`flex items-center gap-1 text-custom-text-100 h-full px-2 py-1 rounded-md ${
commentReactions?.some((r) => r.actor === user?.id && r.reaction === reaction)
? "bg-custom-primary-100/10"
: "bg-custom-background-80"
}`}
>
<span>{groupedReactions?.[reaction].length} </span>
<span>{renderEmoji(reaction)}</span>
</button>
)
)}
</div>
);
};

View File

@ -1,2 +1,3 @@
export * from "./add-comment";
export * from "./comment-card";
export * from "./comment-reaction";

View File

@ -14,3 +14,4 @@ export * from "./parent-issues-list-modal";
export * from "./sidebar";
export * from "./sub-issues-list";
export * from "./label";
export * from "./issue-reaction";

View File

@ -0,0 +1,70 @@
// hooks
import useUserAuth from "hooks/use-user-auth";
import useIssueReaction from "hooks/use-issue-reaction";
// components
import { ReactionSelector } from "components/core";
// string helpers
import { renderEmoji } from "helpers/emoji.helper";
// types
type Props = {
workspaceSlug?: string | string[];
projectId?: string | string[];
issueId?: string | string[];
};
export const IssueReaction: React.FC<Props> = (props) => {
const { workspaceSlug, projectId, issueId } = props;
const { user } = useUserAuth();
const { reactions, groupedReactions, handleReactionCreate, handleReactionDelete } =
useIssueReaction(workspaceSlug, projectId, issueId);
const handleReactionClick = (reaction: string) => {
if (!workspaceSlug || !projectId || !issueId) return;
const isSelected = reactions?.some((r) => r.actor === user?.id && r.reaction === reaction);
if (isSelected) {
handleReactionDelete(reaction);
} else {
handleReactionCreate(reaction);
}
};
return (
<div className="flex gap-1.5 items-center mt-4">
<ReactionSelector
size="md"
position="top"
value={
reactions?.filter((reaction) => reaction.actor === user?.id).map((r) => r.reaction) || []
}
onSelect={handleReactionClick}
/>
{Object.keys(groupedReactions || {}).map(
(reaction) =>
groupedReactions?.[reaction]?.length &&
groupedReactions[reaction].length > 0 && (
<button
type="button"
onClick={() => {
handleReactionClick(reaction);
}}
key={reaction}
className={`flex items-center gap-1 text-custom-text-100 h-full px-2 py-1 rounded-md ${
reactions?.some((r) => r.actor === user?.id && r.reaction === reaction)
? "bg-custom-primary-100/10"
: "bg-custom-background-80"
}`}
>
<span>{groupedReactions?.[reaction].length} </span>
<span>{renderEmoji(reaction)}</span>
</button>
)
)}
</div>
);
};

View File

@ -17,6 +17,7 @@ import {
IssueAttachments,
IssueDescriptionForm,
SubIssuesList,
IssueReaction,
} from "components/issues";
// ui
import { CustomMenu } from "components/ui";
@ -117,6 +118,9 @@ export const IssueMainContent: React.FC<Props> = ({
handleFormSubmit={submitChanges}
isAllowed={memberRole.isMember || memberRole.isOwner || !uneditable}
/>
<IssueReaction workspaceSlug={workspaceSlug} issueId={issueId} projectId={projectId} />
<div className="mt-2 space-y-2">
<SubIssuesList parentIssue={issueDetails} user={user} disabled={uneditable} />
</div>

View File

@ -301,3 +301,13 @@ export const getPaginatedNotificationKey = (
cursor,
})}`;
};
export const ISSUE_REACTION_LIST = (workspaceSlug: string, projectId: string, issueId: string) =>
`ISSUE_REACTION_LIST_${workspaceSlug.toUpperCase()}_${projectId.toUpperCase()}_${issueId.toUpperCase()}`;
export const COMMENT_REACTION_LIST = (
workspaceSlug: string,
projectId: string,
commendId: string
) =>
`COMMENT_REACTION_LIST_${workspaceSlug.toUpperCase()}_${projectId.toUpperCase()}_${commendId.toUpperCase()}`;

View File

@ -36,3 +36,18 @@ export const renderEmoji = (
);
else return isNaN(parseInt(emoji)) ? emoji : String.fromCodePoint(parseInt(emoji));
};
export const groupReactions: (reactions: any[], key: string) => { [key: string]: any[] } = (
reactions: any,
key: string
) => {
const groupedReactions = reactions.reduce((acc: any, reaction: any) => {
if (!acc[reaction[key]]) {
acc[reaction[key]] = [];
}
acc[reaction[key]].push(reaction);
return acc;
}, {} as { [key: string]: any[] });
return groupedReactions;
};

View File

@ -0,0 +1,95 @@
import useSWR from "swr";
// fetch keys
import { COMMENT_REACTION_LIST } from "constants/fetch-keys";
// services
import reactionService from "services/reaction.service";
// helpers
import { groupReactions } from "helpers/emoji.helper";
// hooks
import useUser from "./use-user";
const useCommentReaction = (
workspaceSlug?: string | string[] | null,
projectId?: string | string[] | null,
commendId?: string | string[] | null
) => {
const {
data: commentReactions,
mutate: mutateCommentReactions,
error,
} = useSWR(
workspaceSlug && projectId && commendId
? COMMENT_REACTION_LIST(workspaceSlug.toString(), projectId.toString(), commendId.toString())
: null,
workspaceSlug && projectId && commendId
? () =>
reactionService.listIssueCommentReactions(
workspaceSlug.toString(),
projectId.toString(),
commendId.toString()
)
: null
);
const user = useUser();
const groupedReactions = groupReactions(commentReactions || [], "reaction");
/**
* @description Use this function to create user's reaction to an issue. This function will mutate the reactions state.
* @param {string} reaction
* @example handleReactionDelete("123") // 123 -> is emoji hexa-code
*/
const handleReactionCreate = async (reaction: string) => {
if (!workspaceSlug || !projectId || !commendId) return;
const data = await reactionService.createIssueCommentReaction(
workspaceSlug.toString(),
projectId.toString(),
commendId.toString(),
{ reaction }
);
mutateCommentReactions((prev) => [...(prev || []), data]);
};
/**
* @description Use this function to delete user's reaction from an issue. This function will mutate the reactions state.
* @param {string} reaction
* @example handleReactionDelete("123") // 123 -> is emoji hexa-code
*/
const handleReactionDelete = async (reaction: string) => {
if (!workspaceSlug || !projectId || !commendId) return;
mutateCommentReactions(
(prevData) =>
prevData?.filter((r) => r.actor !== user?.user?.id || r.reaction !== reaction) || []
);
await reactionService.deleteIssueCommentReaction(
workspaceSlug.toString(),
projectId.toString(),
commendId.toString(),
reaction
);
mutateCommentReactions();
};
return {
isLoading: !commentReactions && !error,
commentReactions,
groupedReactions,
handleReactionCreate,
handleReactionDelete,
mutateCommentReactions,
} as const;
};
export default useCommentReaction;

View File

@ -0,0 +1,96 @@
import useSWR from "swr";
// fetch keys
import { ISSUE_REACTION_LIST } from "constants/fetch-keys";
// helpers
import { groupReactions } from "helpers/emoji.helper";
// services
import reactionService from "services/reaction.service";
// hooks
import useUser from "./use-user";
const useIssueReaction = (
workspaceSlug?: string | string[] | null,
projectId?: string | string[] | null,
issueId?: string | string[] | null
) => {
const user = useUser();
const {
data: reactions,
mutate: mutateReaction,
error,
} = useSWR(
workspaceSlug && projectId && issueId
? ISSUE_REACTION_LIST(workspaceSlug.toString(), projectId.toString(), issueId.toString())
: null,
workspaceSlug && projectId && issueId
? () =>
reactionService.listIssueReactions(
workspaceSlug.toString(),
projectId.toString(),
issueId.toString()
)
: null
);
const groupedReactions = groupReactions(reactions || [], "reaction");
/**
* @description Use this function to create user's reaction to an issue. This function will mutate the reactions state.
* @param {string} reaction
* @example handleReactionCreate("128077") // hexa-code of the emoji
*/
const handleReactionCreate = async (reaction: string) => {
if (!workspaceSlug || !projectId || !issueId) return;
const data = await reactionService.createIssueReaction(
workspaceSlug.toString(),
projectId.toString(),
issueId.toString(),
{ reaction }
);
mutateReaction((prev) => [...(prev || []), data]);
};
/**
* @description Use this function to delete user's reaction from an issue. This function will mutate the reactions state.
* @param {string} reaction
* @example handleReactionDelete("123") // 123 -> is emoji hexa-code
*/
const handleReactionDelete = async (reaction: string) => {
if (!workspaceSlug || !projectId || !issueId) return;
mutateReaction(
(prevData) =>
prevData?.filter((r) => r.actor !== user?.user?.id || r.reaction !== reaction) || [],
false
);
await reactionService.deleteIssueReaction(
workspaceSlug.toString(),
projectId.toString(),
issueId.toString(),
reaction
);
mutateReaction();
};
return {
isLoading: !reactions && !error,
reactions,
groupedReactions,
handleReactionCreate,
handleReactionDelete,
mutateReaction,
} as const;
};
export default useIssueReaction;

View File

@ -0,0 +1,145 @@
// services
import APIService from "services/api.service";
import trackEventServices from "services/track-event.service";
// types
import type {
ICurrentUserResponse,
IssueReaction,
IssueCommentReaction,
IssueReactionForm,
IssueCommentReactionForm,
} from "types";
const { NEXT_PUBLIC_API_BASE_URL } = process.env;
const trackEvent =
process.env.NEXT_PUBLIC_TRACK_EVENTS === "true" || process.env.NEXT_PUBLIC_TRACK_EVENTS === "1";
class ReactionService extends APIService {
constructor() {
super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000");
}
async createIssueReaction(
workspaceSlug: string,
projectId: string,
issueId: string,
data: IssueReactionForm,
user?: ICurrentUserResponse
): Promise<any> {
return this.post(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/reactions/`,
data
)
.then((response) => {
if (trackEvent)
trackEventServices.trackReactionEvent(response?.data, "ISSUE_REACTION_CREATE", user);
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async listIssueReactions(
workspaceSlug: string,
projectId: string,
issueId: string
): Promise<IssueReaction[]> {
return this.get(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/reactions/`
)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async deleteIssueReaction(
workspaceSlug: string,
projectId: string,
issueId: string,
reaction: string,
user?: ICurrentUserResponse
): Promise<any> {
return this.delete(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/reactions/${reaction}/`
)
.then((response) => {
if (trackEvent)
trackEventServices.trackReactionEvent(response?.data, "ISSUE_REACTION_DELETE", user);
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async createIssueCommentReaction(
workspaceSlug: string,
projectId: string,
commentId: string,
data: IssueCommentReactionForm,
user?: ICurrentUserResponse
): Promise<any> {
return this.post(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/comments/${commentId}/reactions/`,
data
)
.then((response) => {
if (trackEvent)
trackEventServices.trackReactionEvent(
response?.data,
"ISSUE_COMMENT_REACTION_CREATE",
user
);
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async listIssueCommentReactions(
workspaceSlug: string,
projectId: string,
commentId: string
): Promise<IssueCommentReaction[]> {
return this.get(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/comments/${commentId}/reactions/`
)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async deleteIssueCommentReaction(
workspaceSlug: string,
projectId: string,
commentId: string,
reaction: string,
user?: ICurrentUserResponse
): Promise<any> {
return this.delete(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/comments/${commentId}/reactions/${reaction}/`
)
.then((response) => {
if (trackEvent)
trackEventServices.trackReactionEvent(
response?.data,
"ISSUE_COMMENT_REACTION_DELETE",
user
);
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
}
const reactionService = new ReactionService();
export default reactionService;

View File

@ -19,6 +19,8 @@ import type {
IState,
IView,
IWorkspace,
IssueCommentReaction,
IssueReaction,
} from "types";
type WorkspaceEventType =
@ -110,6 +112,12 @@ type AnalyticsEventType =
| "MODULE_CUSTOM_ANALYTICS"
| "MODULE_ANALYTICS_EXPORT";
type ReactionEventType =
| "ISSUE_REACTION_CREATE"
| "ISSUE_COMMENT_REACTION_CREATE"
| "ISSUE_REACTION_DELETE"
| "ISSUE_COMMENT_REACTION_DELETE";
class TrackEventServices extends APIService {
constructor() {
super("/");
@ -799,6 +807,32 @@ class TrackEventServices extends APIService {
},
});
}
async trackReactionEvent(
data: IssueReaction | IssueCommentReaction,
eventName: ReactionEventType,
user: ICurrentUserResponse | undefined
): Promise<any> {
let payload: any;
if (eventName === "ISSUE_REACTION_DELETE" || eventName === "ISSUE_COMMENT_REACTION_DELETE")
payload = data;
else
payload = {
workspaceId: data?.workspace,
projectId: data?.project,
reaction: data?.reaction,
};
return this.request({
url: "/api/track-event",
method: "POST",
data: {
eventName,
extra: payload,
user: user,
},
});
}
}
const trackEventServices = new TrackEventServices();

View File

@ -17,6 +17,7 @@ export * from "./analytics";
export * from "./calendar";
export * from "./notifications";
export * from "./waitlist";
export * from "./reaction";
export type NestedKeyOf<ObjectType extends object> = {
[Key in keyof ObjectType & (string | number)]: ObjectType[Key] extends object

33
apps/app/types/reaction.d.ts vendored Normal file
View File

@ -0,0 +1,33 @@
export interface IssueReaction {
id: string;
created_at: Date;
updated_at: Date;
reaction: string;
created_by: string;
updated_by: string;
project: string;
workspace: string;
actor: string;
issue: string;
}
export interface IssueReactionForm {
reaction: string;
}
export interface IssueCommentReaction {
id: string;
created_at: Date;
updated_at: Date;
reaction: string;
created_by: string;
updated_by: string;
project: string;
workspace: string;
actor: string;
comment: string;
}
export interface IssueCommentReactionForm {
reaction: string;
}