diff --git a/web/components/project/confirm-project-leave-modal.tsx b/web/components/project/confirm-project-leave-modal.tsx new file mode 100644 index 000000000..429c231d2 --- /dev/null +++ b/web/components/project/confirm-project-leave-modal.tsx @@ -0,0 +1,220 @@ +import React from "react"; +// next imports +import { useRouter } from "next/router"; +// swr +import { mutate } from "swr"; +// react-hook-form +import { Controller, useForm } from "react-hook-form"; +// headless ui +import { Dialog, Transition } from "@headlessui/react"; +// icons +import { ExclamationTriangleIcon } from "@heroicons/react/24/outline"; +// ui +import { DangerButton, Input, SecondaryButton } from "components/ui"; +// fetch-keys +import { PROJECTS_LIST } from "constants/fetch-keys"; +// mobx react lite +import { observer } from "mobx-react-lite"; +// mobx store +import { useMobxStore } from "lib/mobx/store-provider"; +import { RootStore } from "store/root"; +// hooks +import useToast from "hooks/use-toast"; +import useUser from "hooks/use-user"; +// types +import { IProject } from "types"; + +type FormData = { + projectName: string; + confirmLeave: string; +}; + +const defaultValues: FormData = { + projectName: "", + confirmLeave: "", +}; + +export const ConfirmProjectLeaveModal: React.FC = observer(() => { + const router = useRouter(); + const { workspaceSlug } = router.query; + + const store: RootStore = useMobxStore(); + const { project } = store; + + const { user } = useUser(); + + const { setToastAlert } = useToast(); + + const { + control, + formState: { isSubmitting }, + handleSubmit, + reset, + watch, + } = useForm({ defaultValues }); + + const handleClose = () => { + project.handleProjectLeaveModal(null); + + reset({ ...defaultValues }); + }; + + project?.projectLeaveDetails && + console.log("project leave confirmation modal", project?.projectLeaveDetails); + + const onSubmit = async (data: any) => { + if (data) { + if (data.projectName === project?.projectLeaveDetails?.name) { + if (data.confirmLeave === "Leave Project") { + return project + .leaveProject( + project.projectLeaveDetails.workspaceSlug.toString(), + project.projectLeaveDetails.id.toString(), + user + ) + .then((res) => { + mutate( + PROJECTS_LIST(project.projectLeaveDetails.workspaceSlug.toString(), { + is_favorite: "all", + }), + (prevData) => prevData?.filter((project: IProject) => project.id !== data.id), + false + ); + handleClose(); + router.push(`/${workspaceSlug}/projects`); + }) + .catch((err) => { + setToastAlert({ + type: "error", + title: "Error!", + message: "Something went wrong please try again later.", + }); + }); + } else { + setToastAlert({ + type: "error", + title: "Error!", + message: "Please confirm leaving the project by typing the 'Leave Project'.", + }); + } + } else { + setToastAlert({ + type: "error", + title: "Error!", + message: "Please enter the project name as shown in the description.", + }); + } + } else { + setToastAlert({ + type: "error", + title: "Error!", + message: "Please fill all fields.", + }); + } + }; + + return ( + + + +
+ + +
+
+ + +
+
+ + + +

Leave Project

+
+
+ + +

+ Are you sure you want to leave the project - + {` "${project?.projectLeaveDetails?.name}" `} + ? All of the issues associated with you will become inaccessible. +

+
+ +
+

+ Enter the project name{" "} + + {project?.projectLeaveDetails?.name} + {" "} + to continue: +

+ ( + + )} + /> +
+ +
+

+ To confirm, type{" "} + Leave Project below: +

