diff --git a/.husky/pre-push b/.husky/pre-push deleted file mode 100755 index 0e7d3240b..000000000 --- a/.husky/pre-push +++ /dev/null @@ -1,23 +0,0 @@ -#!/bin/sh -. "$(dirname -- "$0")/_/husky.sh" - -changed_files=$(git diff --name-only HEAD~1) - -web_changed=$(echo "$changed_files" | grep -E '^web/' || true) -space_changed=$(echo "$changed_files" | grep -E '^space/' || true) -echo $web_changed -echo $space_changed - -if [ -n "$web_changed" ] && [ -n "$space_changed" ]; then - echo "Changes detected in both web and space. Building..." - yarn run lint - yarn run build -elif [ -n "$web_changed" ]; then - echo "Changes detected in web app. Building..." - yarn run lint --filter=web - yarn run build --filter=web -elif [ -n "$space_changed" ]; then - echo "Changes detected in space app. Building..." - yarn run lint --filter=space - yarn run build --filter=space -fi diff --git a/package.json b/package.json index 397952b3b..eb6a23994 100644 --- a/package.json +++ b/package.json @@ -19,8 +19,7 @@ "devDependencies": { "eslint-config-custom": "*", "prettier": "latest", - "turbo": "latest", - "husky": "^8.0.3" + "turbo": "latest" }, "packageManager": "yarn@1.22.19" } diff --git a/space/.env.example b/space/.env.example index 4fb0e4df6..238f70854 100644 --- a/space/.env.example +++ b/space/.env.example @@ -1 +1,8 @@ -NEXT_PUBLIC_API_BASE_URL='' \ No newline at end of file +# Base url for the API requests +NEXT_PUBLIC_API_BASE_URL="" +# Public boards deploy URL +NEXT_PUBLIC_DEPLOY_URL="" +# Google Client ID for Google OAuth +NEXT_PUBLIC_GOOGLE_CLIENTID="" +# Flag to toggle OAuth +NEXT_PUBLIC_ENABLE_OAUTH=1 \ No newline at end of file diff --git a/space/components/accounts/index.ts b/space/components/accounts/index.ts index 093e8538c..03a173766 100644 --- a/space/components/accounts/index.ts +++ b/space/components/accounts/index.ts @@ -4,3 +4,5 @@ export * from "./email-reset-password-form"; export * from "./github-login-button"; export * from "./google-login"; export * from "./onboarding-form"; +export * from "./sign-in"; +export * from "./user-logged-in"; diff --git a/space/components/accounts/sign-in.tsx b/space/components/accounts/sign-in.tsx new file mode 100644 index 000000000..50d9c7da0 --- /dev/null +++ b/space/components/accounts/sign-in.tsx @@ -0,0 +1,156 @@ +import React from "react"; + +import Image from "next/image"; +import { useRouter } from "next/router"; + +// mobx +import { observer } from "mobx-react-lite"; +import { useMobxStore } from "lib/mobx/store-provider"; +// services +import authenticationService from "services/authentication.service"; +// hooks +import useToast from "hooks/use-toast"; +// components +import { EmailPasswordForm, GithubLoginButton, GoogleLoginButton, EmailCodeForm } from "components/accounts"; +// images +import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.svg"; + +export const SignInView = observer(() => { + const { user: userStore } = useMobxStore(); + + const router = useRouter(); + const { next_path } = router.query; + + const { setToastAlert } = useToast(); + + const onSignInError = (error: any) => { + setToastAlert({ + title: "Error signing in!", + type: "error", + message: error?.error || "Something went wrong. Please try again later or contact the support team.", + }); + }; + + const onSignInSuccess = (response: any) => { + const isOnboarded = response?.user?.onboarding_step?.profile_complete || false; + + userStore.setCurrentUser(response?.user); + + if (!isOnboarded) { + router.push(`/onboarding?next_path=${next_path}`); + return; + } + router.push((next_path ?? "/").toString()); + }; + + const handleGoogleSignIn = async ({ clientId, credential }: any) => { + try { + if (clientId && credential) { + const socialAuthPayload = { + medium: "google", + credential, + clientId, + }; + const response = await authenticationService.socialAuth(socialAuthPayload); + + onSignInSuccess(response); + } else { + throw Error("Cant find credentials"); + } + } catch (err: any) { + onSignInError(err); + } + }; + + const handleGitHubSignIn = async (credential: string) => { + try { + if (process.env.NEXT_PUBLIC_GITHUB_ID && credential) { + const socialAuthPayload = { + medium: "github", + credential, + clientId: process.env.NEXT_PUBLIC_GITHUB_ID, + }; + const response = await authenticationService.socialAuth(socialAuthPayload); + onSignInSuccess(response); + } else { + throw Error("Cant find credentials"); + } + } catch (err: any) { + onSignInError(err); + } + }; + + const handlePasswordSignIn = async (formData: any) => { + await authenticationService + .emailLogin(formData) + .then((response) => { + try { + if (response) { + onSignInSuccess(response); + } + } catch (err: any) { + onSignInError(err); + } + }) + .catch((err) => onSignInError(err)); + }; + + const handleEmailCodeSignIn = async (response: any) => { + try { + if (response) { + onSignInSuccess(response); + } + } catch (err: any) { + onSignInError(err); + } + }; + + return ( +
+
+
+
+
+ Plane Logo +
+
+
+
+
+ {parseInt(process.env.NEXT_PUBLIC_ENABLE_OAUTH || "0") ? ( + <> +

+ Sign in to Plane +

+
+
+ +
+
+ + {/* */} +
+
+ + ) : ( + + )} + + {parseInt(process.env.NEXT_PUBLIC_ENABLE_OAUTH || "0") ? ( +

+ By signing up, you agree to the{" "} + + Terms & Conditions + +

+ ) : null} +
+
+
+ ); +}); diff --git a/space/components/accounts/user-logged-in.tsx b/space/components/accounts/user-logged-in.tsx new file mode 100644 index 000000000..3f177bcc8 --- /dev/null +++ b/space/components/accounts/user-logged-in.tsx @@ -0,0 +1,51 @@ +import Image from "next/image"; + +// mobx +import { useMobxStore } from "lib/mobx/store-provider"; +// assets +import UserLoggedInImage from "public/user-logged-in.svg"; +import PlaneLogo from "public/plane-logos/black-horizontal-with-blue-logo.svg"; + +export const UserLoggedIn = () => { + const { user: userStore } = useMobxStore(); + const user = userStore.currentUser; + + if (!user) return null; + + return ( +
+
+
+ User already logged in +
+
+ {user.avatar && user.avatar !== "" ? ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {user.display_name +
+ ) : ( +
+ {(user.display_name ?? "U")[0]} +
+ )} +
{user.display_name}
+
+
+ +
+
+
+
+ User already logged in +
+
+

