chore: impleted store for issue reaction, comments, and comment reactions. implemented ui and compoennts for issue reactions in issue peek overview (#2498)

This commit is contained in:
guru_sainath 2023-10-20 12:33:39 +05:30 committed by GitHub
parent 2dd46be287
commit 9bddd2eb67
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 549 additions and 44 deletions

View File

@ -1,6 +1,8 @@
import { FC } from "react";
// packages
import { RichTextEditor } from "@plane/rich-text-editor";
// components
import { IssueReaction } from "./reactions";
// hooks
import { useDebouncedCallback } from "use-debounce";
// types
@ -13,33 +15,46 @@ const fileService = new FileService();
interface IPeekOverviewIssueDetails {
workspaceSlug: string;
issue: IIssue;
issueReactions: any;
user: any;
issueUpdate: (issue: Partial<IIssue>) => void;
issueReactionCreate: (reaction: string) => void;
issueReactionRemove: (reaction: string) => void;
}
export const PeekOverviewIssueDetails: FC<IPeekOverviewIssueDetails> = (props) => {
const { workspaceSlug, issue, issueUpdate } = props;
const { workspaceSlug, issue, issueReactions, user, issueUpdate, issueReactionCreate, issueReactionRemove } = props;
const debouncedIssueDescription = useDebouncedCallback(async (_data: any) => {
issueUpdate({ ...issue, description_html: _data });
}, 1500);
return (
<div className="space-y-2">
<div className="space-y-3">
<div className="font-medium text-sm text-custom-text-200">
{issue?.project_detail?.identifier}-{issue?.sequence_id}
</div>
<div className="font-medium text-xl">{issue?.name}</div>
<RichTextEditor
uploadFile={fileService.getUploadFileFunction(workspaceSlug)}
deleteFile={fileService.deleteImage}
value={issue?.description_html}
debouncedUpdatesEnabled={false}
onChange={(description: Object, description_html: string) => {
debouncedIssueDescription(description_html);
}}
/>
<div className="space-y-2">
<RichTextEditor
uploadFile={fileService.getUploadFileFunction(workspaceSlug)}
deleteFile={fileService.deleteImage}
value={issue?.description_html}
debouncedUpdatesEnabled={false}
onChange={(description: Object, description_html: string) => {
debouncedIssueDescription(description_html);
}}
/>
<IssueReaction
issueReactions={issueReactions}
user={user}
issueReactionCreate={issueReactionCreate}
issueReactionRemove={issueReactionRemove}
/>
</div>
</div>
);
};

View File

@ -0,0 +1,3 @@
export * from "./root";
export * from "./selector";
export * from "./preview";

View File

@ -0,0 +1,48 @@
import { FC } from "react";
// helpers
import { renderEmoji } from "helpers/emoji.helper";
interface IIssueReactionPreview {
issueReactions: any;
user: any;
handleReaction: (reaction: string) => void;
}
export const IssueReactionPreview: FC<IIssueReactionPreview> = (props) => {
const { issueReactions, user, handleReaction } = props;
const isUserReacted = (reactions: any) => {
const userReaction = reactions?.find((reaction: any) => reaction.actor === user?.id);
if (userReaction) return true;
return false;
};
return (
<div className="flex items-center gap-2">
{Object.keys(issueReactions || {}).map(
(reaction) =>
issueReactions[reaction]?.length > 0 && (
<button
type="button"
onClick={() => handleReaction(reaction)}
key={reaction}
className={`flex items-center gap-1.5 text-custom-text-100 text-sm h-full px-2 py-1 rounded-md ${
isUserReacted(issueReactions[reaction])
? `bg-custom-primary-100/20 hover:bg-custom-primary-100/30`
: `bg-custom-background-90 hover:bg-custom-background-100/30`
}`}
>
<span>{renderEmoji(reaction)}</span>
<span
className={`${
isUserReacted(issueReactions[reaction]) ? `text-custom-primary-100 hover:text-custom-primary-200` : ``
}`}
>
{issueReactions[reaction].length}
</span>
</button>
)
)}
</div>
);
};

View File

@ -0,0 +1,29 @@
import { FC } from "react";
// components
import { IssueReactionPreview, IssueReactionSelector } from "./";
interface IIssueReaction {
issueReactions: any;
user: any;
issueReactionCreate: (reaction: string) => void;
issueReactionRemove: (reaction: string) => void;
}
export const IssueReaction: FC<IIssueReaction> = (props) => {
const { issueReactions, user, issueReactionCreate, issueReactionRemove } = props;
const handleReaction = (reaction: string) => {
const isReactionAvailable =
issueReactions[reaction].find((_reaction: any) => _reaction.actor === user?.id) ?? false;
if (isReactionAvailable) issueReactionRemove(reaction);
else issueReactionCreate(reaction);
};
return (
<div className="relative flex items-center flex-wrap gap-2">
<IssueReactionSelector onSelect={handleReaction} position="bottom" />
<IssueReactionPreview issueReactions={issueReactions} user={user} handleReaction={handleReaction} />
</div>
);
};

View File

@ -0,0 +1,73 @@
import { FC, Fragment } from "react";
import { Popover, Transition } from "@headlessui/react";
// helper
import { renderEmoji } from "helpers/emoji.helper";
// icons
import { SmilePlus } from "lucide-react";
// constants
import { issueReactionEmojis } from "constants/issue";
interface IIssueReactionSelector {
size?: "sm" | "md" | "lg";
position?: "top" | "bottom";
onSelect: (reaction: any) => void;
}
export const IssueReactionSelector: FC<IIssueReactionSelector> = (props) => {
const { size = "md", position = "top", onSelect } = props;
return (
<>
<Popover className="relative">
{({ open, close: closePopover }) => (
<>
<Popover.Button
className={`${
open ? "" : "bg-custom-background-90"
} group inline-flex items-center rounded-md bg-custom-background-90 focus:outline-none transition-all hover:bg-custom-background-100`}
>
<span
className={`flex justify-center items-center rounded-md px-2 ${
size === "sm" ? "w-6 h-6" : size === "md" ? "w-7 h-7" : "w-8 h-8"
}`}
>
<SmilePlus className="text-custom-text-100 h-3.5 w-3.5" />
</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={`bg-custom-sidebar-background-100 border border-custom-border-200 shadow-custom-shadow-sm rounded p-1 overflow-hidden absolute -left-2 z-10 ${
position === "top" ? "-top-10" : "-bottom-10"
}`}
>
<div className="flex gap-x-1">
{issueReactionEmojis.map((emoji) => (
<button
key={emoji}
type="button"
onClick={() => {
if (onSelect) onSelect(emoji);
closePopover();
}}
className="select-none rounded text-sm p-1 hover:bg-custom-sidebar-background-80 transition-all w-6 h-6 flex justify-center items-center"
>
<div className="w-4 h-4">{renderEmoji(emoji)}</div>
</button>
))}
</div>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
</>
);
};

View File

@ -34,6 +34,14 @@ export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
}
};
const issueReactionCreate = (data: string) => {
issueDetailStore.createIssueReaction(workspaceSlug, projectId, issueId, data);
};
const issueReactionRemove = (data: string) => {
issueDetailStore.removeIssueReaction(workspaceSlug, projectId, issueId, data);
};
return (
<IssueView
workspaceSlug={workspaceSlug}
@ -43,6 +51,8 @@ export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
members={members}
priorities={priorities}
issueUpdate={issueUpdate}
issueReactionCreate={issueReactionCreate}
issueReactionRemove={issueReactionRemove}
>
{children}
</IssueView>

View File

@ -2,6 +2,7 @@ import { FC, ReactNode, useEffect, useState } from "react";
import { useRouter } from "next/router";
import { Maximize2, ArrowRight, Link, Trash, PanelRightOpen, Square, SquareCode } from "lucide-react";
import { observer } from "mobx-react-lite";
import useSWR from "swr";
// components
import { PeekOverviewIssueDetails } from "./issue-detail";
import { PeekOverviewProperties } from "./properties";
@ -16,6 +17,8 @@ interface IIssueView {
projectId: string;
issueId: string;
issueUpdate: (issue: Partial<IIssue>) => void;
issueReactionCreate: (reaction: string) => void;
issueReactionRemove: (reaction: string) => void;
states: any;
members: any;
priorities: any;
@ -43,12 +46,23 @@ const peekOptions: { key: TPeekModes; icon: any; title: string }[] = [
];
export const IssueView: FC<IIssueView> = observer((props) => {
const { workspaceSlug, projectId, issueId, issueUpdate, states, members, priorities, children } = props;
const {
workspaceSlug,
projectId,
issueId,
issueUpdate,
issueReactionCreate,
issueReactionRemove,
states,
members,
priorities,
children,
} = props;
const router = useRouter();
const { peekIssueId } = router.query as { peekIssueId: string };
const { issueDetail: issueDetailStore }: RootStore = useMobxStore();
const { user: userStore, issueDetail: issueDetailStore }: RootStore = useMobxStore();
const [peekMode, setPeekMode] = useState<TPeekModes>("side-peek");
const handlePeekMode = (_peek: TPeekModes) => {
@ -81,12 +95,20 @@ export const IssueView: FC<IIssueView> = observer((props) => {
});
};
useEffect(() => {
if (workspaceSlug && projectId && issueId && peekIssueId && issueId === peekIssueId)
issueDetailStore.fetchIssueDetails(workspaceSlug, projectId, issueId);
}, [workspaceSlug, projectId, issueId, peekIssueId, issueDetailStore]);
useSWR(
workspaceSlug && projectId && issueId && peekIssueId && issueId === peekIssueId
? `ISSUE_PEEK_OVERVIEW_${workspaceSlug}_${projectId}_${peekIssueId}`
: null,
async () => {
if (workspaceSlug && projectId && issueId && peekIssueId && issueId === peekIssueId) {
await issueDetailStore.fetchPeekIssueDetails(workspaceSlug, projectId, issueId);
}
}
);
const issue = issueDetailStore.getIssue;
const issueReactions = issueDetailStore.getIssueReactions;
const user = userStore?.currentUser;
return (
<div className="w-full !text-base">
@ -96,14 +118,14 @@ export const IssueView: FC<IIssueView> = observer((props) => {
{issueId === peekIssueId && (
<div
className={`fixed z-50 overflow-hidden bg-custom-background-80 flex flex-col transition-all duration-200 border border-custom-border-200 rounded shadow-custom-shadow-2xl
className={`fixed z-50 overflow-hidden bg-custom-background-80 flex flex-col transition-all duration-300 border border-custom-border-200 rounded shadow-custom-shadow-2xl
${peekMode === "side-peek" ? `w-full md:w-[50%] top-0 right-0 bottom-0` : ``}
${peekMode === "modal" ? `top-[50%] left-[50%] -translate-x-[50%] -translate-y-[50%] w-5/6 h-5/6` : ``}
${peekMode === "full-screen" ? `top-0 right-0 bottom-0 left-0 m-4` : ``}
`}
>
{/* header */}
<div className="flex-shrink-0 w-full p-3 py-2.5 relative flex items-center gap-2 border-b border-custom-border-200">
<div className="flex-shrink-0 w-full p-4 py-3 relative flex items-center gap-2 border-b border-custom-border-200">
<div
className="flex-shrink-0 overflow-hidden w-6 h-6 flex justify-center items-center rounded-sm transition-all duration-100 border border-custom-border-200 cursor-pointer hover:bg-custom-background-100"
onClick={removeRoutePeekId}
@ -156,10 +178,16 @@ export const IssueView: FC<IIssueView> = observer((props) => {
issue && (
<>
{["side-peek", "modal"].includes(peekMode) ? (
<div className="space-y-8 p-3 py-5">
<PeekOverviewIssueDetails workspaceSlug={workspaceSlug} issue={issue} issueUpdate={issueUpdate} />
{/* reactions */}
<div className="space-y-8 p-4 py-5">
<PeekOverviewIssueDetails
workspaceSlug={workspaceSlug}
issue={issue}
issueUpdate={issueUpdate}
issueReactions={issueReactions}
user={user}
issueReactionCreate={issueReactionCreate}
issueReactionRemove={issueReactionRemove}
/>
<PeekOverviewProperties
issue={issue}
@ -169,22 +197,24 @@ export const IssueView: FC<IIssueView> = observer((props) => {
priorities={priorities}
/>
{/* activity */}
{/* <div className="border border-red-500">Activity</div> */}
</div>
) : (
<div className="w-full h-full flex">
<div className="w-full h-full space-y-8 p-3 py-5">
<div className="w-full h-full space-y-8 p-4 py-5">
<PeekOverviewIssueDetails
workspaceSlug={workspaceSlug}
issue={issue}
issueReactions={issueReactions}
issueUpdate={issueUpdate}
user={user}
issueReactionCreate={issueReactionCreate}
issueReactionRemove={issueReactionRemove}
/>
{/* reactions */}
{/* activity */}
{/* <div className="border border-red-500">Activity</div> */}
</div>
<div className="flex-shrink-0 !w-[400px] h-full border-l border-custom-border-200 p-3 py-5">
<div className="flex-shrink-0 !w-[400px] h-full border-l border-custom-border-200 p-4 py-5">
<PeekOverviewProperties
issue={issue}
issueUpdate={issueUpdate}

View File

@ -393,3 +393,25 @@ export const getValueFromObject = (object: Object, key: string): string | number
for (const _key of keys) value = value[_key];
return value;
};
// issue reactions
export const issueReactionEmojis = ["128077", "128078", "128516", "128165", "128533", "129505", "9992", "128064"];
export const groupReactionEmojis = (reactions: any) => {
let _groupedEmojis: any = {};
issueReactionEmojis.map((_r) => {
_groupedEmojis = { ..._groupedEmojis, [_r]: [] };
});
if (reactions && reactions.length > 0) {
reactions.map((_reaction: any) => {
_groupedEmojis = {
..._groupedEmojis,
[_reaction.reaction]: [..._groupedEmojis[_reaction.reaction], _reaction],
};
});
}
return _groupedEmojis;
};

View File

@ -1,31 +1,40 @@
import { observable, action, makeObservable, runInAction, computed } from "mobx";
// services
import { IssueService } from "services/issue";
import { IssueService, IssueReactionService } from "services/issue";
// types
import { RootStore } from "../root";
import { IUser, IIssue } from "types";
export type IPeekMode = "side" | "modal" | "full";
// constants
import { groupReactionEmojis } from "constants/issue";
export interface IIssueDetailStore {
loader: boolean;
error: any | null;
peekId: string | null;
peekMode: IPeekMode | null;
issues: {
[issueId: string]: IIssue;
};
issue_reactions: {
[issueId: string]: any;
};
issue_comments: {
[issueId: string]: any;
};
issue_comment_reactions: {
[issueId: string]: any;
};
setPeekId: (issueId: string | null) => void;
setPeekMode: (issueId: IPeekMode | null) => void;
// computed
getIssue: IIssue | null;
getIssueReactions: any | null;
getIssueComments: any | null;
getIssueCommentReactions: any | null;
// fetch issue details
fetchIssueDetails: (workspaceSlug: string, projectId: string, issueId: string) => void;
fetchIssueDetails: (workspaceSlug: string, projectId: string, issueId: string) => Promise<IIssue>;
// creating issue
createIssue: (workspaceSlug: string, projectId: string, data: Partial<IIssue>, user: IUser) => Promise<IIssue>;
// updating issue
@ -38,6 +47,44 @@ export interface IIssueDetailStore {
) => void;
// deleting issue
deleteIssue: (workspaceSlug: string, projectId: string, issueId: string, user: IUser) => void;
fetchPeekIssueDetails: (workspaceSlug: string, projectId: string, issueId: string) => Promise<IIssue>;
fetchIssueReactions: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
createIssueReaction: (workspaceSlug: string, projectId: string, issueId: string, reaction: string) => Promise<void>;
removeIssueReaction: (workspaceSlug: string, projectId: string, issueId: string, reaction: string) => Promise<void>;
fetchIssueComments: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
createIssueComment: (workspaceSlug: string, projectId: string, issueId: string, data: any) => Promise<void>;
updateIssueComment: (
workspaceSlug: string,
projectId: string,
issueId: string,
commentId: string,
data: any
) => Promise<void>;
removeIssueComment: (workspaceSlug: string, projectId: string, issueId: string, commentId: string) => Promise<void>;
fetchIssueCommentReactions: (
workspaceSlug: string,
projectId: string,
issueId: string,
commentId: string
) => Promise<void>;
addIssueCommentReaction: (
workspaceSlug: string,
projectId: string,
issueId: string,
commentId: string,
reaction: string
) => Promise<void>;
removeIssueCommentReaction: (
workspaceSlug: string,
projectId: string,
issueId: string,
commentId: string,
reaction: string
) => Promise<void>;
}
export class IssueDetailStore implements IIssueDetailStore {
@ -45,16 +92,24 @@ export class IssueDetailStore implements IIssueDetailStore {
error: any | null = null;
peekId: string | null = null;
peekMode: IPeekMode | null = null;
issues: {
[issueId: string]: IIssue;
} = {};
issue_reactions: {
[issueId: string]: any;
} = {};
issue_comments: {
[issueId: string]: any;
} = {};
issue_comment_reactions: {
[issueId: string]: any;
} = {};
// root store
rootStore;
// service
issueService;
issueReactionService;
constructor(_rootStore: RootStore) {
makeObservable(this, {
@ -63,23 +118,42 @@ export class IssueDetailStore implements IIssueDetailStore {
error: observable.ref,
peekId: observable.ref,
peekMode: observable.ref,
issues: observable.ref,
issue_reactions: observable.ref,
issue_comments: observable.ref,
issue_comment_reactions: observable.ref,
getIssue: computed,
getIssueReactions: computed,
getIssueComments: computed,
getIssueCommentReactions: computed,
setPeekId: action,
setPeekMode: action,
fetchIssueDetails: action,
createIssue: action,
updateIssue: action,
deleteIssue: action,
fetchPeekIssueDetails: action,
fetchIssueReactions: action,
createIssueReaction: action,
removeIssueReaction: action,
fetchIssueComments: action,
createIssueComment: action,
updateIssueComment: action,
removeIssueComment: action,
fetchIssueCommentReactions: action,
addIssueCommentReaction: action,
removeIssueCommentReaction: action,
});
this.rootStore = _rootStore;
this.issueService = new IssueService();
this.issueReactionService = new IssueReactionService();
}
get getIssue() {
@ -88,9 +162,25 @@ export class IssueDetailStore implements IIssueDetailStore {
return _issue || null;
}
setPeekId = (issueId: string | null) => (this.peekId = issueId);
get getIssueReactions() {
if (!this.peekId) return null;
const _reactions = this.issue_reactions[this.peekId];
return _reactions || null;
}
setPeekMode = (mode: IPeekMode | null) => (this.peekMode = mode);
get getIssueComments() {
if (!this.peekId) return null;
const _comments = this.issue_comments[this.peekId];
return _comments || null;
}
get getIssueCommentReactions() {
if (!this.peekId) return null;
const _reactions = this.issue_comment_reactions[this.peekId];
return _reactions || null;
}
setPeekId = (issueId: string | null) => (this.peekId = issueId);
fetchIssueDetails = async (workspaceSlug: string, projectId: string, issueId: string) => {
try {
@ -108,13 +198,15 @@ export class IssueDetailStore implements IIssueDetailStore {
[issueId]: issueDetailsResponse,
};
});
return issueDetailsResponse;
} catch (error) {
runInAction(() => {
this.loader = false;
this.error = error;
});
return error;
throw error;
}
};
@ -218,4 +310,187 @@ export class IssueDetailStore implements IIssueDetailStore {
return error;
}
};
fetchPeekIssueDetails = async (workspaceSlug: string, projectId: string, issueId: string) => {
try {
this.loader = true;
this.error = null;
this.peekId = issueId;
const issueDetailsResponse = await this.issueService.retrieve(workspaceSlug, projectId, issueId);
await this.fetchIssueReactions(workspaceSlug, projectId, issueId);
await this.fetchIssueComments(workspaceSlug, projectId, issueId);
runInAction(() => {
this.loader = false;
this.error = null;
this.issues = {
...this.issues,
[issueId]: issueDetailsResponse,
};
});
return issueDetailsResponse;
} catch (error) {
runInAction(() => {
this.loader = false;
this.error = error;
});
throw error;
}
};
// reactions
fetchIssueReactions = async (workspaceSlug: string, projectId: string, issueId: string) => {
try {
const _reactions = await this.issueReactionService.listIssueReactions(workspaceSlug, projectId, issueId);
const _issue_reactions = {
...this.issue_reactions,
[issueId]: groupReactionEmojis(_reactions),
};
runInAction(() => {
this.issue_reactions = _issue_reactions;
});
} catch (error) {
console.warn("error creating the issue reaction", error);
throw error;
}
};
createIssueReaction = async (workspaceSlug: string, projectId: string, issueId: string, reaction: string) => {
let _currentReactions = this.getIssueReactions;
try {
const _reaction = await this.issueReactionService.createIssueReaction(workspaceSlug, projectId, issueId, {
reaction,
});
_currentReactions = {
..._currentReactions,
[reaction]: [..._currentReactions[reaction], { ..._reaction }],
};
runInAction(() => {
this.issue_reactions = {
...this.issue_reactions,
[issueId]: _currentReactions,
};
});
} catch (error) {
runInAction(() => {
this.issue_reactions = {
...this.issue_reactions,
[issueId]: _currentReactions,
};
});
console.warn("error creating the issue reaction", error);
throw error;
}
};
removeIssueReaction = async (workspaceSlug: string, projectId: string, issueId: string, reaction: string) => {
let _currentReactions = this.getIssueReactions;
try {
const user = this.rootStore.user.currentUser;
if (user) {
_currentReactions = {
..._currentReactions,
[reaction]: [..._currentReactions[reaction].filter((r: any) => r.actor !== user.id)],
};
runInAction(() => {
this.issue_reactions = {
...this.issue_reactions,
[issueId]: _currentReactions,
};
});
await this.issueReactionService.deleteIssueReaction(workspaceSlug, projectId, issueId, reaction);
}
} catch (error) {
runInAction(() => {
this.issue_reactions = {
...this.issue_reactions,
[issueId]: _currentReactions,
};
});
console.warn("error removing the issue reaction", error);
throw error;
}
};
// comments
fetchIssueComments = async (workspaceSlug: string, projectId: string, issueId: string) => {
try {
} catch (error) {
console.warn("error creating the issue comment", error);
throw error;
}
};
createIssueComment = async (workspaceSlug: string, projectId: string, issueId: string, data: any) => {
try {
} catch (error) {
console.warn("error creating the issue comment", error);
throw error;
}
};
updateIssueComment = async (
workspaceSlug: string,
projectId: string,
issueId: string,
commentId: string,
data: any
) => {
try {
} catch (error) {
console.warn("error updating the issue comment", error);
throw error;
}
};
removeIssueComment = async (workspaceSlug: string, projectId: string, issueId: string, commentId: string) => {
try {
} catch (error) {
console.warn("error removing the issue comment", error);
throw error;
}
};
// comment reaction
fetchIssueCommentReactions = async (workspaceSlug: string, projectId: string, issueId: string, commentId: string) => {
try {
} catch (error) {
console.warn("error removing the issue comment", error);
throw error;
}
};
addIssueCommentReaction = async (
workspaceSlug: string,
projectId: string,
issueId: string,
commentId: string,
reaction: string
) => {
try {
} catch (error) {
console.warn("error removing the issue comment", error);
throw error;
}
};
removeIssueCommentReaction = async (
workspaceSlug: string,
projectId: string,
issueId: string,
commentId: string,
reaction: string
) => {
try {
} catch (error) {
console.warn("error removing the issue comment", error);
throw error;
}
};
}