+ ( + + )} + /> +
+
+ Cancel + + {isSubmitting ? "Leaving..." : "Leave Project"} + +
+
+
+
+
+
+
+
+ ); +}); diff --git a/web/components/project/index.ts b/web/components/project/index.ts index a2fed74b8..494a04294 100644 --- a/web/components/project/index.ts +++ b/web/components/project/index.ts @@ -5,3 +5,4 @@ export * from "./settings-header"; export * from "./single-integration-card"; export * from "./single-project-card"; export * from "./single-sidebar-project"; +export * from "./confirm-project-leave-modal"; diff --git a/web/components/project/sidebar-list.tsx b/web/components/project/sidebar-list.tsx index 0ab8f9bee..a46a97f04 100644 --- a/web/components/project/sidebar-list.tsx +++ b/web/components/project/sidebar-list.tsx @@ -35,6 +35,7 @@ export const ProjectSidebarList: FC = () => { const [isProjectModalOpen, setIsProjectModalOpen] = useState(false); const [deleteProjectModal, setDeleteProjectModal] = useState(false); const [projectToDelete, setProjectToDelete] = useState(null); + const [projectToLeaveId, setProjectToLeaveId] = useState(null); // router const [isScrolled, setIsScrolled] = useState(false); @@ -217,6 +218,7 @@ export const ProjectSidebarList: FC = () => { snapshot={snapshot} handleDeleteProject={() => handleDeleteProject(project)} handleCopyText={() => handleCopyText(project.id)} + handleProjectLeave={() => setProjectToLeaveId(project.id)} shortContextMenu /> @@ -285,6 +287,7 @@ export const ProjectSidebarList: FC = () => { provided={provided} snapshot={snapshot} handleDeleteProject={() => handleDeleteProject(project)} + handleProjectLeave={() => setProjectToLeaveId(project.id)} handleCopyText={() => handleCopyText(project.id)} /> diff --git a/web/components/project/single-sidebar-project.tsx b/web/components/project/single-sidebar-project.tsx index 6fbdbbaf0..ebc8bc974 100644 --- a/web/components/project/single-sidebar-project.tsx +++ b/web/components/project/single-sidebar-project.tsx @@ -44,6 +44,7 @@ type Props = { snapshot?: DraggableStateSnapshot; handleDeleteProject: () => void; handleCopyText: () => void; + handleProjectLeave: () => void; shortContextMenu?: boolean; }; @@ -80,276 +81,293 @@ const navigation = (workspaceSlug: string, projectId: string) => [ }, ]; -export const SingleSidebarProject: React.FC = observer( - ({ +export const SingleSidebarProject: React.FC = observer((props) => { + const { project, sidebarCollapse, provided, snapshot, handleDeleteProject, handleCopyText, + handleProjectLeave, shortContextMenu = false, - }) => { - const store: RootStore = useMobxStore(); - const { projectPublish } = store; + } = props; - const router = useRouter(); - const { workspaceSlug, projectId } = router.query; + const store: RootStore = useMobxStore(); + const { projectPublish, project: projectStore } = store; - const { setToastAlert } = useToast(); + const router = useRouter(); + const { workspaceSlug, projectId } = router.query; - const isAdmin = project.member_role === 20; + const { setToastAlert } = useToast(); - const handleAddToFavorites = () => { - if (!workspaceSlug) return; + const isAdmin = project.member_role === 20; - mutate( - PROJECTS_LIST(workspaceSlug as string, { is_favorite: "all" }), - (prevData) => - (prevData ?? []).map((p) => (p.id === project.id ? { ...p, is_favorite: true } : p)), - false - ); + const isViewerOrGuest = project.member_role === 10 || project.member_role === 5; - projectService - .addProjectToFavorites(workspaceSlug as string, { - project: project.id, - }) - .catch(() => - setToastAlert({ - type: "error", - title: "Error!", - message: "Couldn't remove the project from favorites. Please try again.", - }) - ); - }; + const handleAddToFavorites = () => { + if (!workspaceSlug) return; - const handleRemoveFromFavorites = () => { - if (!workspaceSlug) return; + mutate( + PROJECTS_LIST(workspaceSlug as string, { is_favorite: "all" }), + (prevData) => + (prevData ?? []).map((p) => (p.id === project.id ? { ...p, is_favorite: true } : p)), + false + ); - mutate( - PROJECTS_LIST(workspaceSlug as string, { is_favorite: "all" }), - (prevData) => - (prevData ?? []).map((p) => (p.id === project.id ? { ...p, is_favorite: false } : p)), - false - ); - - projectService.removeProjectFromFavorites(workspaceSlug as string, project.id).catch(() => + projectService + .addProjectToFavorites(workspaceSlug as string, { + project: project.id, + }) + .catch(() => setToastAlert({ type: "error", title: "Error!", message: "Couldn't remove the project from favorites. Please try again.", }) ); - }; + }; - return ( - - {({ open }) => ( - <> -
- {provided && ( - - - - )} + const handleRemoveFromFavorites = () => { + if (!workspaceSlug) return; + + mutate( + PROJECTS_LIST(workspaceSlug as string, { is_favorite: "all" }), + (prevData) => + (prevData ?? []).map((p) => (p.id === project.id ? { ...p, is_favorite: false } : p)), + false + ); + + projectService.removeProjectFromFavorites(workspaceSlug as string, project.id).catch(() => + setToastAlert({ + type: "error", + title: "Error!", + message: "Couldn't remove the project from favorites. Please try again.", + }) + ); + }; + + return ( + + {({ open }) => ( + <> +
+ {provided && ( - + )} + + +
-
- {project.emoji ? ( - - {renderEmoji(project.emoji)} - - ) : project.icon_prop ? ( -
- {renderEmoji(project.icon_prop)} -
- ) : ( - - {project?.name.charAt(0)} - - )} + {project.emoji ? ( + + {renderEmoji(project.emoji)} + + ) : project.icon_prop ? ( +
+ {renderEmoji(project.icon_prop)} +
+ ) : ( + + {project?.name.charAt(0)} + + )} - {!sidebarCollapse && ( -

- {project.name} -

- )} -
{!sidebarCollapse && ( - +

+ {project.name} +

)} - - +
+ {!sidebarCollapse && ( + + )} +
+
- {!sidebarCollapse && ( - - {!shortContextMenu && isAdmin && ( - - - - Delete project - - - )} - {!project.is_favorite && ( - - - - Add to favorites - - - )} - {project.is_favorite && ( - - - - Remove from favorites - - - )} - - - - Copy project link + {!sidebarCollapse && ( + + {!shortContextMenu && isAdmin && ( + + + + Delete project + )} + {!project.is_favorite && ( + + + + Add to favorites + + + )} + {project.is_favorite && ( + + + + Remove from favorites + + + )} + + + + Copy project link + + - {/* publish project settings */} - {isAdmin && ( - projectPublish.handleProjectModal(project?.id)} - > -
-
- -
-
{project.is_deployed ? "Publish settings" : "Publish"}
+ {/* publish project settings */} + {isAdmin && ( + projectPublish.handleProjectModal(project?.id)} + > +
+
+
- - )} +
{project.is_deployed ? "Publish settings" : "Publish"}
+
+
+ )} - {project.archive_in > 0 && ( - - router.push(`/${workspaceSlug}/projects/${project?.id}/archived-issues/`) - } - > -
- - Archived Issues -
-
- )} + {project.archive_in > 0 && ( - router.push(`/${workspaceSlug}/projects/${project?.id}/settings`) + router.push(`/${workspaceSlug}/projects/${project?.id}/archived-issues/`) } >
- - Settings + + Archived Issues
- - )} -
+ )} + router.push(`/${workspaceSlug}/projects/${project?.id}/settings`)} + > +
+ + Settings +
+
- - - {navigation(workspaceSlug as string, project?.id).map((item) => { - if ( - (item.name === "Cycles" && !project.cycle_view) || - (item.name === "Modules" && !project.module_view) || - (item.name === "Views" && !project.issue_views_view) || - (item.name === "Pages" && !project.page_view) - ) - return; + {/* leave project */} + {isViewerOrGuest && ( + + projectStore.handleProjectLeaveModal({ + id: project?.id, + name: project?.name, + workspaceSlug: workspaceSlug as string, + }) + } + > +
+ + Leave Project +
+
+ )} +
+ )} +
- return ( - - - + + {navigation(workspaceSlug as string, project?.id).map((item) => { + if ( + (item.name === "Cycles" && !project.cycle_view) || + (item.name === "Modules" && !project.module_view) || + (item.name === "Views" && !project.issue_views_view) || + (item.name === "Pages" && !project.page_view) + ) + return; + + return ( + + + +
-
- - {!sidebarCollapse && item.name} -
- -
- - ); - })} - - - - )} - - ); - } -); + + {!sidebarCollapse && item.name} +
+ + + + ); + })} + + + + )} +
+ ); +}); diff --git a/web/layouts/app-layout/app-sidebar.tsx b/web/layouts/app-layout/app-sidebar.tsx index 9290c00c6..03ac72387 100644 --- a/web/layouts/app-layout/app-sidebar.tsx +++ b/web/layouts/app-layout/app-sidebar.tsx @@ -9,6 +9,7 @@ import { } from "components/workspace"; import { ProjectSidebarList } from "components/project"; import { PublishProjectModal } from "components/project/publish-project/modal"; +import { ConfirmProjectLeaveModal } from "components/project/confirm-project-leave-modal"; // mobx react lite import { observer } from "mobx-react-lite"; // mobx store @@ -38,7 +39,10 @@ const Sidebar: React.FC = observer(({ toggleSidebar, setToggleSide
+ {/* publish project modal */} + {/* project leave modal */} + ); }); diff --git a/web/services/project.service.ts b/web/services/project.service.ts index 961333bee..0c2712c56 100644 --- a/web/services/project.service.ts +++ b/web/services/project.service.ts @@ -21,7 +21,7 @@ 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 ProjectServices extends APIService { +export class ProjectServices extends APIService { constructor() { super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000"); } @@ -142,6 +142,30 @@ class ProjectServices extends APIService { }); } + async leaveProject( + workspaceSlug: string, + projectId: string, + user: ICurrentUserResponse + ): Promise { + return this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/members/leave/`) + .then((response) => { + if (trackEvent) + trackEventServices.trackProjectEvent( + "PROJECT_MEMBER_LEAVE", + { + workspaceSlug, + projectId, + ...response?.data, + }, + user + ); + return response?.data; + }) + .catch((error) => { + throw error?.response?.data; + }); + } + async joinProjects(data: any): Promise { return this.post("/api/users/me/invitations/projects/", data) .then((response) => response?.data) diff --git a/web/services/track-event.service.ts b/web/services/track-event.service.ts index f55a6f366..c59242f50 100644 --- a/web/services/track-event.service.ts +++ b/web/services/track-event.service.ts @@ -35,7 +35,8 @@ type ProjectEventType = | "CREATE_PROJECT" | "UPDATE_PROJECT" | "DELETE_PROJECT" - | "PROJECT_MEMBER_INVITE"; + | "PROJECT_MEMBER_INVITE" + | "PROJECT_MEMBER_LEAVE"; type IssueEventType = "ISSUE_CREATE" | "ISSUE_UPDATE" | "ISSUE_DELETE"; @@ -163,7 +164,11 @@ class TrackEventServices extends APIService { user: ICurrentUserResponse | undefined ): Promise { let payload: any; - if (eventName !== "DELETE_PROJECT" && eventName !== "PROJECT_MEMBER_INVITE") + if ( + eventName !== "DELETE_PROJECT" && + eventName !== "PROJECT_MEMBER_INVITE" && + eventName !== "PROJECT_MEMBER_LEAVE" + ) payload = { workspaceId: data?.workspace_detail?.id, workspaceName: data?.workspace_detail?.name, diff --git a/web/store/project.ts b/web/store/project.ts new file mode 100644 index 000000000..0fe842dad --- /dev/null +++ b/web/store/project.ts @@ -0,0 +1,86 @@ +import { observable, action, computed, makeObservable, runInAction } from "mobx"; +// types +import { RootStore } from "./root"; +// services +import { ProjectServices } from "services/project.service"; + +export interface IProject { + id: string; + name: string; + workspaceSlug: string; +} + +export interface IProjectStore { + loader: boolean; + error: any | null; + + projectLeaveModal: boolean; + projectLeaveDetails: IProject | any; + + handleProjectLeaveModal: (project: IProject | null) => void; + + leaveProject: (workspace_slug: string, project_slug: string, user: any) => Promise; +} + +class ProjectStore implements IProjectStore { + loader: boolean = false; + error: any | null = null; + + projectLeaveModal: boolean = false; + projectLeaveDetails: IProject | null = null; + + // root store + rootStore; + // service + projectService; + + constructor(_rootStore: RootStore) { + makeObservable(this, { + // observable + loader: observable, + error: observable, + + projectLeaveModal: observable, + projectLeaveDetails: observable.ref, + // action + handleProjectLeaveModal: action, + leaveProject: action, + // computed + }); + + this.rootStore = _rootStore; + this.projectService = new ProjectServices(); + } + + handleProjectLeaveModal = (project: IProject | null = null) => { + if (project && project?.id) { + this.projectLeaveModal = !this.projectLeaveModal; + this.projectLeaveDetails = project; + } else { + this.projectLeaveModal = !this.projectLeaveModal; + this.projectLeaveDetails = null; + } + }; + + leaveProject = async (workspace_slug: string, project_slug: string, user: any) => { + try { + this.loader = true; + this.error = null; + + const response = await this.projectService.leaveProject(workspace_slug, project_slug, user); + + runInAction(() => { + this.loader = false; + this.error = null; + }); + + return response; + } catch (error) { + this.loader = false; + this.error = error; + return error; + } + }; +} + +export default ProjectStore; diff --git a/web/store/root.ts b/web/store/root.ts index 40dd62fe6..ce0bdfad5 100644 --- a/web/store/root.ts +++ b/web/store/root.ts @@ -3,20 +3,23 @@ import { enableStaticRendering } from "mobx-react-lite"; // store imports import UserStore from "./user"; import ThemeStore from "./theme"; -import IssuesStore from "./issues"; +import ProjectStore, { IProjectStore } from "./project"; import ProjectPublishStore, { IProjectPublishStore } from "./project-publish"; +import IssuesStore from "./issues"; enableStaticRendering(typeof window === "undefined"); export class RootStore { user; theme; + project: IProjectStore; projectPublish: IProjectPublishStore; issues: IssuesStore; constructor() { this.user = new UserStore(this); this.theme = new ThemeStore(this); + this.project = new ProjectStore(this); this.projectPublish = new ProjectPublishStore(this); this.issues = new IssuesStore(this); }