Logged in Successfully!

+

+ You{"'"}ve successfully logged in. Please enter the appropriate project URL to view the issue board. +

+
+
+
+ ); +}; diff --git a/space/components/views/home.tsx b/space/components/views/home.tsx new file mode 100644 index 000000000..999fce073 --- /dev/null +++ b/space/components/views/home.tsx @@ -0,0 +1,13 @@ +// mobx +import { observer } from "mobx-react-lite"; +import { useMobxStore } from "lib/mobx/store-provider"; +// components +import { SignInView, UserLoggedIn } from "components/accounts"; + +export const HomeView = observer(() => { + const { user: userStore } = useMobxStore(); + + if (!userStore.currentUser) return ; + + return ; +}); diff --git a/space/components/views/index.ts b/space/components/views/index.ts new file mode 100644 index 000000000..84d36cd29 --- /dev/null +++ b/space/components/views/index.ts @@ -0,0 +1 @@ +export * from "./home"; diff --git a/space/components/views/project-details.tsx b/space/components/views/project-details.tsx index c0756335f..1c9c6ddc9 100644 --- a/space/components/views/project-details.tsx +++ b/space/components/views/project-details.tsx @@ -1,5 +1,9 @@ import { useEffect } from "react"; + +import Image from "next/image"; import { useRouter } from "next/router"; + +// mobx import { observer } from "mobx-react-lite"; // components import { IssueListView } from "components/issues/board-views/list"; @@ -11,6 +15,8 @@ import { IssuePeekOverview } from "components/issues/peek-overview"; // mobx store import { RootStore } from "store/root"; import { useMobxStore } from "lib/mobx/store-provider"; +// assets +import SomethingWentWrongImage from "public/something-went-wrong.svg"; export const ProjectDetailsView = observer(() => { const router = useRouter(); @@ -55,8 +61,16 @@ export const ProjectDetailsView = observer(() => { ) : ( <> {issueStore?.error ? ( -
- Something went wrong. +
+
+
+
+ Oops! Something went wrong +
+
+

Oops! Something went wrong.

+

The public board does not exist. Please check the URL.

