diff --git a/.gitpod.yml b/.gitpod.yml deleted file mode 100644 index f2bf4259f..000000000 --- a/.gitpod.yml +++ /dev/null @@ -1,11 +0,0 @@ -# This configuration file was automatically generated by Gitpod. -# Please adjust to your needs (see https://www.gitpod.io/docs/introduction/learn-gitpod/gitpod-yaml) -# and commit this file to your remote git repository to share the goodness with others. - -# Learn more from ready-to-use templates: https://www.gitpod.io/docs/introduction/getting-started/quickstart - -tasks: - - init: yarn install && yarn run build - command: yarn run start - - diff --git a/apiserver/plane/api/views/cycle.py b/apiserver/plane/api/views/cycle.py index 3dca6c312..253da2c5b 100644 --- a/apiserver/plane/api/views/cycle.py +++ b/apiserver/plane/api/views/cycle.py @@ -333,13 +333,21 @@ class CycleViewSet(BaseViewSet): workspace__slug=slug, project_id=project_id, pk=pk ) + request_data = request.data + if cycle.end_date is not None and cycle.end_date < timezone.now().date(): - return Response( - { - "error": "The Cycle has already been completed so it cannot be edited" - }, - status=status.HTTP_400_BAD_REQUEST, - ) + if "sort_order" in request_data: + # Can only change sort order + request_data = { + "sort_order": request_data.get("sort_order", cycle.sort_order) + } + else: + return Response( + { + "error": "The Cycle has already been completed so it cannot be edited" + }, + status=status.HTTP_400_BAD_REQUEST, + ) serializer = CycleWriteSerializer(cycle, data=request.data, partial=True) if serializer.is_valid(): @@ -373,7 +381,9 @@ class CycleViewSet(BaseViewSet): .annotate(assignee_id=F("assignees__id")) .annotate(avatar=F("assignees__avatar")) .annotate(display_name=F("assignees__display_name")) - .values("first_name", "last_name", "assignee_id", "avatar", "display_name") + .values( + "first_name", "last_name", "assignee_id", "avatar", "display_name" + ) .annotate(total_issues=Count("assignee_id")) .annotate( completed_issues=Count( @@ -709,7 +719,6 @@ class CycleDateCheckEndpoint(BaseAPIView): class CycleFavoriteViewSet(BaseViewSet): - serializer_class = CycleFavoriteSerializer model = CycleFavorite diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index 3d6b59c7f..a390f7b81 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -17,6 +17,7 @@ from django.db.models import ( When, Exists, Max, + IntegerField, ) from django.core.serializers.json import DjangoJSONEncoder from django.utils.decorators import method_decorator @@ -337,7 +338,11 @@ class UserWorkSpaceIssues(BaseAPIView): issue_queryset = ( Issue.issue_objects.filter( - (Q(assignees__in=[request.user]) | Q(created_by=request.user) | Q(issue_subscribers__subscriber=request.user)), + ( + Q(assignees__in=[request.user]) + | Q(created_by=request.user) + | Q(issue_subscribers__subscriber=request.user) + ), workspace__slug=slug, ) .annotate( @@ -1994,7 +1999,9 @@ class IssueVotePublicViewSet(BaseViewSet): serializer = IssueVoteSerializer(issue_vote) return Response(serializer.data, status=status.HTTP_201_CREATED) except IntegrityError: - return Response({"error": "Reaction already exists"}, status=status.HTTP_400_BAD_REQUEST) + return Response( + {"error": "Reaction already exists"}, status=status.HTTP_400_BAD_REQUEST + ) except Exception as e: capture_exception(e) return Response( @@ -2172,9 +2179,32 @@ class ProjectIssuesPublicEndpoint(BaseAPIView): issues = IssuePublicSerializer(issue_queryset, many=True).data - states = State.objects.filter( - workspace__slug=slug, project_id=project_id - ).values("name", "group", "color", "id") + state_group_order = [ + "backlog", + "unstarted", + "started", + "completed", + "cancelled", + ] + + states = ( + State.objects.filter( + workspace__slug=slug, + project_id=project_id, + ) + .annotate( + custom_order=Case( + *[ + When(group=value, then=Value(index)) + for index, value in enumerate(state_group_order) + ], + default=Value(len(state_group_order)), + output_field=IntegerField(), + ), + ) + .values("name", "group", "color", "id") + .order_by("custom_order", "sequence") + ) labels = Label.objects.filter( workspace__slug=slug, project_id=project_id diff --git a/space/.env.example b/space/.env.example index 4fb0e4df6..2d3165893 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="https://plane-space-dev.vercel.app" +# Google Client ID for Google OAuth +NEXT_PUBLIC_GOOGLE_CLIENTID=232920797020-235n93bn7hh7628vdd69hq873129ng4o.apps.googleusercontent.com +# 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/icons/index.ts b/space/components/icons/index.ts index 5f23e0f3a..28162f591 100644 --- a/space/components/icons/index.ts +++ b/space/components/icons/index.ts @@ -1,5 +1 @@ -export * from "./issue-group/backlog-state-icon"; -export * from "./issue-group/unstarted-state-icon"; -export * from "./issue-group/started-state-icon"; -export * from "./issue-group/completed-state-icon"; -export * from "./issue-group/cancelled-state-icon"; +export * from "./state-group"; diff --git a/space/components/icons/issue-group/backlog-state-icon.tsx b/space/components/icons/state-group/backlog-state-icon.tsx similarity index 100% rename from space/components/icons/issue-group/backlog-state-icon.tsx rename to space/components/icons/state-group/backlog-state-icon.tsx diff --git a/space/components/icons/issue-group/cancelled-state-icon.tsx b/space/components/icons/state-group/cancelled-state-icon.tsx similarity index 100% rename from space/components/icons/issue-group/cancelled-state-icon.tsx rename to space/components/icons/state-group/cancelled-state-icon.tsx diff --git a/space/components/icons/issue-group/completed-state-icon.tsx b/space/components/icons/state-group/completed-state-icon.tsx similarity index 100% rename from space/components/icons/issue-group/completed-state-icon.tsx rename to space/components/icons/state-group/completed-state-icon.tsx diff --git a/space/components/icons/state-group/index.ts b/space/components/icons/state-group/index.ts new file mode 100644 index 000000000..6ede38df6 --- /dev/null +++ b/space/components/icons/state-group/index.ts @@ -0,0 +1,6 @@ +export * from "./backlog-state-icon"; +export * from "./cancelled-state-icon"; +export * from "./completed-state-icon"; +export * from "./started-state-icon"; +export * from "./state-group-icon"; +export * from "./unstarted-state-icon"; diff --git a/space/components/icons/issue-group/started-state-icon.tsx b/space/components/icons/state-group/started-state-icon.tsx similarity index 100% rename from space/components/icons/issue-group/started-state-icon.tsx rename to space/components/icons/state-group/started-state-icon.tsx diff --git a/space/components/icons/state-group/state-group-icon.tsx b/space/components/icons/state-group/state-group-icon.tsx new file mode 100644 index 000000000..1af523400 --- /dev/null +++ b/space/components/icons/state-group/state-group-icon.tsx @@ -0,0 +1,29 @@ +// icons +import { + BacklogStateIcon, + CancelledStateIcon, + CompletedStateIcon, + StartedStateIcon, + UnstartedStateIcon, +} from "components/icons"; +import { TIssueGroupKey } from "types/issue"; + +type Props = { + stateGroup: TIssueGroupKey; + color: string; + className?: string; + height?: string; + width?: string; +}; + +export const StateGroupIcon: React.FC = ({ stateGroup, className, color, height = "12px", width = "12px" }) => { + if (stateGroup === "backlog") + return ; + else if (stateGroup === "cancelled") + return ; + else if (stateGroup === "completed") + return ; + else if (stateGroup === "started") + return ; + else return ; +}; diff --git a/space/components/icons/issue-group/unstarted-state-icon.tsx b/space/components/icons/state-group/unstarted-state-icon.tsx similarity index 100% rename from space/components/icons/issue-group/unstarted-state-icon.tsx rename to space/components/icons/state-group/unstarted-state-icon.tsx diff --git a/space/components/issues/board-views/kanban/header.tsx b/space/components/issues/board-views/kanban/header.tsx index 69c252593..5645e2b3b 100644 --- a/space/components/issues/board-views/kanban/header.tsx +++ b/space/components/issues/board-views/kanban/header.tsx @@ -1,11 +1,11 @@ -"use client"; - // mobx react lite import { observer } from "mobx-react-lite"; // interfaces import { IIssueState } from "types/issue"; // constants import { issueGroupFilter } from "constants/data"; +// icons +import { StateGroupIcon } from "components/icons"; // mobx hook import { useMobxStore } from "lib/mobx/store-provider"; import { RootStore } from "store/root"; @@ -20,7 +20,7 @@ export const IssueListHeader = observer(({ state }: { state: IIssueState }) => { return (
- +
{state?.name}
diff --git a/space/components/issues/board-views/list/block.tsx b/space/components/issues/board-views/list/block.tsx index 2d1cdf9ba..bdf39b84f 100644 --- a/space/components/issues/board-views/list/block.tsx +++ b/space/components/issues/board-views/list/block.tsx @@ -6,15 +6,12 @@ import { IssueBlockPriority } from "components/issues/board-views/block-priority import { IssueBlockState } from "components/issues/board-views/block-state"; import { IssueBlockLabels } from "components/issues/board-views/block-labels"; import { IssueBlockDueDate } from "components/issues/board-views/block-due-date"; -import { IssueBlockUpVotes } from "components/issues/board-views/block-upvotes"; -import { IssueBlockDownVotes } from "components/issues/board-views/block-downvotes"; // mobx hook import { useMobxStore } from "lib/mobx/store-provider"; // interfaces import { IIssue } from "types/issue"; // store import { RootStore } from "store/root"; -import { IssueVotes } from "components/issues/peek-overview"; export const IssueListBlock: FC<{ issue: IIssue }> = observer((props) => { const { issue } = props; @@ -40,9 +37,6 @@ export const IssueListBlock: FC<{ issue: IIssue }> = observer((props) => { // router.push(`/${workspace_slug?.toString()}/${project_slug}?board=${board?.toString()}&peekId=${issue.id}`); }; - const totalUpVotes = issue.votes.filter((v) => v.vote === 1); - const totalDownVotes = issue.votes.filter((v) => v.vote === -1); - return (
diff --git a/space/components/issues/board-views/list/header.tsx b/space/components/issues/board-views/list/header.tsx index 546c20bf6..83312e7b9 100644 --- a/space/components/issues/board-views/list/header.tsx +++ b/space/components/issues/board-views/list/header.tsx @@ -1,9 +1,9 @@ -"use client"; - // mobx react lite import { observer } from "mobx-react-lite"; // interfaces import { IIssueState } from "types/issue"; +// icons +import { StateGroupIcon } from "components/icons"; // constants import { issueGroupFilter } from "constants/data"; // mobx hook @@ -20,7 +20,7 @@ export const IssueListHeader = observer(({ state }: { state: IIssueState }) => { return (
- +
{state?.name}
{store.issue.getCountOfIssuesByState(state.id)}
diff --git a/space/components/issues/board-views/list/index.tsx b/space/components/issues/board-views/list/index.tsx index 4d4701840..1c6900dd9 100644 --- a/space/components/issues/board-views/list/index.tsx +++ b/space/components/issues/board-views/list/index.tsx @@ -1,4 +1,3 @@ -import { useEffect } from "react"; import { observer } from "mobx-react-lite"; // components import { IssueListHeader } from "components/issues/board-views/list/header"; @@ -9,7 +8,6 @@ import { IIssueState, IIssue } from "types/issue"; import { useMobxStore } from "lib/mobx/store-provider"; // store import { RootStore } from "store/root"; -import { useRouter } from "next/router"; export const IssueListView = observer(() => { const { issue: issueStore }: RootStore = useMobxStore(); diff --git a/space/components/issues/peek-overview/header.tsx b/space/components/issues/peek-overview/header.tsx index 79de3978b..2aa43ff47 100644 --- a/space/components/issues/peek-overview/header.tsx +++ b/space/components/issues/peek-overview/header.tsx @@ -1,7 +1,5 @@ import React from "react"; -import { useRouter } from "next/router"; - // headless ui import { Listbox, Transition } from "@headlessui/react"; // hooks @@ -48,15 +46,12 @@ export const PeekOverviewHeader: React.FC = (props) => { const { issueDetails: issueDetailStore }: RootStore = useMobxStore(); - const router = useRouter(); - const { workspace_slug } = router.query; - const { setToastAlert } = useToast(); const handleCopyLink = () => { - const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : ""; + const urlToCopy = window.location.href; - copyTextToClipboard(`${originURL}/${workspace_slug}/projects/${issueDetails?.project}/`).then(() => { + copyTextToClipboard(urlToCopy).then(() => { setToastAlert({ type: "success", title: "Link copied!", diff --git a/space/components/issues/peek-overview/issue-properties.tsx b/space/components/issues/peek-overview/issue-properties.tsx index 2d454852a..6b3394b56 100644 --- a/space/components/issues/peek-overview/issue-properties.tsx +++ b/space/components/issues/peek-overview/issue-properties.tsx @@ -1,21 +1,15 @@ -// headless ui -import { Disclosure } from "@headlessui/react"; -// import { getStateGroupIcon } from "components/icons"; // hooks import useToast from "hooks/use-toast"; // icons import { Icon } from "components/ui"; import { copyTextToClipboard, addSpaceIfCamelCase } from "helpers/string.helper"; +// helpers +import { renderDateFormat } from "constants/helpers"; // types import { IIssue } from "types/issue"; +import { IPeekMode } from "store/issue_details"; // constants import { issueGroupFilter, issuePriorityFilter } from "constants/data"; -import { useEffect } from "react"; -import { renderDateFormat } from "constants/helpers"; -import { IPeekMode } from "store/issue_details"; -import { useRouter } from "next/router"; -import { RootStore } from "store/root"; -import { useMobxStore } from "lib/mobx/store-provider"; type Props = { issueDetails: IIssue; @@ -37,11 +31,6 @@ const validDate = (date: any, state: string): string => { export const PeekOverviewIssueProperties: React.FC = ({ issueDetails, mode }) => { const { setToastAlert } = useToast(); - const { issueDetails: issueDetailStore }: RootStore = useMobxStore(); - - const router = useRouter(); - const { workspaceSlug } = router.query; - const startDate = issueDetails.start_date; const targetDate = issueDetails.target_date; @@ -57,11 +46,9 @@ export const PeekOverviewIssueProperties: React.FC = ({ issueDetails, mod const priority = issueDetails.priority ? issuePriorityFilter(issueDetails.priority) : null; const handleCopyLink = () => { - const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : ""; + const urlToCopy = window.location.href; - copyTextToClipboard( - `${originURL}/${workspaceSlug}/projects/${issueDetails.project}/issues/${issueDetails.id}` - ).then(() => { + copyTextToClipboard(urlToCopy).then(() => { setToastAlert({ type: "success", title: "Link copied!", diff --git a/space/components/issues/peek-overview/layout.tsx b/space/components/issues/peek-overview/layout.tsx index 09cfa5b78..a3d7386eb 100644 --- a/space/components/issues/peek-overview/layout.tsx +++ b/space/components/issues/peek-overview/layout.tsx @@ -65,26 +65,24 @@ export const IssuePeekOverview: React.FC = observer((props) => { return ( <> - -
- - - - - -
+ + + + + +
- + = observer((props) => { >
-
- - -
- {issueDetailStore.peekMode === "modal" && ( - - )} - {issueDetailStore.peekMode === "full" && ( - - )} -
-
-
-
+ + +
+ {issueDetailStore.peekMode === "modal" && ( + + )} + {issueDetailStore.peekMode === "full" && ( + + )} +
+
+
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 bbf043130..9a6cd824c 100644 --- a/space/components/views/project-details.tsx +++ b/space/components/views/project-details.tsx @@ -55,7 +55,7 @@ export const ProjectDetailsView = observer(() => { ) : ( <> {issueStore?.error ? ( -
+
Something went wrong.
) : ( @@ -67,7 +67,7 @@ export const ProjectDetailsView = observer(() => {
)} {projectStore?.activeBoard === "kanban" && ( -
+
)} diff --git a/space/layouts/project-layout.tsx b/space/layouts/project-layout.tsx index f19ddabd2..1a0b7899e 100644 --- a/space/layouts/project-layout.tsx +++ b/space/layouts/project-layout.tsx @@ -13,18 +13,20 @@ const ProjectLayout = ({ children }: { children: React.ReactNode }) => (
{children}
- + + +
+ Plane logo +
+
+ Powered by Plane Deploy +
+
); 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/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/cycles/gantt-chart/cycle-issues-layout.tsx b/web/components/cycles/gantt-chart/cycle-issues-layout.tsx index 7741432ce..c18bc9346 100644 --- a/web/components/cycles/gantt-chart/cycle-issues-layout.tsx +++ b/web/components/cycles/gantt-chart/cycle-issues-layout.tsx @@ -5,6 +5,7 @@ import useIssuesView from "hooks/use-issues-view"; import useUser from "hooks/use-user"; import useGanttChartCycleIssues from "hooks/gantt-chart/cycle-issues-view"; import { updateGanttIssue } from "components/gantt-chart/hooks/block-update"; +import useProjectDetails from "hooks/use-project-details"; // components import { GanttChartRoot, renderIssueBlocksStructure } from "components/gantt-chart"; import { IssueGanttBlock, IssueGanttSidebarBlock } from "components/issues"; @@ -18,6 +19,7 @@ export const CycleIssuesGanttChartView = () => { const { orderBy } = useIssuesView(); const { user } = useUser(); + const { projectDetails } = useProjectDetails(); const { ganttIssues, mutateGanttIssues } = useGanttChartCycleIssues( workspaceSlug as string, @@ -25,6 +27,8 @@ export const CycleIssuesGanttChartView = () => { cycleId as string ); + const isAllowed = projectDetails?.member_role === 20 || projectDetails?.member_role === 15; + return (
{ } SidebarBlockRender={IssueGanttSidebarBlock} BlockRender={IssueGanttBlock} - enableReorder={orderBy === "sort_order"} + enableBlockLeftResize={isAllowed} + enableBlockRightResize={isAllowed} + enableBlockMove={isAllowed} + enableReorder={orderBy === "sort_order" && isAllowed} bottomSpacing />
diff --git a/web/components/cycles/gantt-chart/cycles-list-layout.tsx b/web/components/cycles/gantt-chart/cycles-list-layout.tsx index a5b576bca..9614ea447 100644 --- a/web/components/cycles/gantt-chart/cycles-list-layout.tsx +++ b/web/components/cycles/gantt-chart/cycles-list-layout.tsx @@ -8,6 +8,7 @@ import { KeyedMutator } from "swr"; import cyclesService from "services/cycles.service"; // hooks import useUser from "hooks/use-user"; +import useProjectDetails from "hooks/use-project-details"; // components import { GanttChartRoot, IBlockUpdateData } from "components/gantt-chart"; import { CycleGanttBlock, CycleGanttSidebarBlock } from "components/cycles"; @@ -24,6 +25,7 @@ export const CyclesListGanttChartView: FC = ({ cycles, mutateCycles }) => const { workspaceSlug } = router.query; const { user } = useUser(); + const { projectDetails } = useProjectDetails(); const handleCycleUpdate = (cycle: ICycle, payload: IBlockUpdateData) => { if (!workspaceSlug || !user) return; @@ -71,6 +73,8 @@ export const CyclesListGanttChartView: FC = ({ cycles, mutateCycles }) => })) : []; + const isAllowed = projectDetails?.member_role === 20 || projectDetails?.member_role === 15; + return (
= ({ cycles, mutateCycles }) => enableBlockLeftResize={false} enableBlockRightResize={false} enableBlockMove={false} + enableReorder={isAllowed} />
); diff --git a/web/components/issues/gantt-chart/layout.tsx b/web/components/issues/gantt-chart/layout.tsx index a42d764d8..39e169a60 100644 --- a/web/components/issues/gantt-chart/layout.tsx +++ b/web/components/issues/gantt-chart/layout.tsx @@ -5,6 +5,7 @@ import useIssuesView from "hooks/use-issues-view"; import useUser from "hooks/use-user"; import useGanttChartIssues from "hooks/gantt-chart/issue-view"; import { updateGanttIssue } from "components/gantt-chart/hooks/block-update"; +import useProjectDetails from "hooks/use-project-details"; // components import { GanttChartRoot, renderIssueBlocksStructure } from "components/gantt-chart"; import { IssueGanttBlock, IssueGanttSidebarBlock } from "components/issues"; @@ -18,12 +19,15 @@ export const IssueGanttChartView = () => { const { orderBy } = useIssuesView(); const { user } = useUser(); + const { projectDetails } = useProjectDetails(); const { ganttIssues, mutateGanttIssues } = useGanttChartIssues( workspaceSlug as string, projectId as string ); + const isAllowed = projectDetails?.member_role === 20 || projectDetails?.member_role === 15; + return (
{ } BlockRender={IssueGanttBlock} SidebarBlockRender={IssueGanttSidebarBlock} - enableReorder={orderBy === "sort_order"} + enableBlockLeftResize={isAllowed} + enableBlockRightResize={isAllowed} + enableBlockMove={isAllowed} + enableReorder={orderBy === "sort_order" && isAllowed} bottomSpacing />
diff --git a/web/components/issues/peek-overview/issue-properties.tsx b/web/components/issues/peek-overview/issue-properties.tsx index 1f2d618ac..16728b148 100644 --- a/web/components/issues/peek-overview/issue-properties.tsx +++ b/web/components/issues/peek-overview/issue-properties.tsx @@ -50,12 +50,9 @@ export const PeekOverviewIssueProperties: React.FC = ({ maxDate?.setDate(maxDate.getDate()); 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!", diff --git a/web/components/modules/gantt-chart/module-issues-layout.tsx b/web/components/modules/gantt-chart/module-issues-layout.tsx index 9c0b05078..c350232e9 100644 --- a/web/components/modules/gantt-chart/module-issues-layout.tsx +++ b/web/components/modules/gantt-chart/module-issues-layout.tsx @@ -7,6 +7,7 @@ import useIssuesView from "hooks/use-issues-view"; import useUser from "hooks/use-user"; import useGanttChartModuleIssues from "hooks/gantt-chart/module-issues-view"; import { updateGanttIssue } from "components/gantt-chart/hooks/block-update"; +import useProjectDetails from "hooks/use-project-details"; // components import { GanttChartRoot, renderIssueBlocksStructure } from "components/gantt-chart"; import { IssueGanttBlock, IssueGanttSidebarBlock } from "components/issues"; @@ -22,6 +23,7 @@ export const ModuleIssuesGanttChartView: FC = ({}) => { const { orderBy } = useIssuesView(); const { user } = useUser(); + const { projectDetails } = useProjectDetails(); const { ganttIssues, mutateGanttIssues } = useGanttChartModuleIssues( workspaceSlug as string, @@ -29,6 +31,8 @@ export const ModuleIssuesGanttChartView: FC = ({}) => { moduleId as string ); + const isAllowed = projectDetails?.member_role === 20 || projectDetails?.member_role === 15; + return (
= ({}) => { } SidebarBlockRender={IssueGanttSidebarBlock} BlockRender={IssueGanttBlock} - enableReorder={orderBy === "sort_order"} + enableBlockLeftResize={isAllowed} + enableBlockRightResize={isAllowed} + enableBlockMove={isAllowed} + enableReorder={orderBy === "sort_order" && isAllowed} bottomSpacing />
diff --git a/web/components/modules/gantt-chart/modules-list-layout.tsx b/web/components/modules/gantt-chart/modules-list-layout.tsx index 70f493dde..08465ffa9 100644 --- a/web/components/modules/gantt-chart/modules-list-layout.tsx +++ b/web/components/modules/gantt-chart/modules-list-layout.tsx @@ -1,7 +1,6 @@ import { FC } from "react"; import { useRouter } from "next/router"; -import Link from "next/link"; import { KeyedMutator } from "swr"; @@ -9,6 +8,7 @@ import { KeyedMutator } from "swr"; import modulesService from "services/modules.service"; // hooks import useUser from "hooks/use-user"; +import useProjectDetails from "hooks/use-project-details"; // components import { GanttChartRoot, IBlockUpdateData } from "components/gantt-chart"; import { ModuleGanttBlock, ModuleGanttSidebarBlock } from "components/modules"; @@ -25,6 +25,7 @@ export const ModulesListGanttChartView: FC = ({ modules, mutateModules }) const { workspaceSlug } = router.query; const { user } = useUser(); + const { projectDetails } = useProjectDetails(); const handleModuleUpdate = (module: IModule, payload: IBlockUpdateData) => { if (!workspaceSlug || !user) return; @@ -78,6 +79,8 @@ export const ModulesListGanttChartView: FC = ({ modules, mutateModules }) })) : []; + const isAllowed = projectDetails?.member_role === 20 || projectDetails?.member_role === 15; + return (
= ({ modules, mutateModules }) blockUpdateHandler={(block, payload) => handleModuleUpdate(block, payload)} SidebarBlockRender={ModuleGanttSidebarBlock} BlockRender={ModuleGanttBlock} + enableBlockLeftResize={isAllowed} + enableBlockRightResize={isAllowed} + enableBlockMove={isAllowed} + enableReorder={isAllowed} />
); 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/publish-project/modal.tsx b/web/components/project/publish-project/modal.tsx index b22a496f5..173a5242c 100644 --- a/web/components/project/publish-project/modal.tsx +++ b/web/components/project/publish-project/modal.tsx @@ -6,7 +6,14 @@ import { Controller, useForm } from "react-hook-form"; // headless ui import { Dialog, Transition } from "@headlessui/react"; // ui components -import { ToggleSwitch, PrimaryButton, SecondaryButton, Icon, DangerButton } from "components/ui"; +import { + ToggleSwitch, + PrimaryButton, + SecondaryButton, + Icon, + DangerButton, + Loader, +} from "components/ui"; import { CustomPopover } from "./popover"; // mobx react lite import { observer } from "mobx-react-lite"; @@ -146,22 +153,14 @@ export const PublishProjectModal: React.FC = observer(() => { const projectId = projectPublish.project_id; return projectPublish - .createProjectSettingsAsync( - workspaceSlug.toString(), - projectId?.toString() ?? "", - payload, - user - ) - .then((response) => { + .publishProject(workspaceSlug.toString(), projectId?.toString() ?? "", payload, user) + .then((res) => { mutateProjectDetails(); handleClose(); if (projectId) window.open(`${plane_deploy_url}/${workspaceSlug}/${projectId}`, "_blank"); - return response; + return res; }) - .catch((error) => { - console.error("error", error); - return error; - }); + .catch((err) => err); }; const handleUpdatePublishSettings = async (payload: IProjectPublishSettings) => { @@ -199,7 +198,7 @@ export const PublishProjectModal: React.FC = observer(() => { setIsUnpublishing(true); projectPublish - .deleteProjectSettingsAsync( + .unPublishProject( workspaceSlug.toString(), projectPublish.project_id as string, publishId, @@ -329,7 +328,7 @@ export const PublishProjectModal: React.FC = observer(() => { {/* heading */}
Publish
- {watch("id") && ( + {projectPublish.projectPublishSettings !== "not-initialized" && ( handleUnpublishProject(watch("id") ?? "")} className="!px-2 !py-1.5" @@ -341,137 +340,145 @@ export const PublishProjectModal: React.FC = observer(() => {
{/* content */} -
-
-
- {`${plane_deploy_url}/${workspaceSlug}/${projectPublish.project_id}`} -
-
- -
-
+ {projectPublish.fetchSettingsLoader ? ( + + + + + + + ) : ( +
+ {watch("id") && ( + <> +
+
+ {`${plane_deploy_url}/${workspaceSlug}/${projectPublish.project_id}`} +
+
+ +
+
+
+
+ +
+
This project is live on web
+
+ + )} - {watch("id") && ( -
-
- -
-
This project is live on web
-
- )} - -
-
-
Views
- ( - 0 - ? viewOptions - .filter((v) => value.includes(v.key)) - .map((v) => v.label) - .join(", ") - : `` - } - placeholder="Select views" - > - <> - {viewOptions.map((option) => ( -
{ - const _views = - value.length > 0 - ? value.includes(option.key) - ? value.filter((_o: string) => _o !== option.key) - : [...value, option.key] - : [option.key]; - - if (_views.length === 0) return; - - onChange(_views); - checkIfUpdateIsRequired(); - }} - > -
{option.label}
+
+
+
Views
+ ( + 0 + ? viewOptions + .filter((v) => value.includes(v.key)) + .map((v) => v.label) + .join(", ") + : `` + } + placeholder="Select views" + > + <> + {viewOptions.map((option) => (
{ + const _views = + value.length > 0 + ? value.includes(option.key) + ? value.filter((_o: string) => _o !== option.key) + : [...value, option.key] + : [option.key]; + + if (_views.length === 0) return; + + onChange(_views); + checkIfUpdateIsRequired(); + }} > - {value.length > 0 && value.includes(option.key) && ( - - )} +
{option.label}
+
+ {value.length > 0 && value.includes(option.key) && ( + + )} +
-
- ))} - - - )} - /> -
+ ))} + + + )} + /> +
+
+
Allow comments
+ ( + { + onChange(val); + checkIfUpdateIsRequired(); + }} + size="sm" + /> + )} + /> +
+
+
Allow reactions
+ ( + { + onChange(val); + checkIfUpdateIsRequired(); + }} + size="sm" + /> + )} + /> +
+
+
Allow voting
+ ( + { + onChange(val); + checkIfUpdateIsRequired(); + }} + size="sm" + /> + )} + /> +
-
-
Allow comments
- ( - { - onChange(val); - checkIfUpdateIsRequired(); - }} - size="sm" - /> - )} - /> -
-
-
Allow reactions
- ( - { - onChange(val); - checkIfUpdateIsRequired(); - }} - size="sm" - /> - )} - /> -
-
-
Allow voting
- ( - { - onChange(val); - checkIfUpdateIsRequired(); - }} - size="sm" - /> - )} - /> -
- - {/*
+ {/*
Allow issue proposals
= observer(() => { )} />
*/} +
-
+ )} {/* modal handlers */}
@@ -490,22 +498,24 @@ export const PublishProjectModal: React.FC = observer(() => {
Anyone with the link can access
-
- Cancel - {watch("id") ? ( - <> - {isUpdateRequired && ( - - {isSubmitting ? "Updating..." : "Update settings"} - - )} - - ) : ( - - {isSubmitting ? "Publishing..." : "Publish"} - - )} -
+ {!projectPublish.fetchSettingsLoader && ( +
+ Cancel + {watch("id") ? ( + <> + {isUpdateRequired && ( + + {isSubmitting ? "Updating..." : "Update settings"} + + )} + + ) : ( + + {isSubmitting ? "Publishing..." : "Publish"} + + )} +
+ )}
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/components/views/gantt-chart.tsx b/web/components/views/gantt-chart.tsx index 36022f6fa..b25f034cd 100644 --- a/web/components/views/gantt-chart.tsx +++ b/web/components/views/gantt-chart.tsx @@ -6,6 +6,7 @@ import { useRouter } from "next/router"; import useGanttChartViewIssues from "hooks/gantt-chart/view-issues-view"; import useUser from "hooks/use-user"; import { updateGanttIssue } from "components/gantt-chart/hooks/block-update"; +import useProjectDetails from "hooks/use-project-details"; // components import { GanttChartRoot, renderIssueBlocksStructure } from "components/gantt-chart"; import { IssueGanttBlock, IssueGanttSidebarBlock } from "components/issues"; @@ -19,6 +20,7 @@ export const ViewIssuesGanttChartView: FC = ({}) => { const { workspaceSlug, projectId, viewId } = router.query; const { user } = useUser(); + const { projectDetails } = useProjectDetails(); const { ganttIssues, mutateGanttIssues } = useGanttChartViewIssues( workspaceSlug as string, @@ -26,6 +28,8 @@ export const ViewIssuesGanttChartView: FC = ({}) => { viewId as string ); + const isAllowed = projectDetails?.member_role === 20 || projectDetails?.member_role === 15; + return (
= ({}) => { } SidebarBlockRender={IssueGanttSidebarBlock} BlockRender={IssueGanttBlock} + enableBlockLeftResize={isAllowed} + enableBlockRightResize={isAllowed} + enableBlockMove={isAllowed} + enableReorder={isAllowed} />
); 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-publish.tsx b/web/store/project-publish.tsx index ffc45f546..b8c6f0dbe 100644 --- a/web/store/project-publish.tsx +++ b/web/store/project-publish.tsx @@ -21,7 +21,8 @@ export interface IProjectPublishSettings { } export interface IProjectPublishStore { - loader: boolean; + generalLoader: boolean; + fetchSettingsLoader: boolean; error: any | null; projectPublishModal: boolean; @@ -35,7 +36,7 @@ export interface IProjectPublishStore { project_slug: string, user: any ) => Promise; - createProjectSettingsAsync: ( + publishProject: ( workspace_slug: string, project_slug: string, data: IProjectPublishSettings, @@ -48,7 +49,7 @@ export interface IProjectPublishStore { data: IProjectPublishSettings, user: any ) => Promise; - deleteProjectSettingsAsync: ( + unPublishProject: ( workspace_slug: string, project_slug: string, project_publish_id: string, @@ -57,7 +58,8 @@ export interface IProjectPublishStore { } class ProjectPublishStore implements IProjectPublishStore { - loader: boolean = false; + generalLoader: boolean = false; + fetchSettingsLoader: boolean = false; error: any | null = null; projectPublishModal: boolean = false; @@ -72,7 +74,8 @@ class ProjectPublishStore implements IProjectPublishStore { constructor(_rootStore: RootStore) { makeObservable(this, { // observable - loader: observable, + generalLoader: observable, + fetchSettingsLoader: observable, error: observable, projectPublishModal: observable, @@ -80,6 +83,10 @@ class ProjectPublishStore implements IProjectPublishStore { projectPublishSettings: observable.ref, // action handleProjectModal: action, + getProjectSettingsAsync: action, + publishProject: action, + updateProjectSettingsAsync: action, + unPublishProject: action, // computed }); @@ -100,7 +107,7 @@ class ProjectPublishStore implements IProjectPublishStore { getProjectSettingsAsync = async (workspace_slug: string, project_slug: string, user: any) => { try { - this.loader = true; + this.fetchSettingsLoader = true; this.error = null; const response = await this.projectPublishService.getProjectSettingsAsync( @@ -128,30 +135,30 @@ class ProjectPublishStore implements IProjectPublishStore { runInAction(() => { this.projectPublishSettings = _projectPublishSettings; - this.loader = false; + this.fetchSettingsLoader = false; this.error = null; }); } else { this.projectPublishSettings = "not-initialized"; - this.loader = false; + this.fetchSettingsLoader = false; this.error = null; } return response; } catch (error) { - this.loader = false; + this.fetchSettingsLoader = false; this.error = error; return error; } }; - createProjectSettingsAsync = async ( + publishProject = async ( workspace_slug: string, project_slug: string, data: IProjectPublishSettings, user: any ) => { try { - this.loader = true; + this.generalLoader = true; this.error = null; const response = await this.projectPublishService.createProjectSettingsAsync( @@ -174,14 +181,14 @@ class ProjectPublishStore implements IProjectPublishStore { runInAction(() => { this.projectPublishSettings = _projectPublishSettings; - this.loader = false; + this.generalLoader = false; this.error = null; }); return response; } } catch (error) { - this.loader = false; + this.generalLoader = false; this.error = error; return error; } @@ -195,7 +202,7 @@ class ProjectPublishStore implements IProjectPublishStore { user: any ) => { try { - this.loader = true; + this.generalLoader = true; this.error = null; const response = await this.projectPublishService.updateProjectSettingsAsync( @@ -219,27 +226,27 @@ class ProjectPublishStore implements IProjectPublishStore { runInAction(() => { this.projectPublishSettings = _projectPublishSettings; - this.loader = false; + this.generalLoader = false; this.error = null; }); return response; } } catch (error) { - this.loader = false; + this.generalLoader = false; this.error = error; return error; } }; - deleteProjectSettingsAsync = async ( + unPublishProject = async ( workspace_slug: string, project_slug: string, project_publish_id: string, user: any ) => { try { - this.loader = true; + this.generalLoader = true; this.error = null; const response = await this.projectPublishService.deleteProjectSettingsAsync( @@ -251,13 +258,13 @@ class ProjectPublishStore implements IProjectPublishStore { runInAction(() => { this.projectPublishSettings = "not-initialized"; - this.loader = false; + this.generalLoader = false; this.error = null; }); return response; } catch (error) { - this.loader = false; + this.generalLoader = false; this.error = error; return error; } 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 2e7a0f242..2e81922d2 100644 --- a/web/store/root.ts +++ b/web/store/root.ts @@ -3,15 +3,17 @@ 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 KanbanStore from "./kanban"; +import IssuesStore from "./issues"; enableStaticRendering(typeof window === "undefined"); export class RootStore { user; theme; + project: IProjectStore; projectPublish: IProjectPublishStore; issues: IssuesStore; kanban: KanbanStore; @@ -19,6 +21,7 @@ export class RootStore { 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); this.kanban = new KanbanStore(this);