diff --git a/web/components/issues/issue-peek-overview/issue-detail.tsx b/web/components/issues/issue-peek-overview/issue-detail.tsx index 41a388266..b9aff3f8d 100644 --- a/web/components/issues/issue-peek-overview/issue-detail.tsx +++ b/web/components/issues/issue-peek-overview/issue-detail.tsx @@ -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) => void; + issueReactionCreate: (reaction: string) => void; + issueReactionRemove: (reaction: string) => void; } export const PeekOverviewIssueDetails: FC = (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 ( -
+
{issue?.project_detail?.identifier}-{issue?.sequence_id}
{issue?.name}
- { - debouncedIssueDescription(description_html); - }} - /> +
+ { + debouncedIssueDescription(description_html); + }} + /> + + +
); }; diff --git a/web/components/issues/issue-peek-overview/reactions/index.ts b/web/components/issues/issue-peek-overview/reactions/index.ts new file mode 100644 index 000000000..a65262eb7 --- /dev/null +++ b/web/components/issues/issue-peek-overview/reactions/index.ts @@ -0,0 +1,3 @@ +export * from "./root"; +export * from "./selector"; +export * from "./preview"; diff --git a/web/components/issues/issue-peek-overview/reactions/preview.tsx b/web/components/issues/issue-peek-overview/reactions/preview.tsx new file mode 100644 index 000000000..bb9c6c4f7 --- /dev/null +++ b/web/components/issues/issue-peek-overview/reactions/preview.tsx @@ -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 = (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 ( +
+ {Object.keys(issueReactions || {}).map( + (reaction) => + issueReactions[reaction]?.length > 0 && ( + + ) + )} +
+ ); +}; diff --git a/web/components/issues/issue-peek-overview/reactions/root.tsx b/web/components/issues/issue-peek-overview/reactions/root.tsx new file mode 100644 index 000000000..f7657c39c --- /dev/null +++ b/web/components/issues/issue-peek-overview/reactions/root.tsx @@ -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 = (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 ( +
+ + +
+ ); +}; diff --git a/web/components/issues/issue-peek-overview/reactions/selector.tsx b/web/components/issues/issue-peek-overview/reactions/selector.tsx new file mode 100644 index 000000000..cd11afb49 --- /dev/null +++ b/web/components/issues/issue-peek-overview/reactions/selector.tsx @@ -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 = (props) => { + const { size = "md", position = "top", onSelect } = props; + + return ( + <> + + {({ open, close: closePopover }) => ( + <> + + + + + + + +
+ {issueReactionEmojis.map((emoji) => ( + + ))} +
+
+
+ + )} +
+ + ); +}; diff --git a/web/components/issues/issue-peek-overview/root.tsx b/web/components/issues/issue-peek-overview/root.tsx index c8ab92bdb..40a742f3d 100644 --- a/web/components/issues/issue-peek-overview/root.tsx +++ b/web/components/issues/issue-peek-overview/root.tsx @@ -34,6 +34,14 @@ export const IssuePeekOverview: FC = observer((props) => { } }; + const issueReactionCreate = (data: string) => { + issueDetailStore.createIssueReaction(workspaceSlug, projectId, issueId, data); + }; + + const issueReactionRemove = (data: string) => { + issueDetailStore.removeIssueReaction(workspaceSlug, projectId, issueId, data); + }; + return ( = observer((props) => { members={members} priorities={priorities} issueUpdate={issueUpdate} + issueReactionCreate={issueReactionCreate} + issueReactionRemove={issueReactionRemove} > {children} diff --git a/web/components/issues/issue-peek-overview/view.tsx b/web/components/issues/issue-peek-overview/view.tsx index d3974a56b..04e12999a 100644 --- a/web/components/issues/issue-peek-overview/view.tsx +++ b/web/components/issues/issue-peek-overview/view.tsx @@ -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) => 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 = 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("side-peek"); const handlePeekMode = (_peek: TPeekModes) => { @@ -81,12 +95,20 @@ export const IssueView: FC = 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 (
@@ -96,14 +118,14 @@ export const IssueView: FC = observer((props) => { {issueId === peekIssueId && (
{/* header */} -
+
= observer((props) => { issue && ( <> {["side-peek", "modal"].includes(peekMode) ? ( -
- - - {/* reactions */} +
+ = observer((props) => { priorities={priorities} /> - {/* activity */} + {/*
Activity
*/}
) : (
-
+
- {/* reactions */} - - {/* activity */} + {/*
Activity
*/}
-
+
{ + 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; +}; diff --git a/web/store/issue/issue_detail.store.ts b/web/store/issue/issue_detail.store.ts index d49ed5700..6675ba26a 100644 --- a/web/store/issue/issue_detail.store.ts +++ b/web/store/issue/issue_detail.store.ts @@ -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; // creating issue createIssue: (workspaceSlug: string, projectId: string, data: Partial, user: IUser) => Promise; // 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; + + fetchIssueReactions: (workspaceSlug: string, projectId: string, issueId: string) => Promise; + createIssueReaction: (workspaceSlug: string, projectId: string, issueId: string, reaction: string) => Promise; + removeIssueReaction: (workspaceSlug: string, projectId: string, issueId: string, reaction: string) => Promise; + + fetchIssueComments: (workspaceSlug: string, projectId: string, issueId: string) => Promise; + createIssueComment: (workspaceSlug: string, projectId: string, issueId: string, data: any) => Promise; + updateIssueComment: ( + workspaceSlug: string, + projectId: string, + issueId: string, + commentId: string, + data: any + ) => Promise; + removeIssueComment: (workspaceSlug: string, projectId: string, issueId: string, commentId: string) => Promise; + + fetchIssueCommentReactions: ( + workspaceSlug: string, + projectId: string, + issueId: string, + commentId: string + ) => Promise; + addIssueCommentReaction: ( + workspaceSlug: string, + projectId: string, + issueId: string, + commentId: string, + reaction: string + ) => Promise; + removeIssueCommentReaction: ( + workspaceSlug: string, + projectId: string, + issueId: string, + commentId: string, + reaction: string + ) => Promise; } 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; + } + }; }