+
) : ( projectStore?.activeBoard && ( diff --git a/space/next.config.js b/space/next.config.js index 712c1c472..392a4cab9 100644 --- a/space/next.config.js +++ b/space/next.config.js @@ -13,6 +13,7 @@ const nextConfig = { if (parseInt(process.env.NEXT_PUBLIC_DEPLOY_WITH_NGINX || "0")) { const nextConfigWithNginx = withImages({ basePath: "/spaces", ...nextConfig }); + module.exports = nextConfigWithNginx; } else { module.exports = nextConfig; } diff --git a/space/pages/_app.tsx b/space/pages/_app.tsx index 2995edbbf..33c137d41 100644 --- a/space/pages/_app.tsx +++ b/space/pages/_app.tsx @@ -12,6 +12,8 @@ import MobxStoreInit from "lib/mobx/store-init"; // constants import { SITE_NAME, SITE_DESCRIPTION, SITE_URL, TWITTER_USER_NAME, SITE_KEYWORDS, SITE_TITLE } from "constants/seo"; +const prefix = parseInt(process.env.NEXT_PUBLIC_DEPLOY_WITH_NGINX || "0") === 0 ? "/" : "/spaces/"; + function MyApp({ Component, pageProps }: AppProps) { return ( @@ -25,11 +27,11 @@ function MyApp({ Component, pageProps }: AppProps) { - - - - - + + + + + diff --git a/space/pages/index.tsx b/space/pages/index.tsx index 87a291441..fe0b7d33a 100644 --- a/space/pages/index.tsx +++ b/space/pages/index.tsx @@ -1,156 +1,8 @@ -import React, { useEffect } from "react"; -import Image from "next/image"; -import { useRouter } from "next/router"; -// assets -import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.svg"; -// mobx -import { observer } from "mobx-react-lite"; -import { useMobxStore } from "lib/mobx/store-provider"; -// services -import authenticationService from "services/authentication.service"; -// hooks -import useToast from "hooks/use-toast"; +import React from "react"; + // components -import { EmailPasswordForm, GithubLoginButton, GoogleLoginButton, EmailCodeForm } from "components/accounts"; +import { HomeView } from "components/views"; -const HomePage = () => { - const { user: userStore } = useMobxStore(); - - const router = useRouter(); - const { next_path } = router.query; - - const { setToastAlert } = useToast(); - - const onSignInError = (error: any) => { - setToastAlert({ - title: "Error signing in!", - type: "error", - message: error?.error || "Something went wrong. Please try again later or contact the support team.", - }); - }; - - const onSignInSuccess = (response: any) => { - const isOnboarded = response?.user?.onboarding_step?.profile_complete || false; - - userStore.setCurrentUser(response?.user); - - if (!isOnboarded) { - router.push(`/onboarding?next_path=${next_path}`); - return; - } - router.push((next_path ?? "/").toString()); - }; - - const handleGoogleSignIn = async ({ clientId, credential }: any) => { - try { - if (clientId && credential) { - const socialAuthPayload = { - medium: "google", - credential, - clientId, - }; - const response = await authenticationService.socialAuth(socialAuthPayload); - - onSignInSuccess(response); - } else { - throw Error("Cant find credentials"); - } - } catch (err: any) { - onSignInError(err); - } - }; - - const handleGitHubSignIn = async (credential: string) => { - try { - if (process.env.NEXT_PUBLIC_GITHUB_ID && credential) { - const socialAuthPayload = { - medium: "github", - credential, - clientId: process.env.NEXT_PUBLIC_GITHUB_ID, - }; - const response = await authenticationService.socialAuth(socialAuthPayload); - onSignInSuccess(response); - } else { - throw Error("Cant find credentials"); - } - } catch (err: any) { - onSignInError(err); - } - }; - - const handlePasswordSignIn = async (formData: any) => { - await authenticationService - .emailLogin(formData) - .then((response) => { - try { - if (response) { - onSignInSuccess(response); - } - } catch (err: any) { - onSignInError(err); - } - }) - .catch((err) => onSignInError(err)); - }; - - const handleEmailCodeSignIn = async (response: any) => { - try { - if (response) { - onSignInSuccess(response); - } - } catch (err: any) { - onSignInError(err); - } - }; - - return ( -
-
-
-
-
- Plane Logo -
-
-
-
-
- {parseInt(process.env.NEXT_PUBLIC_ENABLE_OAUTH || "0") ? ( - <> -

- Sign in to Plane -

-
-
- -
-
- - {/* */} -
-
- - ) : ( - - )} - - {parseInt(process.env.NEXT_PUBLIC_ENABLE_OAUTH || "0") ? ( -

- By signing up, you agree to the{" "} - - Terms & Conditions - -

- ) : null} -
-
-
- ); -}; +const HomePage = () => ; export default HomePage; diff --git a/space/public/site.webmanifest.json b/space/public/site.webmanifest.json new file mode 100644 index 000000000..4c32ec6e3 --- /dev/null +++ b/space/public/site.webmanifest.json @@ -0,0 +1,13 @@ +{ + "name": "Plane Space", + "short_name": "Plane Space", + "description": "Plane helps you plan your issues, cycles, and product modules.", + "start_url": ".", + "display": "standalone", + "background_color": "#f9fafb", + "theme_color": "#3f76ff", + "icons": [ + { "src": "/favicon/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "/favicon/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" } + ] +} diff --git a/space/public/something-went-wrong.svg b/space/public/something-went-wrong.svg new file mode 100644 index 000000000..bd51f7f49 --- /dev/null +++ b/space/public/something-went-wrong.svg @@ -0,0 +1,3 @@ + + + diff --git a/space/public/user-logged-in.svg b/space/public/user-logged-in.svg new file mode 100644 index 000000000..e20b49e82 --- /dev/null +++ b/space/public/user-logged-in.svg @@ -0,0 +1,3 @@ + + + diff --git a/web/.env.example b/web/.env.example new file mode 100644 index 000000000..50a6209b2 --- /dev/null +++ b/web/.env.example @@ -0,0 +1,26 @@ +# Base url for the API requests +NEXT_PUBLIC_API_BASE_URL="" +# Extra image domains that need to be added for Next Image +NEXT_PUBLIC_EXTRA_IMAGE_DOMAINS= +# Google Client ID for Google OAuth +NEXT_PUBLIC_GOOGLE_CLIENTID="" +# GitHub App ID for GitHub OAuth +NEXT_PUBLIC_GITHUB_ID="" +# GitHub App Name for GitHub Integration +NEXT_PUBLIC_GITHUB_APP_NAME="" +# Sentry DSN for error monitoring +NEXT_PUBLIC_SENTRY_DSN="" +# Enable/Disable OAUTH - default 0 for selfhosted instance +NEXT_PUBLIC_ENABLE_OAUTH=0 +# Enable/Disable Sentry +NEXT_PUBLIC_ENABLE_SENTRY=0 +# Enable/Disable session recording +NEXT_PUBLIC_ENABLE_SESSION_RECORDER=0 +# Enable/Disable event tracking +NEXT_PUBLIC_TRACK_EVENTS=0 +# Slack Client ID for Slack Integration +NEXT_PUBLIC_SLACK_CLIENT_ID="" +# For Telemetry, set it to "app.plane.so" +NEXT_PUBLIC_PLAUSIBLE_DOMAIN="" +# Public boards deploy URL +NEXT_PUBLIC_DEPLOY_URL="" \ No newline at end of file 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..7d6582869 --- /dev/null +++ b/web/components/project/confirm-project-leave-modal.tsx @@ -0,0 +1,211 @@ +import React from "react"; +// next imports +import { useRouter } from "next/router"; +// 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"; +import useProjects from "hooks/use-projects"; +// 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 { mutateProjects } = useProjects(); + + const { setToastAlert } = useToast(); + + const { + control, + formState: { isSubmitting }, + handleSubmit, + reset, + watch, + } = useForm({ defaultValues }); + + const handleClose = () => { + project.handleProjectLeaveModal(null); + + reset({ ...defaultValues }); + }; + + 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) => { + mutateProjects(); + 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/send-project-invitation-modal.tsx b/web/components/project/send-project-invitation-modal.tsx index 035a680f2..b8f383f05 100644 --- a/web/components/project/send-project-invitation-modal.tsx +++ b/web/components/project/send-project-invitation-modal.tsx @@ -85,7 +85,8 @@ const SendProjectInvitationModal: React.FC = (props) => { }); const uninvitedPeople = people?.filter((person) => { - const isInvited = members?.find((member) => member.display_name === person.member.display_name); + const isInvited = members?.find((member) => member.memberId === person.member.id); + return !isInvited; }); @@ -143,7 +144,7 @@ const SendProjectInvitationModal: React.FC = (props) => { content: (
- {person.member.display_name} + {person.member.display_name} ({person.member.first_name + " " + person.member.last_name})
), })); 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); }