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 (
+
+
+
+
+
+ {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.avatar && user.avatar !== "" ? (
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+
+
+ ) : (
+
+ {(user.display_name ?? "U")[0]}
+
+ )}
+
{user.display_name}
+
+
+
+
+
+
+
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.
+
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 (
-
-
-
-
-
- {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 (
+
+
+
+ );
+});
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/`)
- }
- >
-
-
- )}
+ {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);
}