diff --git a/apps/app/components/command-palette/command-k.tsx b/apps/app/components/command-palette/command-k.tsx index a1525a348..d20a44290 100644 --- a/apps/app/components/command-palette/command-k.tsx +++ b/apps/app/components/command-palette/command-k.tsx @@ -665,7 +665,7 @@ export const CommandK: React.FC = ({ deleteIssue, isPaletteOpen, setIsPal className="focus:outline-none" >
- + Join our Discord
diff --git a/apps/app/components/core/views/spreadsheet-view/single-issue.tsx b/apps/app/components/core/views/spreadsheet-view/single-issue.tsx index 11a8c42c5..53869a638 100644 --- a/apps/app/components/core/views/spreadsheet-view/single-issue.tsx +++ b/apps/app/components/core/views/spreadsheet-view/single-issue.tsx @@ -6,7 +6,6 @@ import { mutate } from "swr"; // components import { - IssuePeekOverview, ViewAssigneeSelect, ViewDueDateSelect, ViewEstimateSelect, @@ -76,9 +75,6 @@ export const SingleSpreadsheetIssue: React.FC = ({ }) => { const [isOpen, setIsOpen] = useState(false); - // issue peek overview - const [issuePeekOverview, setIssuePeekOverview] = useState(false); - const router = useRouter(); const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query; @@ -161,6 +157,15 @@ export const SingleSpreadsheetIssue: React.FC = ({ [workspaceSlug, projectId, cycleId, moduleId, viewId, params, user] ); + const openPeekOverview = () => { + const { query } = router; + + router.push({ + pathname: router.pathname, + query: { ...query, peekIssue: issue.id }, + }); + }; + const handleCopyText = () => { const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : ""; @@ -183,15 +188,6 @@ export const SingleSpreadsheetIssue: React.FC = ({ return ( <> - handleDeleteIssue(issue)} - handleUpdateIssue={async (formData) => partialUpdateIssue(formData, issue)} - issue={issue} - isOpen={issuePeekOverview} - onClose={() => setIssuePeekOverview(false)} - workspaceSlug={workspaceSlug?.toString() ?? ""} - readOnly={isNotAllowed} - />
= ({ diff --git a/apps/app/components/core/views/spreadsheet-view/spreadsheet-view.tsx b/apps/app/components/core/views/spreadsheet-view/spreadsheet-view.tsx index 0b2e785d6..1076f30d0 100644 --- a/apps/app/components/core/views/spreadsheet-view/spreadsheet-view.tsx +++ b/apps/app/components/core/views/spreadsheet-view/spreadsheet-view.tsx @@ -6,6 +6,7 @@ import { useRouter } from "next/router"; // components import { SpreadsheetColumns, SpreadsheetIssues } from "components/core"; import { CustomMenu, Spinner } from "components/ui"; +import { IssuePeekOverview } from "components/issues"; // hooks import useIssuesProperties from "hooks/use-issue-properties"; import useSpreadsheetIssuesView from "hooks/use-spreadsheet-issues-view"; @@ -38,7 +39,7 @@ export const SpreadsheetView: React.FC = ({ const type = cycleId ? "cycle" : moduleId ? "module" : "issue"; - const { spreadsheetIssues } = useSpreadsheetIssuesView(); + const { spreadsheetIssues, mutateIssues } = useSpreadsheetIssuesView(); const [properties] = useIssuesProperties(workspaceSlug as string, projectId as string); @@ -59,80 +60,88 @@ export const SpreadsheetView: React.FC = ({ .join(" "); return ( -
-
- -
- {spreadsheetIssues ? ( -
- {spreadsheetIssues.map((issue: IIssue, index) => ( - - ))} -
- {type === "issue" ? ( - - ) : ( - !disableUserActions && ( - - - Add Issue - - } - position="left" - optionsClassName="left-5 !w-36" - noBorder - > - { - const e = new KeyboardEvent("keydown", { key: "c" }); - document.dispatchEvent(e); - }} - > - Create new - - {openIssuesListModal && ( - - Add an existing issue - - )} - - ) - )} -
+ <> + mutateIssues()} + projectId={projectId?.toString() ?? ""} + workspaceSlug={workspaceSlug?.toString() ?? ""} + readOnly={disableUserActions} + /> +
+
+
- ) : ( - - )} -
+ {spreadsheetIssues ? ( +
+ {spreadsheetIssues.map((issue: IIssue, index) => ( + + ))} +
+ {type === "issue" ? ( + + ) : ( + !disableUserActions && ( + + + Add Issue + + } + position="left" + optionsClassName="left-5 !w-36" + noBorder + > + { + const e = new KeyboardEvent("keydown", { key: "c" }); + document.dispatchEvent(e); + }} + > + Create new + + {openIssuesListModal && ( + + Add an existing issue + + )} + + ) + )} +
+
+ ) : ( + + )} +
+ ); }; diff --git a/apps/app/components/issues/gantt-chart/blocks.tsx b/apps/app/components/issues/gantt-chart/blocks.tsx index 2ad21c499..3ab7ea90b 100644 --- a/apps/app/components/issues/gantt-chart/blocks.tsx +++ b/apps/app/components/issues/gantt-chart/blocks.tsx @@ -15,7 +15,7 @@ export const IssueGanttBlock = ({ data }: { data: IIssue }) => { return (
router.push(`/${workspaceSlug}/projects/${data?.project}/issues/${data?.id}`)} > @@ -49,7 +49,7 @@ export const IssueGanttSidebarBlock = ({ data }: { data: IIssue }) => { return (
router.push(`/${workspaceSlug}/projects/${data?.project}/issues/${data?.id}`)} > {getStateGroupIcon(data?.state_detail?.group, "14", "14", data?.state_detail?.color)} diff --git a/apps/app/components/issues/peek-overview/full-screen-peek-view.tsx b/apps/app/components/issues/peek-overview/full-screen-peek-view.tsx index d470f4910..9c04e0b6a 100644 --- a/apps/app/components/issues/peek-overview/full-screen-peek-view.tsx +++ b/apps/app/components/issues/peek-overview/full-screen-peek-view.tsx @@ -1,3 +1,4 @@ +// components import { PeekOverviewHeader, PeekOverviewIssueActivity, @@ -5,13 +6,16 @@ import { PeekOverviewIssueProperties, TPeekOverviewModes, } from "components/issues"; +// ui +import { Loader } from "components/ui"; +// types import { IIssue } from "types"; type Props = { handleClose: () => void; handleDeleteIssue: () => void; - handleUpdateIssue: (issue: Partial) => Promise; - issue: IIssue; + handleUpdateIssue: (formData: Partial) => Promise; + issue: IIssue | undefined; mode: TPeekOverviewModes; readOnly: boolean; setMode: (mode: TPeekOverviewModes) => void; @@ -40,39 +44,59 @@ export const FullScreenPeekView: React.FC = ({ workspaceSlug={workspaceSlug} />
-
- {/* issue title and description */} -
- + {issue ? ( +
+ {/* issue title and description */} +
+ +
+ {/* divider */} +
+ {/* issue activity/comments */} +
+ +
- {/* divider */} -
- {/* issue activity/comments */} -
- -
-
+ ) : ( + + +
+ + + +
+
+ )}
{/* issue properties */}
- + {issue ? ( + + ) : ( + + + + + + + )}
diff --git a/apps/app/components/issues/peek-overview/header.tsx b/apps/app/components/issues/peek-overview/header.tsx index 29e23a262..266b2edb8 100644 --- a/apps/app/components/issues/peek-overview/header.tsx +++ b/apps/app/components/issues/peek-overview/header.tsx @@ -1,18 +1,21 @@ +import Link from "next/link"; + // hooks import useToast from "hooks/use-toast"; // ui import { CustomSelect, Icon } from "components/ui"; +// icons +import { East, OpenInFull } from "@mui/icons-material"; // helpers import { copyTextToClipboard } from "helpers/string.helper"; // types import { IIssue } from "types"; import { TPeekOverviewModes } from "./layout"; -import { ArrowRightAlt, CloseFullscreen, East, OpenInFull } from "@mui/icons-material"; type Props = { handleClose: () => void; handleDeleteIssue: () => void; - issue: IIssue; + issue: IIssue | undefined; mode: TPeekOverviewModes; setMode: (mode: TPeekOverviewModes) => void; workspaceSlug: string; @@ -47,12 +50,9 @@ export const PeekOverviewHeader: React.FC = ({ const { setToastAlert } = useToast(); const handleCopyLink = () => { - const originURL = - typeof window !== "undefined" && window.location.origin ? window.location.origin : ""; + const urlToCopy = window.location.href; - copyTextToClipboard( - `${originURL}/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}` - ).then(() => { + copyTextToClipboard(urlToCopy).then(() => { setToastAlert({ type: "success", title: "Link copied!", @@ -73,23 +73,15 @@ export const PeekOverviewHeader: React.FC = ({ /> )} - {mode === "modal" || mode === "full" ? ( - - ) : ( - - )} + + setMode(val)} @@ -119,7 +111,7 @@ export const PeekOverviewHeader: React.FC = ({
{(mode === "side" || mode === "modal") && ( -
+
diff --git a/apps/app/components/issues/peek-overview/issue-properties.tsx b/apps/app/components/issues/peek-overview/issue-properties.tsx index bf1ecefd9..2c8b4d572 100644 --- a/apps/app/components/issues/peek-overview/issue-properties.tsx +++ b/apps/app/components/issues/peek-overview/issue-properties.tsx @@ -1,6 +1,11 @@ +// mobx +import { observer } from "mobx-react-lite"; // headless ui import { Disclosure } from "@headlessui/react"; import { getStateGroupIcon } from "components/icons"; +// hooks +import useToast from "hooks/use-toast"; +import useUser from "hooks/use-user"; // components import { SidebarAssigneeSelect, @@ -9,27 +14,27 @@ import { SidebarStateSelect, TPeekOverviewModes, } from "components/issues"; -// icons +// ui import { CustomDatePicker, Icon } from "components/ui"; +// helpers import { copyTextToClipboard } from "helpers/string.helper"; -import useToast from "hooks/use-toast"; // types import { IIssue } from "types"; type Props = { handleDeleteIssue: () => void; + handleUpdateIssue: (formData: Partial) => Promise; issue: IIssue; mode: TPeekOverviewModes; - onChange: (issueProperty: Partial) => void; readOnly: boolean; workspaceSlug: string; }; export const PeekOverviewIssueProperties: React.FC = ({ handleDeleteIssue, + handleUpdateIssue, issue, mode, - onChange, readOnly, workspaceSlug, }) => { @@ -86,7 +91,7 @@ export const PeekOverviewIssueProperties: React.FC = ({
onChange({ state: val })} + onChange={(val: string) => handleUpdateIssue({ state: val })} disabled={readOnly} />
@@ -99,7 +104,7 @@ export const PeekOverviewIssueProperties: React.FC = ({
onChange({ assignees_list: val })} + onChange={(val: string[]) => handleUpdateIssue({ assignees_list: val })} disabled={readOnly} />
@@ -112,7 +117,7 @@ export const PeekOverviewIssueProperties: React.FC = ({
onChange({ priority: val })} + onChange={(val: string) => handleUpdateIssue({ priority: val })} disabled={readOnly} />
@@ -128,7 +133,7 @@ export const PeekOverviewIssueProperties: React.FC = ({ placeholder="Start date" value={issue.start_date} onChange={(val) => - onChange({ + handleUpdateIssue({ start_date: val, }) } @@ -153,7 +158,7 @@ export const PeekOverviewIssueProperties: React.FC = ({ placeholder="Due date" value={issue.target_date} onChange={(val) => - onChange({ + handleUpdateIssue({ target_date: val, }) } @@ -175,7 +180,7 @@ export const PeekOverviewIssueProperties: React.FC = ({
onChange({ estimate_point: val })} + onChange={(val: number | null) =>handleUpdateIssue({ estimate_point: val })} disabled={readOnly} />
diff --git a/apps/app/components/issues/peek-overview/layout.tsx b/apps/app/components/issues/peek-overview/layout.tsx index 7196052f8..50fa5df68 100644 --- a/apps/app/components/issues/peek-overview/layout.tsx +++ b/apps/app/components/issues/peek-overview/layout.tsx @@ -1,107 +1,184 @@ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; +import { useRouter } from "next/router"; + +// mobx +import { observer } from "mobx-react-lite"; +import { useMobxStore } from "lib/mobx/store-provider"; // headless ui import { Dialog, Transition } from "@headlessui/react"; +// hooks +import useUser from "hooks/use-user"; +// components import { FullScreenPeekView, SidePeekView } from "components/issues"; // types import { IIssue } from "types"; type Props = { - handleDeleteIssue: () => void; - handleUpdateIssue: (issue: Partial) => Promise; - issue: IIssue | null; - isOpen: boolean; - onClose: () => void; - workspaceSlug: string; + handleMutation: () => void; + projectId: string; readOnly: boolean; + workspaceSlug: string; }; export type TPeekOverviewModes = "side" | "modal" | "full"; -export const IssuePeekOverview: React.FC = ({ - handleDeleteIssue, - handleUpdateIssue, - issue, - isOpen, - onClose, - workspaceSlug, - readOnly, -}) => { - const [peekOverviewMode, setPeekOverviewMode] = useState("side"); +export const IssuePeekOverview: React.FC = observer( + ({ handleMutation, projectId, readOnly, workspaceSlug }) => { + const [isSidePeekOpen, setIsSidePeekOpen] = useState(false); + const [isModalPeekOpen, setIsModalPeekOpen] = useState(false); + const [peekOverviewMode, setPeekOverviewMode] = useState("side"); - const handleClose = () => { - onClose(); - setPeekOverviewMode("side"); - }; + const router = useRouter(); + const { peekIssue } = router.query; - if (!issue || !isOpen) return null; + const { issues: issuesStore } = useMobxStore(); + const { deleteIssue, getIssueById, issues, updateIssue } = issuesStore; - return ( - - - {/* add backdrop conditionally */} - {(peekOverviewMode === "modal" || peekOverviewMode === "full") && ( - -
- - )} -
-
+ const issue = issues[peekIssue?.toString() ?? ""]; + + const { user } = useUser(); + + const handleClose = () => { + const { query } = router; + delete query.peekIssue; + + router.push({ + pathname: router.pathname, + query: { ...query }, + }); + }; + + const handleUpdateIssue = async (formData: Partial) => { + if (!issue || !user) return; + + await updateIssue(workspaceSlug, projectId, issue.id, formData, user); + handleMutation(); + }; + + const handleDeleteIssue = async () => { + if (!issue || !user) return; + + await deleteIssue(workspaceSlug, projectId, issue.id, user); + handleMutation(); + + handleClose(); + }; + + useEffect(() => { + if (!peekIssue) return; + + getIssueById(workspaceSlug, projectId, peekIssue.toString()); + }, [getIssueById, peekIssue, projectId, workspaceSlug]); + + useEffect(() => { + if (peekIssue) { + if (peekOverviewMode === "side") { + setIsSidePeekOpen(true); + setIsModalPeekOpen(false); + } else { + setIsModalPeekOpen(true); + setIsSidePeekOpen(false); + } + } else { + console.log("Triggered"); + setIsSidePeekOpen(false); + setIsModalPeekOpen(false); + } + }, [peekIssue, peekOverviewMode]); + + return ( + <> + + +
+
+ + + setPeekOverviewMode(mode)} + workspaceSlug={workspaceSlug} + /> + + +
+
+
+
+ + - - {(peekOverviewMode === "side" || peekOverviewMode === "modal") && ( - setPeekOverviewMode(mode)} - workspaceSlug={workspaceSlug} - /> - )} - {peekOverviewMode === "full" && ( - setPeekOverviewMode(mode)} - workspaceSlug={workspaceSlug} - /> - )} - +
-
-
-
-
- ); -}; +
+
+ + + {peekOverviewMode === "modal" && ( + setPeekOverviewMode(mode)} + workspaceSlug={workspaceSlug} + /> + )} + {peekOverviewMode === "full" && ( + setPeekOverviewMode(mode)} + workspaceSlug={workspaceSlug} + /> + )} + + +
+
+ + + + ); + } +); diff --git a/apps/app/components/issues/peek-overview/side-peek-view.tsx b/apps/app/components/issues/peek-overview/side-peek-view.tsx index f938c3805..1bdeed479 100644 --- a/apps/app/components/issues/peek-overview/side-peek-view.tsx +++ b/apps/app/components/issues/peek-overview/side-peek-view.tsx @@ -1,3 +1,4 @@ +// components import { PeekOverviewHeader, PeekOverviewIssueActivity, @@ -5,13 +6,16 @@ import { PeekOverviewIssueProperties, TPeekOverviewModes, } from "components/issues"; +// ui +import { Loader } from "components/ui"; +// types import { IIssue } from "types"; type Props = { handleClose: () => void; handleDeleteIssue: () => void; - handleUpdateIssue: (issue: Partial) => Promise; - issue: IIssue; + handleUpdateIssue: (formData: Partial) => Promise; + issue: IIssue | undefined; mode: TPeekOverviewModes; readOnly: boolean; setMode: (mode: TPeekOverviewModes) => void; @@ -39,37 +43,50 @@ export const SidePeekView: React.FC = ({ workspaceSlug={workspaceSlug} />
-
- {/* issue title and description */} -
- + {issue ? ( +
+ {/* issue title and description */} +
+ +
+ {/* issue properties */} +
+ +
+ {/* divider */} +
+ {/* issue activity/comments */} +
+ {issue && ( + + )} +
- {/* issue properties */} -
- -
- {/* divider */} -
- {/* issue activity/comments */} -
- -
-
+ ) : ( + + +
+ + + +
+
+ )}
); diff --git a/apps/app/hooks/use-spreadsheet-issues-view.tsx b/apps/app/hooks/use-spreadsheet-issues-view.tsx index 4471b4352..145313dac 100644 --- a/apps/app/hooks/use-spreadsheet-issues-view.tsx +++ b/apps/app/hooks/use-spreadsheet-issues-view.tsx @@ -48,7 +48,7 @@ const useSpreadsheetIssuesView = () => { sub_issue: "false", }; - const { data: projectSpreadsheetIssues } = useSWR( + const { data: projectSpreadsheetIssues, mutate: mutateProjectSpreadsheetIssues } = useSWR( workspaceSlug && projectId ? PROJECT_ISSUES_LIST_WITH_PARAMS(projectId.toString(), params) : null, @@ -58,7 +58,7 @@ const useSpreadsheetIssuesView = () => { : null ); - const { data: cycleSpreadsheetIssues } = useSWR( + const { data: cycleSpreadsheetIssues, mutate: mutateCycleSpreadsheetIssues } = useSWR( workspaceSlug && projectId && cycleId ? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), params) : null, @@ -73,7 +73,7 @@ const useSpreadsheetIssuesView = () => { : null ); - const { data: moduleSpreadsheetIssues } = useSWR( + const { data: moduleSpreadsheetIssues, mutate: mutateModuleSpreadsheetIssues } = useSWR( workspaceSlug && projectId && moduleId ? MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), params) : null, @@ -88,7 +88,7 @@ const useSpreadsheetIssuesView = () => { : null ); - const { data: viewSpreadsheetIssues } = useSWR( + const { data: viewSpreadsheetIssues, mutate: mutateViewSpreadsheetIssues } = useSWR( workspaceSlug && projectId && viewId && params ? VIEW_ISSUES(viewId.toString(), params) : null, workspaceSlug && projectId && viewId && params ? () => @@ -106,6 +106,13 @@ const useSpreadsheetIssuesView = () => { return { issueView, + mutateIssues: cycleId + ? mutateCycleSpreadsheetIssues + : moduleId + ? mutateModuleSpreadsheetIssues + : viewId + ? mutateViewSpreadsheetIssues + : mutateProjectSpreadsheetIssues, spreadsheetIssues: spreadsheetIssues ?? [], orderBy, setOrderBy, diff --git a/apps/app/store/issues.ts b/apps/app/store/issues.ts new file mode 100644 index 000000000..538c5e2a9 --- /dev/null +++ b/apps/app/store/issues.ts @@ -0,0 +1,172 @@ +// mobx +import { action, observable, runInAction, makeAutoObservable } from "mobx"; +// services +import issueService from "services/issues.service"; +// types +import type { ICurrentUserResponse, IIssue } from "types"; + +class IssuesStore { + issues: { [key: string]: IIssue } = {}; + isIssuesLoading: boolean = false; + rootStore: any | null = null; + + constructor(_rootStore: any | null = null) { + makeAutoObservable(this, { + issues: observable.ref, + loadIssues: action, + getIssueById: action, + isIssuesLoading: observable, + createIssue: action, + updateIssue: action, + deleteIssue: action, + }); + + this.rootStore = _rootStore; + } + + /** + * @description Fetch all issues of a project and hydrate issues field + */ + + loadIssues = async (workspaceSlug: string, projectId: string) => { + this.isIssuesLoading = true; + try { + const issuesResponse: IIssue[] = (await issueService.getIssuesWithParams( + workspaceSlug, + projectId + )) as IIssue[]; + + const issues: { [kye: string]: IIssue } = {}; + issuesResponse.forEach((issue) => { + issues[issue.id] = issue; + }); + + runInAction(() => { + this.issues = issues; + this.isIssuesLoading = false; + }); + } catch (error) { + this.isIssuesLoading = false; + console.error("Fetching issues error", error); + } + }; + + getIssueById = async ( + workspaceSlug: string, + projectId: string, + issueId: string + ): Promise => { + if (this.issues[issueId]) return this.issues[issueId]; + + try { + const issueResponse: IIssue = await issueService.retrieve(workspaceSlug, projectId, issueId); + + const issues = { + ...this.issues, + [issueId]: { ...issueResponse }, + }; + + runInAction(() => { + this.issues = issues; + }); + + return issueResponse; + } catch (error) { + throw error; + } + }; + + createIssue = async ( + workspaceSlug: string, + projectId: string, + issueForm: IIssue, + user: ICurrentUserResponse + ): Promise => { + try { + const issueResponse = await issueService.createIssues( + workspaceSlug, + projectId, + issueForm, + user + ); + + const issues = { + ...this.issues, + [issueResponse.id]: { ...issueResponse }, + }; + + runInAction(() => { + this.issues = issues; + }); + return issueResponse; + } catch (error) { + console.error("Creating issue error", error); + throw error; + } + }; + + updateIssue = async ( + workspaceSlug: string, + projectId: string, + issueId: string, + issueForm: Partial, + user: ICurrentUserResponse + ) => { + // keep a copy of the issue in the store + const originalIssue = { ...this.issues[issueId] }; + + // immediately update the issue in the store + const updatedIssue = { ...originalIssue, ...issueForm }; + + try { + runInAction(() => { + this.issues[issueId] = updatedIssue; + }); + + // make a patch request to update the issue + const issueResponse: IIssue = await issueService.patchIssue( + workspaceSlug, + projectId, + issueId, + issueForm, + user + ); + + const updatedIssues = { ...this.issues }; + updatedIssues[issueId] = { ...issueResponse }; + + runInAction(() => { + this.issues = updatedIssues; + }); + } catch (error) { + // if there is an error, revert the changes + runInAction(() => { + this.issues[issueId] = originalIssue; + }); + + return error; + } + }; + + deleteIssue = async ( + workspaceSlug: string, + projectId: string, + issueId: string, + user: ICurrentUserResponse + ) => { + const issues = { ...this.issues }; + delete issues[issueId]; + + try { + runInAction(() => { + this.issues = issues; + }); + + issueService.deleteIssue(workspaceSlug, projectId, issueId, user); + } catch (error) { + console.error("Deleting issue error", error); + } + }; +} + +export default IssuesStore; diff --git a/apps/app/store/root.ts b/apps/app/store/root.ts index 5895637a8..40dd62fe6 100644 --- a/apps/app/store/root.ts +++ b/apps/app/store/root.ts @@ -3,6 +3,7 @@ import { enableStaticRendering } from "mobx-react-lite"; // store imports import UserStore from "./user"; import ThemeStore from "./theme"; +import IssuesStore from "./issues"; import ProjectPublishStore, { IProjectPublishStore } from "./project-publish"; enableStaticRendering(typeof window === "undefined"); @@ -11,10 +12,12 @@ export class RootStore { user; theme; projectPublish: IProjectPublishStore; + issues: IssuesStore; constructor() { this.user = new UserStore(this); this.theme = new ThemeStore(this); this.projectPublish = new ProjectPublishStore(this); + this.issues = new IssuesStore(this); } }