build: setup turbo repo

This commit is contained in:
pablohashescobar 2022-11-30 02:21:17 +05:30
parent 976e5b9c27
commit ba47c273b1
148 changed files with 3177 additions and 515 deletions

20
apps/app/pages/_app.tsx Normal file
View file

@ -0,0 +1,20 @@
import "../styles/globals.css";
import "styles/editor.css";
import type { AppProps } from "next/app";
import GlobalContextProvider from "contexts/globalContextProvider";
import CommandPalette from "components/command-palette";
function MyApp({ Component, pageProps }: AppProps) {
return (
<GlobalContextProvider>
<>
<CommandPalette />
<Component {...pageProps} />
</>
</GlobalContextProvider>
);
}
export default MyApp;

View file

@ -0,0 +1,13 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from 'next'
type Data = {
name: string
}
export default function handler(
req: NextApiRequest,
res: NextApiResponse<Data>
) {
res.status(200).json({ name: 'John Doe' })
}

View file

@ -0,0 +1,147 @@
import React from "react";
// next
import type { NextPage } from "next";
import { useRouter } from "next/router";
// react hook form
import { useForm } from "react-hook-form";
// services
import workspaceService from "lib/services/workspace.service";
// hooks
import useUser from "lib/hooks/useUser";
// layouts
import DefaultLayout from "layouts/DefaultLayout";
// ui
import { Input, Button, Select } from "ui";
// types
import type { IWorkspace } from "types";
const CreateWorkspace: NextPage = () => {
const {
register,
handleSubmit,
setError,
formState: { errors, isSubmitting },
} = useForm<IWorkspace>({
defaultValues: {
name: "",
},
});
const router = useRouter();
const { mutateWorkspaces, user } = useUser();
const onSubmit = async (formData: IWorkspace) => {
await workspaceService
.createWorkspace(formData)
.then((res) => {
console.log(res);
mutateWorkspaces((prevData) => [...(prevData ?? []), res], false);
router.push("/");
})
.catch((err) => {
Object.keys(err).map((key) => {
const errorMessage = err[key];
setError(key as keyof IWorkspace, {
message: Array.isArray(errorMessage) ? errorMessage.join(", ") : errorMessage,
});
});
});
};
// const workspaceName = watch("name") ?? "";
// useEffect(() => {
// workspaceName && workspaceName !== ""
// ? setValue(
// "url",
// `${window.location.origin}/${workspaceName
// .toLowerCase()
// .replace(/ /g, "")}`
// )
// : setValue("url", workspaceName);
// }, [workspaceName, setValue]);
return (
<DefaultLayout>
<div className="flex flex-col items-center justify-center w-full h-full px-4">
{user && (
<div className="w-96 p-2 rounded-lg bg-indigo-100 text-indigo-600 mb-10 lg:mb-20">
<p className="text-sm text-center">logged in as {user.email}</p>
</div>
)}
<div className="rounded border p-4 px-6 w-full md:w-2/3 lg:w-1/3 space-y-4 flex flex-col justify-between bg-white">
<h2 className="text-2xl text-center font-medium mb-4">Create a new workspace</h2>
<form onSubmit={handleSubmit(onSubmit)}>
<div className="space-y-4">
<div>
<Input
id="name"
label="Workspace Name"
name="name"
autoComplete="off"
register={register}
validations={{
required: "Name is required",
}}
error={errors.name}
placeholder="Enter workspace name"
/>
</div>
<div>
<Input
id="url"
label="Workspace URL"
name="url"
autoComplete="off"
validations={{
required: "URL is required",
}}
placeholder="Enter workspace URL"
/>
</div>
<div>
<Select
id="size"
name="company_size"
label="How large is your company?"
register={register}
options={[
{ value: 5, label: "5" },
{ value: 10, label: "10" },
{ value: 25, label: "25" },
{ value: 50, label: "50" },
]}
/>
</div>
<div>
<Input
id="projects"
label="What is your role?"
name="projects"
autoComplete="off"
placeholder="Head of Engineering"
/>
</div>
{/* <div>
<TextArea
id="description"
label="Description"
name="description"
register={register}
error={errors.description}
placeholder="Enter workspace description"
/>
</div> */}
<Button type="submit" className="w-full" disabled={isSubmitting}>
{isSubmitting ? "Creating Workspace..." : "Create Workspace"}
</Button>
</div>
</form>
</div>
</div>
</DefaultLayout>
);
};
export default CreateWorkspace;

41
apps/app/pages/editor.tsx Normal file
View file

@ -0,0 +1,41 @@
import React, { useEffect, useRef } from "react";
// next
import type { NextPage } from "next";
// prose mirror
import { EditorState } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import { Schema, DOMParser } from "prosemirror-model";
import { schema } from "prosemirror-schema-basic";
import { addListNodes } from "prosemirror-schema-list";
import { exampleSetup } from "prosemirror-example-setup";
const Editor: NextPage = () => {
const editorRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!editorRef.current || !contentRef.current) return;
const mySchema = new Schema({
nodes: addListNodes(schema.spec.nodes, "paragraph block*", "block"),
marks: schema.spec.marks,
});
const myEditorView = new EditorView(editorRef.current, {
state: EditorState.create({
doc: DOMParser.fromSchema(mySchema)?.parse(contentRef.current),
plugins: exampleSetup({ schema: mySchema }),
}),
});
return () => myEditorView.destroy();
}, []);
return (
<div id="editor" ref={editorRef}>
<div id="content" ref={contentRef} />
</div>
);
};
export default Editor;

25
apps/app/pages/index.tsx Normal file
View file

@ -0,0 +1,25 @@
import React, { useEffect } from "react";
// next
import type { NextPage } from "next";
import { useRouter } from "next/router";
// hooks
import useUser from "lib/hooks/useUser";
const Home: NextPage = () => {
const router = useRouter();
const { user, isUserLoading, activeWorkspace, workspaces } = useUser();
useEffect(() => {
if (!isUserLoading && (!user || user === null)) router.push("/signin");
}, [isUserLoading, user, router]);
useEffect(() => {
if (!activeWorkspace && workspaces?.length === 0) router.push("/invitations");
else if (activeWorkspace) router.push(`/workspace/`);
}, [activeWorkspace, router, workspaces]);
return <></>;
};
export default Home;

View file

@ -0,0 +1,189 @@
import React, { useEffect, useState } from "react";
// next
import type { NextPage } from "next";
import Link from "next/link";
import { useRouter } from "next/router";
// swr
import useSWR from "swr";
// services
import workspaceService from "lib/services/workspace.service";
import userService from "lib/services/user.service";
// hooks
import useUser from "lib/hooks/useUser";
// constants
import { USER_WORKSPACE_INVITATIONS } from "constants/api-routes";
// layouts
import DefaultLayout from "layouts/DefaultLayout";
// ui
import { Button, Spinner } from "ui";
// types
import type { IWorkspaceInvitation } from "types";
import { CubeIcon, PlusIcon } from "@heroicons/react/24/outline";
import { EmptySpace, EmptySpaceItem } from "ui/EmptySpace";
const OnBoard: NextPage = () => {
const router = useRouter();
const { workspaces, mutateWorkspaces, user } = useUser();
const [invitationsRespond, setInvitationsRespond] = useState<string[]>([]);
const { data: invitations, mutate } = useSWR<IWorkspaceInvitation[]>(
USER_WORKSPACE_INVITATIONS,
() => workspaceService.userWorkspaceInvitations()
);
const handleInvitation = (
workspace_invitation: IWorkspaceInvitation,
action: "accepted" | "withdraw"
) => {
if (action === "accepted") {
setInvitationsRespond((prevData) => {
return [...prevData, workspace_invitation.id];
});
} else if (action === "withdraw") {
setInvitationsRespond((prevData) => {
return prevData.filter((item: string) => item !== workspace_invitation.id);
});
}
};
const submitInvitations = () => {
userService.updateUserOnBoard().then((response) => {
console.log(response);
});
workspaceService
.joinWorkspaces({ invitations: invitationsRespond })
.then(async (res: any) => {
console.log(res);
await mutate();
await mutateWorkspaces();
router.push("/workspace");
})
.catch((err) => {
console.log(err);
});
};
useEffect(() => {
userService.updateUserOnBoard().then((response) => {
console.log(response);
});
}, []);
return (
<DefaultLayout
meta={{
title: "Plane - Welcome to Plane",
description:
"Please fasten your seatbelts because we are about to take your productivity to the next level.",
}}
>
<div className="flex min-h-full flex-col items-center justify-center p-4 sm:p-0">
{user && (
<div className="w-96 p-2 rounded-lg bg-indigo-100 text-indigo-600 mb-10">
<p className="text-sm text-center">logged in as {user.email}</p>
</div>
)}
<div className="w-full md:w-2/3 lg:w-1/3 p-8 rounded-lg">
{invitations && workspaces ? (
invitations.length > 0 ? (
<div className="mt-3 sm:mt-5">
<div className="mt-2">
<h2 className="text-2xl font-medium mb-4">Join your workspaces</h2>
<div className="space-y-2 mb-12">
{invitations.map((item) => (
<div
className="relative flex items-center border px-4 py-2 rounded"
key={item.id}
>
<div className="ml-3 text-sm flex flex-col items-start w-full">
<h3 className="font-medium text-xl text-gray-700">
{item.workspace.name}
</h3>
<p className="text-sm">invited by {item.workspace.owner.first_name}</p>
</div>
<div className="flex gap-x-2 h-5 items-center">
<div className="h-full flex items-center gap-x-1">
<input
id={`${item.id}`}
aria-describedby="workspaces"
name={`${item.id}`}
checked={invitationsRespond.includes(item.id)}
value={item.workspace.name}
onChange={() => {
handleInvitation(
item,
invitationsRespond.includes(item.id) ? "withdraw" : "accepted"
);
}}
type="checkbox"
className="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
<label htmlFor={item.id} className="text-sm">
Accept
</label>
</div>
</div>
</div>
))}
</div>
</div>
<div className="flex justify-between mt-4">
<Button className="w-full" onClick={submitInvitations}>
Continue to Dashboard
</Button>
</div>
</div>
) : workspaces && workspaces.length > 0 ? (
<div className="mt-3 flex flex-col gap-y-3">
<h2 className="text-2xl font-medium mb-4">Your workspaces</h2>
{workspaces.map((workspace) => (
<div
className="flex items-center justify-between border px-4 py-2 rounded mb-2"
key={workspace.id}
>
<div className="flex items-center gap-x-2">
<CubeIcon className="h-5 w-5 text-gray-400" />
<Link href={"/workspace"}>
<a>{workspace.name}</a>
</Link>
</div>
<div className="flex items-center gap-x-2">
<p className="text-sm">{workspace.owner.first_name}</p>
</div>
</div>
))}
<Link href={"/workspace"}>
<Button type="button">Go to workspaces</Button>
</Link>
</div>
) : (
invitations.length === 0 &&
workspaces.length === 0 && (
<EmptySpace
title="You don't have any workspaces yet"
description="Your workspace is where you'll create projects, collaborate on your issues, and organize different streams of work in your Plane account."
>
<EmptySpaceItem
Icon={PlusIcon}
title={"Create your Workspace"}
action={() => {
router.push("/create-workspace");
}}
/>
</EmptySpace>
)
)
) : (
<div className="w-full h-full flex justify-center items-center">
<Spinner />
</div>
)}
</div>
</div>
</DefaultLayout>
);
};
export default OnBoard;

View file

@ -0,0 +1,99 @@
import React, { useState, useEffect } from "react";
// next
import type { NextPage } from "next";
import { useRouter } from "next/router";
// services
import authenticationService from "lib/services/authentication.service";
// hooks
import useUser from "lib/hooks/useUser";
import useToast from "lib/hooks/useToast";
// layouts
import DefaultLayout from "layouts/DefaultLayout";
const MagicSignIn: NextPage = () => {
const router = useRouter();
const [isSigningIn, setIsSigningIn] = useState(true);
const [errorSigningIn, setErrorSignIn] = useState<string | undefined>();
const { password, key } = router.query;
const { setToastAlert } = useToast();
const { mutateUser, mutateWorkspaces } = useUser();
useEffect(() => {
setIsSigningIn(true);
setErrorSignIn(undefined);
if (!password || !key) return;
authenticationService
.magicSignIn({ token: password, key })
.then(async (res) => {
setIsSigningIn(false);
await mutateUser();
await mutateWorkspaces();
if (res.user.is_onboarded) router.push("/");
else router.push("/invitations");
})
.catch((err) => {
setErrorSignIn(err.response.data.error);
setIsSigningIn(false);
});
}, [password, key, mutateUser, mutateWorkspaces, router]);
return (
<DefaultLayout
meta={{
title: "Magic Sign In",
}}
>
<div className="w-full h-screen flex justify-center items-center bg-gray-50 overflow-auto">
{isSigningIn ? (
<div className="w-full h-full flex flex-col gap-y-2 justify-center items-center">
<h2 className="text-4xl">Signing you in...</h2>
<p className="text-sm text-gray-600">
Please wait while we are preparing your take off.
</p>
</div>
) : errorSigningIn ? (
<div className="w-full h-full flex flex-col gap-y-2 justify-center items-center">
<h2 className="text-4xl">Error</h2>
<p className="text-sm text-gray-600">
{errorSigningIn}.
<span
className="underline cursor-pointer"
onClick={() => {
authenticationService
.emailCode({ email: (key as string).split("_")[1] })
.then(() => {
setToastAlert({
type: "success",
title: "Email sent",
message: "A new link/code has been send to you.",
});
})
.catch(() => {
setToastAlert({
type: "error",
title: "Error",
message: "Unable to send email.",
});
});
}}
>
Send link again?
</span>
</p>
</div>
) : (
<div className="w-full h-full flex flex-col gap-y-2 justify-center items-center">
<h2 className="text-4xl">Success</h2>
<p className="text-sm text-gray-600">Redirecting you to the app...</p>
</div>
)}
</div>
</DefaultLayout>
);
};
export default MagicSignIn;

View file

@ -0,0 +1,209 @@
// react
import React from "react";
// next
import type { NextPage } from "next";
// swr
import useSWR from "swr";
// layouts
import AdminLayout from "layouts/AdminLayout";
// hooks
import useUser from "lib/hooks/useUser";
// ui
import { Spinner } from "ui";
import { BreadcrumbItem, Breadcrumbs } from "ui/Breadcrumbs";
import { EmptySpace, EmptySpaceItem } from "ui/EmptySpace";
import HeaderButton from "ui/HeaderButton";
// constants
import { USER_ISSUE } from "constants/fetch-keys";
import { classNames } from "constants/common";
// services
import userService from "lib/services/user.service";
import issuesServices from "lib/services/issues.services";
// components
import ChangeStateDropdown from "components/project/issues/my-issues/ChangeStateDropdown";
// icons
import { PlusIcon, RectangleStackIcon } from "@heroicons/react/24/outline";
// types
import { IIssue } from "types";
import Link from "next/link";
const MyIssues: NextPage = () => {
const { user } = useUser();
const { data: myIssues, mutate: mutateMyIssue } = useSWR<IIssue[]>(
user ? USER_ISSUE : null,
user ? () => userService.userIssues() : null
);
const updateMyIssues = (
workspaceSlug: string,
projectId: string,
issueId: string,
issue: Partial<IIssue>
) => {
mutateMyIssue((prevData) => {
return prevData?.map((prevIssue) => {
if (prevIssue.id === issueId) {
return {
...prevIssue,
...issue,
state_detail: {
...prevIssue.state_detail,
...issue.state_detail,
},
};
}
return prevIssue;
});
}, false);
issuesServices
.patchIssue(workspaceSlug, projectId, issueId, issue)
.then((response) => {
console.log(response);
})
.catch((error) => {
console.log(error);
});
};
return (
<AdminLayout>
<div className="w-full h-full flex flex-col space-y-5">
{myIssues ? (
<>
{myIssues.length > 0 ? (
<>
<Breadcrumbs>
<BreadcrumbItem title="My Issues" />
</Breadcrumbs>
<div className="flex items-center justify-between cursor-pointer w-full">
<h2 className="text-2xl font-medium">My Issues</h2>
<div className="flex items-center gap-x-3">
<HeaderButton
Icon={PlusIcon}
label="Add Issue"
onClick={() => {
const e = new KeyboardEvent("keydown", {
key: "i",
ctrlKey: true,
});
document.dispatchEvent(e);
}}
/>
</div>
</div>
<div className="mt-4 flex flex-col">
<div className="overflow-x-auto ">
<div className="inline-block min-w-full align-middle px-0.5 py-2">
<div className="overflow-hidden shadow ring-1 ring-black ring-opacity-5 md:rounded-lg">
<table className="min-w-full">
<thead className="bg-gray-100">
<tr className="text-left">
<th
scope="col"
className="px-3 py-3.5 text-sm font-semibold text-gray-900"
>
NAME
</th>
<th
scope="col"
className="px-3 py-3.5 text-sm font-semibold text-gray-900"
>
DESCRIPTION
</th>
<th
scope="col"
className="px-3 py-3.5 text-sm font-semibold text-gray-900"
>
PROJECT
</th>
<th
scope="col"
className="px-3 py-3.5 text-sm font-semibold text-gray-900"
>
PRIORITY
</th>
<th
scope="col"
className="px-3 py-3.5 text-sm font-semibold text-gray-900"
>
STATUS
</th>
</tr>
</thead>
<tbody className="bg-white">
{myIssues.map((myIssue, index) => (
<tr
key={myIssue.id}
className={classNames(
index === 0 ? "border-gray-300" : "border-gray-200",
"border-t text-sm text-gray-900"
)}
>
<td className="px-3 py-4 text-sm font-medium text-gray-900 hover:text-theme max-w-[15rem] duration-300">
<Link href={`/projects/${myIssue.project}/issues/${myIssue.id}`}>
<a>{myIssue.name}</a>
</Link>
</td>
<td className="px-3 py-4 max-w-[15rem]">{myIssue.description}</td>
<td className="px-3 py-4">
{myIssue.project_detail?.name}
<br />
<span className="text-xs">{`(${myIssue.project_detail?.identifier}-${myIssue.sequence_id})`}</span>
</td>
<td className="px-3 py-4 capitalize">{myIssue.priority}</td>
<td className="relative px-3 py-4">
<ChangeStateDropdown
issue={myIssue}
updateIssues={updateMyIssues}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
</>
) : (
<div className="w-full h-full flex flex-col justify-center items-center px-4">
<EmptySpace
title="You don't have any issue assigned to you yet."
description="Issues help you track individual pieces of work. With Issues, keep track of what's going on, who is working on it, and what's done."
Icon={RectangleStackIcon}
>
<EmptySpaceItem
title="Create a new issue"
description={
<span>
Use{" "}
<pre className="inline bg-gray-100 px-2 py-1 rounded">Ctrl/Command + I</pre>{" "}
shortcut to create a new issue
</span>
}
Icon={PlusIcon}
action={() => {
const e = new KeyboardEvent("keydown", {
key: "i",
ctrlKey: true,
});
document.dispatchEvent(e);
}}
/>
</EmptySpace>
</div>
)}
</>
) : (
<div className="w-full h-full flex justify-center items-center">
<Spinner />
</div>
)}
</div>
</AdminLayout>
);
};
export default MyIssues;

View file

@ -0,0 +1,310 @@
import React, { useEffect, useState } from "react";
// next
import Image from "next/image";
import type { NextPage } from "next";
// react hook form
import { useForm } from "react-hook-form";
// react dropzone
import Dropzone, { useDropzone } from "react-dropzone";
// hooks
import useUser from "lib/hooks/useUser";
// layouts
import AdminLayout from "layouts/AdminLayout";
// services
import userService from "lib/services/user.service";
import fileServices from "lib/services/file.services";
// ui
import { BreadcrumbItem, Breadcrumbs, Button, Input, Spinner } from "ui";
// types
import type { IIssue, IUser, IWorkspaceInvitation } from "types";
import {
ChevronRightIcon,
ClipboardDocumentListIcon,
PencilIcon,
RectangleStackIcon,
UserIcon,
UserPlusIcon,
XMarkIcon,
} from "@heroicons/react/24/outline";
import useSWR from "swr";
import { USER_ISSUE, USER_WORKSPACE_INVITATIONS } from "constants/fetch-keys";
import useToast from "lib/hooks/useToast";
import Link from "next/link";
import workspaceService from "lib/services/workspace.service";
const defaultValues: Partial<IUser> = {
avatar: "",
first_name: "",
last_name: "",
email: "",
};
const Profile: NextPage = () => {
const [image, setImage] = useState<File | null>(null);
const [isImageUploading, setIsImageUploading] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const { user: myProfile, mutateUser, projects } = useUser();
const { setToastAlert } = useToast();
const onSubmit = (formData: IUser) => {
userService
.updateUser(formData)
.then((response) => {
mutateUser(response, false);
setIsEditing(false);
setToastAlert({
title: "Success",
type: "success",
message: "Profile updated successfully",
});
})
.catch((error) => {
console.log(error);
});
};
const {
register,
handleSubmit,
reset,
watch,
setValue,
formState: { errors, isSubmitting },
} = useForm<IUser>({ defaultValues });
const { data: myIssues } = useSWR<IIssue[]>(
myProfile ? USER_ISSUE : null,
myProfile ? () => userService.userIssues() : null
);
const { data: invitations } = useSWR<IWorkspaceInvitation[]>(USER_WORKSPACE_INVITATIONS, () =>
workspaceService.userWorkspaceInvitations()
);
useEffect(() => {
reset({ ...defaultValues, ...myProfile });
}, [myProfile, reset]);
const quickLinks = [
{
icon: RectangleStackIcon,
title: "My Issues",
number: myIssues?.length ?? 0,
description: "View the list of issues assigned to you across the workspace.",
href: "/me/my-issues",
},
{
icon: ClipboardDocumentListIcon,
title: "My Projects",
number: projects?.length ?? 0,
description: "View the list of projects of the workspace.",
href: "/projects",
},
{
icon: UserPlusIcon,
title: "Workspace Invitations",
number: invitations?.length ?? 0,
description: "View your workspace invitations.",
href: "/invitations",
},
];
return (
<AdminLayout
meta={{
title: "Plane - My Profile",
}}
>
<div className="w-full space-y-5">
<Breadcrumbs>
<BreadcrumbItem title="My Profile" />
</Breadcrumbs>
{myProfile ? (
<>
<div className="space-y-5">
<section className="relative p-5 rounded-xl flex gap-10 bg-secondary">
<div
className="absolute top-4 right-4 bg-indigo-100 hover:bg-theme hover:text-white rounded p-1 cursor-pointer duration-300"
onClick={() => setIsEditing((prevData) => !prevData)}
>
{isEditing ? (
<XMarkIcon className="h-4 w-4" />
) : (
<PencilIcon className="h-4 w-4" />
)}
</div>
<div className="flex-shrink-0">
<Dropzone
multiple={false}
accept={{
"image/*": [],
}}
onDrop={(files) => {
setImage(files[0]);
}}
>
{({ getRootProps, getInputProps, open }) => (
<div className="space-y-4">
<input {...getInputProps()} />
<div className="relative">
<span
className="inline-block h-40 w-40 rounded overflow-hidden bg-gray-100"
{...getRootProps()}
>
{(!watch("avatar") || watch("avatar") === "") &&
(!image || image === null) ? (
<UserIcon className="h-full w-full text-gray-300" />
) : (
<div className="relative h-40 w-40 overflow-hidden">
<Image
src={image ? URL.createObjectURL(image) : watch("avatar")}
alt={myProfile.first_name}
layout="fill"
objectFit="cover"
priority
/>
</div>
)}
</span>
</div>
<p className="text-gray-500 text-sm">
Max file size is 500kb.
<br />
Supported file types are .jpg and .png.
</p>
<Button
type="button"
className="mt-4"
onClick={() => {
if (image === null) open();
else {
setIsImageUploading(true);
const formData = new FormData();
formData.append("asset", image);
formData.append("attributes", JSON.stringify({}));
fileServices
.uploadFile(formData)
.then((response) => {
const imageUrl = response.asset;
setValue("avatar", imageUrl);
handleSubmit(onSubmit)();
setIsImageUploading(false);
})
.catch((err) => {
setIsImageUploading(false);
});
}
}}
>
{isImageUploading ? "Uploading..." : "Upload"}
</Button>
</div>
)}
</Dropzone>
</div>
<form className="space-y-5" onSubmit={handleSubmit(onSubmit)}>
<div className="grid grid-cols-2 gap-x-10 gap-y-5 mt-2">
<div>
<h4 className="text-sm text-gray-500">First Name</h4>
{isEditing ? (
<Input
name="first_name"
id="first_name"
register={register}
error={errors.first_name}
placeholder="Enter your first name"
autoComplete="off"
validations={{
required: "This field is required.",
}}
/>
) : (
<h2>{myProfile.first_name}</h2>
)}
</div>
<div>
<h4 className="text-sm text-gray-500">Last Name</h4>
{isEditing ? (
<Input
name="last_name"
register={register}
error={errors.last_name}
id="last_name"
placeholder="Enter your last name"
autoComplete="off"
/>
) : (
<h2>{myProfile.last_name}</h2>
)}
</div>
<div>
<h4 className="text-sm text-gray-500">Email ID</h4>
{isEditing ? (
<Input
id="email"
type="email"
register={register}
error={errors.email}
name="email"
validations={{
required: "Email is required",
}}
placeholder="Enter email"
/>
) : (
<h2>{myProfile.email}</h2>
)}
</div>
</div>
{isEditing && (
<div>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Updating Profile..." : "Update Profile"}
</Button>
</div>
)}
</form>
</section>
<section>
<h2 className="text-xl font-medium mb-3">Quick Links</h2>
<div className="grid grid-cols-3 gap-5">
{quickLinks.map((item, index) => (
<Link key={index} href={item.href}>
<a className="group p-5 rounded-lg bg-secondary hover:bg-theme duration-300">
<h4 className="group-hover:text-white flex items-center gap-2 duration-300">
{item.title}
<ChevronRightIcon className="h-3 w-3" />
</h4>
<div className="flex justify-between items-center gap-3">
<div>
<h2 className="mt-3 mb-2 text-3xl font-bold group-hover:text-white duration-300">
{item.number}
</h2>
<p className="text-gray-500 group-hover:text-white text-sm duration-300">
{item.description}
</p>
</div>
<div>
<item.icon className="h-12 w-12 group-hover:text-white duration-300" />
</div>
</div>
</a>
</Link>
))}
</div>
</section>
</div>
</>
) : (
<div className="w-full mx-auto h-full flex justify-center items-center">
<Spinner />
</div>
)}
</div>
</AdminLayout>
);
};
export default Profile;

View file

@ -0,0 +1,154 @@
import React, { useState } from "react";
// next
import Link from "next/link";
import type { NextPage } from "next";
import { useRouter } from "next/router";
// services
import workspaceService from "lib/services/workspace.service";
// hooks
import useUser from "lib/hooks/useUser";
// hoc
import withAuthWrapper from "lib/hoc/withAuthWrapper";
// layouts
import AdminLayout from "layouts/AdminLayout";
// ui
import { Button } from "ui";
// swr
import useSWR from "swr";
const MyWorkspacesInvites: NextPage = () => {
const router = useRouter();
const [invitationsRespond, setInvitationsRespond] = useState<any>([]);
const { workspaces } = useUser();
const {
data: workspaceInvitations,
isValidating,
mutate: mutateInvitations,
} = useSWR<any[]>("WORKSPACE_INVITATIONS", () => workspaceService.userWorkspaceInvitations());
const handleInvitation = (workspace_invitation: any, action: string) => {
if (action === "accepted") {
setInvitationsRespond((prevData: any) => {
return [...prevData, workspace_invitation.workspace.id];
});
} else if (action === "withdraw") {
setInvitationsRespond((prevData: any) => {
return prevData.filter((item: string) => item !== workspace_invitation.workspace.id);
});
}
};
const submitInvitations = () => {
workspaceService
.joinWorkspaces({ workspace_ids: invitationsRespond })
.then(async (res) => {
console.log(res);
await mutateInvitations();
router.push("/");
})
.catch((err: any) => console.log);
};
return (
<AdminLayout
meta={{
title: "Plane - My Workspace Invites",
}}
>
<div className="flex flex-col items-center justify-center w-full h-full">
<div className="relative rounded bg-gray-50 px-4 pt-5 pb-4 text-left shadow sm:w-full sm:max-w-2xl sm:p-6">
{(workspaceInvitations as any)?.length > 0 ? (
<>
<div>
<div className="mt-3 sm:mt-5">
<div className="mt-2">
<h2 className="text-lg mb-4">Workspace Invitations</h2>
<div className="space-y-2">
{workspaceInvitations?.map((item: any) => (
<div className="relative flex items-start" key={item.id}>
<div className="flex h-5 items-center">
<input
id={`${item.id}`}
aria-describedby="workspaces"
name={`${item.id}`}
checked={
item.workspace.accepted ||
invitationsRespond.includes(item.workspace.id)
}
value={item.workspace.name}
onChange={() =>
handleInvitation(
item,
item.accepted
? "withdraw"
: invitationsRespond.includes(item.workspace.id)
? "withdraw"
: "accepted"
)
}
type="checkbox"
className="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
</div>
<div className="ml-3 text-sm flex justify-between w-full">
<label htmlFor={`${item.id}`} className="font-medium text-gray-700">
{item.workspace.name}
</label>
<div>
{invitationsRespond.includes(item.workspace.id) ? (
<div className="flex gap-x-2">
<p>Accepted</p>
<button
type="button"
onClick={() => handleInvitation(item, "withdraw")}
>
Withdraw
</button>
</div>
) : (
<button
type="button"
onClick={() => handleInvitation(item, "accepted")}
>
Join
</button>
)}
</div>
</div>
</div>
))}
</div>
</div>
</div>
</div>
<div className="flex justify-between mt-4">
<Link href={workspaces?.length === 0 ? "/create-workspace" : "/"}>
<button type="button" className="text-sm text-gray-700">
Skip
</button>
</Link>
<Button onClick={submitInvitations}>Submit</Button>
</div>
</>
) : (
<div>
<span>No Invitaions Found</span>
<p>
<Link href="/">
<a>Click Here </a>
</Link>
<span>to redirect home</span>
</p>
</div>
)}
</div>
</div>
</AdminLayout>
);
};
export default withAuthWrapper(MyWorkspacesInvites);

View file

@ -0,0 +1,192 @@
import React, { useEffect, useState } from "react";
// next
import { useRouter } from "next/router";
import type { NextPage } from "next";
// swr
import useSWR, { mutate } from "swr";
// services
import issuesServices from "lib/services/issues.services";
import sprintService from "lib/services/cycles.services";
// hooks
import useUser from "lib/hooks/useUser";
// fetching keys
import { CYCLE_ISSUES, CYCLE_LIST } from "constants/fetch-keys";
// layouts
import AdminLayout from "layouts/AdminLayout";
// components
import SprintView from "components/project/cycles/CycleView";
import ConfirmIssueDeletion from "components/project/issues/ConfirmIssueDeletion";
import ConfirmSprintDeletion from "components/project/cycles/ConfirmCycleDeletion";
import CreateUpdateIssuesModal from "components/project/issues/CreateUpdateIssueModal";
import CreateUpdateSprintsModal from "components/project/cycles/CreateUpdateCyclesModal";
// ui
import { Spinner } from "ui";
// icons
import { PlusIcon } from "@heroicons/react/20/solid";
// types
import { IIssue, ICycle, SelectSprintType, SelectIssue } from "types";
import { EmptySpace, EmptySpaceItem } from "ui/EmptySpace";
import { ArrowPathIcon } from "@heroicons/react/24/outline";
import HeaderButton from "ui/HeaderButton";
import { BreadcrumbItem, Breadcrumbs } from "ui/Breadcrumbs";
const ProjectSprints: NextPage = () => {
const [isOpen, setIsOpen] = useState(false);
const [selectedSprint, setSelectedSprint] = useState<SelectSprintType>();
const [isIssueModalOpen, setIsIssueModalOpen] = useState(false);
const [selectedIssues, setSelectedIssues] = useState<SelectIssue>();
const [deleteIssue, setDeleteIssue] = useState<string | undefined>();
const { activeWorkspace, activeProject } = useUser();
const router = useRouter();
const { projectId } = router.query;
const { data: sprints } = useSWR<ICycle[]>(
projectId && activeWorkspace ? CYCLE_LIST(projectId as string) : null,
activeWorkspace && projectId
? () => sprintService.getCycles(activeWorkspace.slug, projectId as string)
: null
);
const openIssueModal = (
sprintId: string,
issue?: IIssue,
actionType: "create" | "edit" | "delete" = "create"
) => {
const sprint = sprints?.find((sprint) => sprint.id === sprintId);
if (sprint) {
setSelectedSprint({
...sprint,
actionType: "create-issue",
});
if (issue) setSelectedIssues({ ...issue, actionType });
setIsIssueModalOpen(true);
}
};
const addIssueToSprint = (sprintId: string, issueId: string) => {
if (!activeWorkspace || !projectId) return;
issuesServices
.addIssueToSprint(activeWorkspace.slug, projectId as string, sprintId, {
issue: issueId,
})
.then((response) => {
console.log(response);
mutate(CYCLE_ISSUES(sprintId));
})
.catch((error) => {
console.log(error);
});
};
useEffect(() => {
if (isOpen) return;
const timer = setTimeout(() => {
setSelectedSprint(undefined);
clearTimeout(timer);
}, 500);
}, [isOpen]);
useEffect(() => {
if (selectedIssues?.actionType === "delete") {
setDeleteIssue(selectedIssues.id);
}
}, [selectedIssues]);
return (
<AdminLayout
meta={{
title: "Plane - Cycles",
}}
>
<CreateUpdateSprintsModal
isOpen={
isOpen &&
selectedSprint?.actionType !== "delete" &&
selectedSprint?.actionType !== "create-issue"
}
setIsOpen={setIsOpen}
data={selectedSprint}
projectId={projectId as string}
/>
<ConfirmSprintDeletion
isOpen={isOpen && !!selectedSprint && selectedSprint.actionType === "delete"}
setIsOpen={setIsOpen}
data={selectedSprint}
/>
<ConfirmIssueDeletion
handleClose={() => setDeleteIssue(undefined)}
isOpen={!!deleteIssue}
data={selectedIssues}
/>
<CreateUpdateIssuesModal
isOpen={
isIssueModalOpen &&
selectedSprint?.actionType === "create-issue" &&
selectedIssues?.actionType !== "delete"
}
data={selectedIssues}
prePopulateData={{ sprints: selectedSprint?.id }}
setIsOpen={setIsOpen}
projectId={projectId as string}
/>
{sprints ? (
sprints.length > 0 ? (
<div className="h-full w-full space-y-5">
<Breadcrumbs>
<BreadcrumbItem title="Projects" link="/projects" />
<BreadcrumbItem title={`${activeProject?.name ?? "Project"} Cycles`} />
</Breadcrumbs>
<div className="flex items-center justify-between cursor-pointer w-full">
<h2 className="text-2xl font-medium">Project Cycle</h2>
<HeaderButton Icon={PlusIcon} label="Add Cycle" onClick={() => setIsOpen(true)} />
</div>
<div className="h-full w-full">
{sprints.map((sprint) => (
<SprintView
sprint={sprint}
selectSprint={setSelectedSprint}
projectId={projectId as string}
workspaceSlug={activeWorkspace?.slug as string}
openIssueModal={openIssueModal}
addIssueToSprint={addIssueToSprint}
key={sprint.id}
/>
))}
</div>
</div>
) : (
<div className="w-full h-full flex flex-col justify-center items-center px-4">
<EmptySpace
title="You don't have any cycle yet."
description="A cycle is a fixed time period where a team commits to a set number of issues from their backlog. Cycles are usually one, two, or four weeks long."
Icon={ArrowPathIcon}
>
<EmptySpaceItem
title="Create a new cycle"
description={
<span>
Use <pre className="inline bg-gray-100 px-2 py-1 rounded">Ctrl/Command + Q</pre>{" "}
shortcut to create a new cycle
</span>
}
Icon={PlusIcon}
action={() => setIsOpen(true)}
/>
</EmptySpace>
</div>
)
) : (
<div className="w-full h-full flex justify-center items-center">
<Spinner />
</div>
)}
</AdminLayout>
);
};
export default ProjectSprints;

View file

@ -0,0 +1,280 @@
// next
import type { NextPage } from "next";
import { useRouter } from "next/router";
// react
import React, { useCallback, useEffect, useState } from "react";
// swr
import useSWR from "swr";
// react hook form
import { useForm } from "react-hook-form";
// headless ui
import { Tab } from "@headlessui/react";
// services
import issuesServices from "lib/services/issues.services";
import stateServices from "lib/services/state.services";
// fetch keys
import { PROJECT_ISSUES_ACTIVITY, PROJECT_ISSUES_COMMENTS, STATE_LIST } from "constants/fetch-keys";
// hooks
import useUser from "lib/hooks/useUser";
// layouts
import AdminLayout from "layouts/AdminLayout";
// components
import CreateUpdateIssuesModal from "components/project/issues/CreateUpdateIssueModal";
import IssueCommentSection from "components/project/issues/issue-detail/comment/IssueCommentSection";
// common
import { debounce } from "constants/common";
// components
import IssueDetailSidebar from "components/project/issues/issue-detail/IssueDetailSidebar";
// activites
import IssueActivitySection from "components/project/issues/issue-detail/activity";
// ui
import { Spinner, TextArea } from "ui";
import HeaderButton from "ui/HeaderButton";
import { BreadcrumbItem, Breadcrumbs } from "ui/Breadcrumbs";
// types
import { IIssue, IIssueComment, IssueResponse, IState } from "types";
// icons
import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/24/outline";
const IssueDetail: NextPage = () => {
const router = useRouter();
const { issueId, projectId } = router.query;
const { activeWorkspace, activeProject, issues, mutateIssues } = useUser();
const [isOpen, setIsOpen] = useState(false);
const [issueDetail, setIssueDetail] = useState<IIssue | undefined>(undefined);
const {
register,
formState: { errors },
handleSubmit,
reset,
control,
} = useForm<IIssue>({});
const { data: issueActivities } = useSWR<any[]>(
activeWorkspace && projectId && issueId ? PROJECT_ISSUES_ACTIVITY : null,
activeWorkspace && projectId && issueId
? () =>
issuesServices.getIssueActivities(
activeWorkspace.slug,
projectId as string,
issueId as string
)
: null
);
const { data: issueComments } = useSWR<IIssueComment[]>(
activeWorkspace && projectId && issueId ? PROJECT_ISSUES_COMMENTS : null,
activeWorkspace && projectId && issueId
? () =>
issuesServices.getIssueComments(
activeWorkspace.slug,
projectId as string,
issueId as string
)
: null
);
const { data: states } = useSWR<IState[]>(
activeWorkspace && activeProject ? STATE_LIST(activeProject.id) : null,
activeWorkspace && activeProject
? () => stateServices.getStates(activeWorkspace.slug, activeProject.id)
: null
);
const submitChanges = useCallback(
(formData: Partial<IIssue>) => {
if (!activeWorkspace || !activeProject || !issueId) return;
mutateIssues(
(prevData) => ({
...(prevData as IssueResponse),
results: (prevData?.results ?? []).map((issue) => {
if (issue.id === issueId) {
return { ...issue, ...formData };
}
return issue;
}),
}),
false
);
issuesServices
.patchIssue(activeWorkspace.slug, projectId as string, issueId as string, formData)
.then((response) => {
console.log(response);
})
.catch((error) => {
console.log(error);
});
},
[activeProject, activeWorkspace, issueId, projectId, mutateIssues]
);
useEffect(() => {
if (issueDetail)
reset({
...issueDetail,
blockers_list:
issueDetail.blockers_list ??
issueDetail.blocker_issues?.map((issue) => issue.blocker_issue_detail?.id),
blocked_list:
issueDetail.blocked_list ??
issueDetail.blocked_issues?.map((issue) => issue.blocked_issue_detail?.id),
assignees_list:
issueDetail.assignees_list ?? issueDetail.assignee_details?.map((user) => user.id),
labels_list: issueDetail.labels_list ?? issueDetail.labels?.map((label) => label),
});
}, [issueDetail, reset]);
useEffect(() => {
const issueIndex = issues?.results.findIndex((issue) => issue.id === issueId);
if (issueIndex === undefined) return;
const issueDetail = issues?.results[issueIndex];
setIssueDetail(issueDetail);
}, [issues, issueId]);
const prevIssue = issues?.results[issues?.results.findIndex((issue) => issue.id === issueId) - 1];
const nextIssue = issues?.results[issues?.results.findIndex((issue) => issue.id === issueId) + 1];
return (
<AdminLayout>
<CreateUpdateIssuesModal
isOpen={isOpen}
setIsOpen={setIsOpen}
projectId={projectId as string}
data={isOpen ? issueDetail : undefined}
isUpdatingSingleIssue
/>
<div className="space-y-5">
<Breadcrumbs>
<BreadcrumbItem
title={`${activeProject?.name ?? "Project"} Issues`}
link={`/projects/${activeProject?.id}/issues`}
/>
<BreadcrumbItem
title={`Issue ${activeProject?.identifier ?? "Project"}-${
issueDetail?.sequence_id ?? "..."
} Details`}
/>
</Breadcrumbs>
<div className="flex items-center justify-between w-full">
<h2 className="text-2xl font-medium">{`${activeProject?.name}/${activeProject?.identifier}-${issueDetail?.sequence_id}`}</h2>
<div className="flex items-center gap-x-3">
<HeaderButton
Icon={ChevronLeftIcon}
disabled={!prevIssue}
label="Previous"
onClick={() => {
if (!prevIssue) return;
router.push(`/projects/${prevIssue.project}/issues/${prevIssue.id}`);
}}
/>
<HeaderButton
Icon={ChevronRightIcon}
disabled={!nextIssue}
label="Next"
onClick={() => {
if (!nextIssue) return;
router.push(`/projects/${nextIssue.project}/issues/${nextIssue?.id}`);
}}
position="reverse"
/>
</div>
</div>
{issueDetail && activeProject ? (
<div className="grid grid-cols-4 gap-5">
<div className="col-span-3 space-y-5">
<div className="bg-secondary rounded-lg p-5">
<TextArea
id="name"
placeholder="Enter issue name"
name="name"
autoComplete="off"
validations={{ required: true }}
register={register}
onChange={debounce(() => {
handleSubmit(submitChanges)();
}, 5000)}
mode="transparent"
className="text-3xl sm:text-3xl"
/>
<TextArea
id="description"
name="description"
error={errors.description}
validations={{
required: true,
}}
onChange={debounce(() => {
handleSubmit(submitChanges)();
}, 5000)}
placeholder="Enter issue description"
mode="transparent"
register={register}
/>
</div>
<div className="bg-secondary rounded-lg p-5">
<div className="relative">
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<div className="w-full border-t border-gray-300" />
</div>
<div className="relative flex justify-center">
<span className="bg-white px-2 text-sm text-gray-500">Activity/Comments</span>
</div>
</div>
<div className="w-full space-y-5 mt-3">
<Tab.Group>
<Tab.List className="flex gap-x-3">
{["Comments", "Activity"].map((item) => (
<Tab
key={item}
className={({ selected }) =>
`px-3 py-1 text-sm rounded-md border border-gray-700 ${
selected ? "bg-gray-700 text-white" : ""
}`
}
>
{item}
</Tab>
))}
</Tab.List>
<Tab.Panels>
<Tab.Panel>
<IssueCommentSection
comments={issueComments}
workspaceSlug={activeWorkspace?.slug as string}
projectId={projectId as string}
issueId={issueId as string}
/>
</Tab.Panel>
<Tab.Panel>
<IssueActivitySection issueActivities={issueActivities} states={states} />
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
</div>
</div>
</div>
<div className="sticky top-0 h-min bg-secondary p-4 rounded-lg">
<IssueDetailSidebar
control={control}
issueDetail={issueDetail}
submitChanges={submitChanges}
/>
</div>
</div>
) : (
<div className="h-full w-full grid place-items-center px-4 sm:px-0">
<Spinner />
</div>
)}
</div>
</AdminLayout>
);
};
export default IssueDetail;

View file

@ -0,0 +1,334 @@
// react
import React, { useEffect, useState } from "react";
// next
import type { NextPage } from "next";
import { useRouter } from "next/router";
// swr
import useSWR from "swr";
// headless ui
import { Menu, Popover, Transition } from "@headlessui/react";
// services
import stateServices from "lib/services/state.services";
import issuesServices from "lib/services/issues.services";
// hooks
import useUser from "lib/hooks/useUser";
import useTheme from "lib/hooks/useTheme";
import useIssuesProperties from "lib/hooks/useIssuesProperties";
// fetching keys
import { PROJECT_ISSUES_LIST, STATE_LIST } from "constants/fetch-keys";
// commons
import { groupBy } from "constants/common";
// layouts
import AdminLayout from "layouts/AdminLayout";
// components
import ListView from "components/project/issues/ListView";
import BoardView from "components/project/issues/BoardView";
import ConfirmIssueDeletion from "components/project/issues/ConfirmIssueDeletion";
import CreateUpdateIssuesModal from "components/project/issues/CreateUpdateIssueModal";
// ui
import { Spinner } from "ui";
import { EmptySpace, EmptySpaceItem } from "ui/EmptySpace";
import HeaderButton from "ui/HeaderButton";
import { BreadcrumbItem, Breadcrumbs } from "ui";
// icons
import { ChevronDownIcon, ListBulletIcon, RectangleStackIcon } from "@heroicons/react/24/outline";
import { PlusIcon, EyeIcon, EyeSlashIcon, Squares2X2Icon } from "@heroicons/react/20/solid";
// types
import type { IIssue, IssueResponse, Properties, IState, NestedKeyOf, ProjectMember } from "types";
import { PROJECT_MEMBERS } from "constants/api-routes";
import projectService from "lib/services/project.service";
const PRIORITIES = ["high", "medium", "low"];
const ProjectIssues: NextPage = () => {
const [isOpen, setIsOpen] = useState(false);
const { issueView, setIssueView, groupByProperty, setGroupByProperty } = useTheme();
const [selectedIssue, setSelectedIssue] = useState<
(IIssue & { actionType: "edit" | "delete" }) | undefined
>(undefined);
const [editIssue, setEditIssue] = useState<string | undefined>();
const [deleteIssue, setDeleteIssue] = useState<string | undefined>(undefined);
const { activeWorkspace, activeProject } = useUser();
const router = useRouter();
const { projectId } = router.query;
const [properties, setProperties] = useIssuesProperties(
activeWorkspace?.slug,
projectId as string
);
const { data: projectIssues } = useSWR<IssueResponse>(
projectId && activeWorkspace
? PROJECT_ISSUES_LIST(activeWorkspace.slug, projectId as string)
: null,
activeWorkspace && projectId
? () => issuesServices.getIssues(activeWorkspace.slug, projectId as string)
: null
);
const { data: states } = useSWR<IState[]>(
activeWorkspace && activeProject ? STATE_LIST(activeProject.id) : null,
activeWorkspace && activeProject
? () => stateServices.getStates(activeWorkspace.slug, activeProject.id)
: null
);
const { data: members } = useSWR<ProjectMember[]>(
activeWorkspace && activeProject ? PROJECT_MEMBERS : null,
activeWorkspace && activeProject
? () => projectService.projectMembers(activeWorkspace.slug, activeProject.id)
: null
);
useEffect(() => {
if (!isOpen) {
const timer = setTimeout(() => {
setSelectedIssue(undefined);
clearTimeout(timer);
}, 500);
}
}, [isOpen]);
const groupedByIssues: {
[key: string]: IIssue[];
} = {
...(groupByProperty === "state_detail.name"
? Object.fromEntries(
states
?.sort((a, b) => a.sequence - b.sequence)
?.map((state) => [
state.name,
projectIssues?.results.filter((issue) => issue.state === state.name) ?? [],
]) ?? []
)
: groupByProperty === "priority"
? Object.fromEntries(
PRIORITIES.map((priority) => [
priority,
projectIssues?.results.filter((issue) => issue.priority === priority) ?? [],
])
)
: {}),
...groupBy(projectIssues?.results ?? [], groupByProperty ?? ""),
};
const groupByOptions: Array<{ name: string; key: NestedKeyOf<IIssue> }> = [
{ name: "State", key: "state_detail.name" },
{ name: "Priority", key: "priority" },
{ name: "Created By", key: "created_by" },
];
return (
<AdminLayout>
<CreateUpdateIssuesModal
isOpen={isOpen && selectedIssue?.actionType !== "delete"}
setIsOpen={setIsOpen}
projectId={projectId as string}
data={selectedIssue}
/>
<ConfirmIssueDeletion
handleClose={() => setDeleteIssue(undefined)}
isOpen={!!deleteIssue}
data={projectIssues?.results.find((issue) => issue.id === deleteIssue)}
/>
{!projectIssues ? (
<div className="h-full w-full flex justify-center items-center">
<Spinner />
</div>
) : projectIssues.count > 0 ? (
<div className="w-full space-y-5">
<Breadcrumbs>
<BreadcrumbItem title="Projects" link="/projects" />
<BreadcrumbItem title={`${activeProject?.name ?? "Project"} Issues`} />
</Breadcrumbs>
<div className="flex items-center justify-between w-full">
<h2 className="text-2xl font-medium">Project Issues</h2>
<div className="flex items-center gap-x-3">
<div className="flex items-center gap-x-1">
<button
type="button"
className={`h-7 w-7 p-1 grid place-items-center rounded hover:bg-gray-200 duration-300 outline-none ${
issueView === "list" ? "bg-gray-200" : ""
}`}
onClick={() => {
setIssueView("list");
setGroupByProperty(null);
}}
>
<ListBulletIcon className="h-4 w-4" />
</button>
<button
type="button"
className={`h-7 w-7 p-1 grid place-items-center rounded hover:bg-gray-200 duration-300 outline-none ${
issueView === "kanban" ? "bg-gray-200" : ""
}`}
onClick={() => {
setIssueView("kanban");
setGroupByProperty("state_detail.name");
}}
>
<Squares2X2Icon className="h-4 w-4" />
</button>
</div>
<Menu as="div" className="relative inline-block w-40">
<div className="w-full">
<Menu.Button className="inline-flex justify-between items-center w-full rounded-md shadow-sm p-2 border border-gray-300 text-xs font-semibold text-gray-700 hover:bg-gray-100 focus:outline-none">
<span className="flex gap-x-1 items-center">
{groupByOptions.find((option) => option.key === groupByProperty)?.name ??
"No Grouping"}
</span>
<div className="flex-grow flex justify-end">
<ChevronDownIcon className="h-4 w-4" aria-hidden="true" />
</div>
</Menu.Button>
</div>
<Transition
as={React.Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items className="origin-top-left absolute left-0 mt-2 w-full rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none z-50">
<div className="p-1">
{groupByOptions.map((option) => (
<Menu.Item key={option.key}>
{({ active }) => (
<button
type="button"
className={`${
active ? "bg-theme text-white" : "text-gray-900"
} group flex w-full items-center rounded-md p-2 text-xs`}
onClick={() => setGroupByProperty(option.key)}
>
{option.name}
</button>
)}
</Menu.Item>
))}
{issueView === "list" ? (
<Menu.Item>
{({ active }) => (
<button
type="button"
className={`hover:bg-theme hover:text-white ${
active ? "bg-theme text-white" : "text-gray-900"
} group flex w-full items-center rounded-md p-2 text-xs`}
onClick={() => setGroupByProperty(null)}
>
No grouping
</button>
)}
</Menu.Item>
) : null}
</div>
</Menu.Items>
</Transition>
</Menu>
<Popover className="relative">
{({ open }) => (
<>
<Popover.Button className="inline-flex justify-between items-center rounded-md shadow-sm p-2 border border-gray-300 text-xs font-semibold text-gray-700 hover:bg-gray-100 focus:outline-none w-40">
<span>Properties</span>
<ChevronDownIcon className="h-4 w-4" />
</Popover.Button>
<Transition
as={React.Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute left-1/2 z-10 mt-1 -translate-x-1/2 transform px-2 sm:px-0 w-full">
<div className="overflow-hidden rounded-lg shadow-lg ring-1 ring-black ring-opacity-5">
<div className="relative grid bg-white p-1">
{Object.keys(properties).map((key) => (
<button
key={key}
className={`text-gray-900 hover:bg-theme hover:text-white flex justify-between w-full items-center rounded-md p-2 text-xs`}
onClick={() => setProperties(key as keyof Properties)}
>
<p className="capitalize">{key.replace("_", " ")}</p>
<span className="self-end">
{properties[key as keyof Properties] ? (
<EyeIcon width="18" height="18" />
) : (
<EyeSlashIcon width="18" height="18" />
)}
</span>
</button>
))}
</div>
</div>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
<HeaderButton
Icon={PlusIcon}
label="Add Issue"
onClick={() => {
const e = new KeyboardEvent("keydown", {
key: "i",
ctrlKey: true,
});
document.dispatchEvent(e);
}}
/>
</div>
</div>
{issueView === "list" ? (
<ListView
properties={properties}
groupedByIssues={groupedByIssues}
selectedGroup={groupByProperty}
setSelectedIssue={setSelectedIssue}
handleDeleteIssue={setDeleteIssue}
/>
) : (
<BoardView
properties={properties}
selectedGroup={groupByProperty}
groupedByIssues={groupedByIssues}
members={members}
/>
)}
</div>
) : (
<div className="h-full w-full grid place-items-center px-4 sm:px-0">
<EmptySpace
title="You don't have any issue yet."
description="Issues help you track individual pieces of work. With Issues, keep track of what's going on, who is working on it, and what's done."
Icon={RectangleStackIcon}
>
<EmptySpaceItem
title="Create a new issue"
description={
<span>
Use <pre className="inline bg-gray-100 px-2 py-1 rounded">Ctrl/Command + I</pre>{" "}
shortcut to create a new issue
</span>
}
Icon={PlusIcon}
action={() => setIsOpen(true)}
/>
</EmptySpace>
</div>
)}
</AdminLayout>
);
};
export default ProjectIssues;

View file

@ -0,0 +1,197 @@
import React, { useState } from "react";
// next
import { useRouter } from "next/router";
import type { NextPage } from "next";
// swr
import useSWR from "swr";
// headless ui
import { Menu } from "@headlessui/react";
// services
import projectService from "lib/services/project.service";
// hooks
import useUser from "lib/hooks/useUser";
// fetching keys
import { PROJECT_MEMBERS, PROJECT_INVITATIONS } from "constants/fetch-keys";
// layouts
import AdminLayout from "layouts/AdminLayout";
// components
import SendProjectInvitationModal from "components/project/SendProjectInvitationModal";
// ui
import { Spinner, Button } from "ui";
// icons
import { PlusIcon, EllipsisHorizontalIcon } from "@heroicons/react/20/solid";
import HeaderButton from "ui/HeaderButton";
import { BreadcrumbItem, Breadcrumbs } from "ui/Breadcrumbs";
const ROLE = {
5: "Guest",
10: "Viewer",
15: "Member",
20: "Admin",
};
const ProjectMembers: NextPage = () => {
const [isOpen, setIsOpen] = useState(false);
const { activeWorkspace, activeProject } = useUser();
const router = useRouter();
const { projectId } = router.query;
const { data: projectMembers, mutate: mutateMembers } = useSWR(
activeWorkspace && projectId ? PROJECT_MEMBERS(projectId as string) : null,
activeWorkspace && projectId
? () => projectService.projectMembers(activeWorkspace.slug, projectId as any)
: null
);
const { data: projectInvitations, mutate: mutateInvitations } = useSWR(
activeWorkspace && projectId ? PROJECT_INVITATIONS : null,
activeWorkspace && projectId
? () => projectService.projectInvitations(activeWorkspace.slug, projectId as any)
: null
);
let members = [
...(projectMembers?.map((item: any) => ({
id: item.id,
email: item.member?.email,
role: item.role,
status: true,
member: true,
})) || []),
...(projectInvitations?.map((item: any) => ({
id: item.id,
email: item.email,
role: item.role,
status: item.accepted,
member: false,
})) || []),
];
return (
<AdminLayout>
<SendProjectInvitationModal isOpen={isOpen} setIsOpen={setIsOpen} members={members} />
{!projectMembers || !projectInvitations ? (
<div className="h-full w-full grid place-items-center px-4 sm:px-0">
<Spinner />
</div>
) : (
<div className="h-full w-full space-y-5">
<Breadcrumbs>
<BreadcrumbItem title="Projects" link="/projects" />
<BreadcrumbItem title={`${activeProject?.name ?? "Project"} Members`} />
</Breadcrumbs>
<div className="flex items-center justify-between cursor-pointer w-full">
<h2 className="text-2xl font-medium">Invite Members</h2>
<HeaderButton Icon={PlusIcon} label="Add Member" onClick={() => setIsOpen(true)} />
</div>
{members && members.length === 0 ? null : (
<table className="min-w-full table-fixed border border-gray-300 md:rounded-lg divide-y divide-gray-300">
<thead className="bg-gray-50">
<tr>
<th
scope="col"
className="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6"
>
Name
</th>
<th
scope="col"
className="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6"
>
Role
</th>
<th
scope="col"
className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 sm:pl-6"
>
Status
</th>
<th scope="col" className="relative py-3.5 pl-3 pr-4 sm:pr-6 w-10">
<span className="sr-only">Edit</span>
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 bg-white">
{members?.map((member: any) => (
<tr key={member.id}>
<td className="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-6">
{member.email ?? "No email has been added."}
</td>
<td className="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-6">
{ROLE[member.role as keyof typeof ROLE] ?? "None"}
</td>
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500 sm:pl-6">
{member?.member ? (
"Member"
) : member.status ? (
<span className="p-0.5 px-2 text-sm bg-green-700 text-white rounded-full">
Accepted
</span>
) : (
<span className="p-0.5 px-2 text-sm bg-yellow-400 text-black rounded-full">
Pending
</span>
)}
</td>
<td className="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6">
<Menu>
<Menu.Button>
<EllipsisHorizontalIcon
width="16"
height="16"
className="inline text-gray-500"
/>
</Menu.Button>
<Menu.Items className="absolute z-50 w-28 bg-white rounded border cursor-pointer -left-20 top-9">
<Menu.Item>
<div className="hover:bg-gray-100 border-b last:border-0">
<button
className="w-full text-left py-2 pl-2"
type="button"
onClick={() => {}}
>
Edit
</button>
</div>
</Menu.Item>
<Menu.Item>
<div className="hover:bg-gray-100 border-b last:border-0">
<button
className="w-full text-left py-2 pl-2"
type="button"
onClick={async () => {
member.member
? (await projectService.deleteProjectMember(
activeWorkspace?.slug as string,
projectId as any,
member.id
),
await mutateMembers())
: (await projectService.deleteProjectInvitation(
activeWorkspace?.slug as string,
projectId as any,
member.id
),
await mutateInvitations());
}}
>
Remove
</button>
</div>
</Menu.Item>
</Menu.Items>
</Menu>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
</AdminLayout>
);
};
export default ProjectMembers;

View file

@ -0,0 +1,470 @@
import React, { useEffect, useCallback, useState } from "react";
// swr
import { mutate } from "swr";
// next
import type { NextPage } from "next";
import { useRouter } from "next/router";
// swr
import useSWR from "swr";
// react hook form
import { useForm, Controller } from "react-hook-form";
// headless ui
import { Listbox, Transition } from "@headlessui/react";
// layouts
import AdminLayout from "layouts/AdminLayout";
// service
import projectServices from "lib/services/project.service";
import workspaceService from "lib/services/workspace.service";
// hooks
import useUser from "lib/hooks/useUser";
import useToast from "lib/hooks/useToast";
// fetch keys
import { PROJECT_DETAILS, PROJECTS_LIST, WORKSPACE_MEMBERS } from "constants/fetch-keys";
// commons
import { addSpaceIfCamelCase, debounce } from "constants/common";
// components
import CreateUpdateStateModal from "components/project/issues/BoardView/state/CreateUpdateStateModal";
// ui
import { Spinner, Button, Input, TextArea, Select } from "ui";
import { Breadcrumbs, BreadcrumbItem } from "ui/Breadcrumbs";
// icons
import {
ChevronDownIcon,
CheckIcon,
PlusIcon,
PencilSquareIcon,
} from "@heroicons/react/24/outline";
// types
import type { IProject, IState, IWorkspace, WorkspaceMember } from "types";
const defaultValues: Partial<IProject> = {
name: "",
description: "",
};
const NETWORK_CHOICES = { "0": "Secret", "2": "Public" };
const ProjectSettings: NextPage = () => {
const {
register,
handleSubmit,
reset,
control,
setError,
formState: { errors, isSubmitting },
} = useForm<IProject>({
defaultValues,
});
const [isCreateStateModalOpen, setIsCreateStateModalOpen] = useState(false);
const [selectedState, setSelectedState] = useState<string | undefined>();
const router = useRouter();
const { projectId } = router.query;
const { activeWorkspace, activeProject, states } = useUser();
const { setToastAlert } = useToast();
const { data: projectDetails } = useSWR<IProject>(
activeWorkspace && projectId ? PROJECT_DETAILS : null,
activeWorkspace
? () => projectServices.getProject(activeWorkspace.slug, projectId as string)
: null
);
const { data: people } = useSWR<WorkspaceMember[]>(
activeWorkspace ? WORKSPACE_MEMBERS : null,
activeWorkspace ? () => workspaceService.workspaceMembers(activeWorkspace.slug) : null
);
useEffect(() => {
projectDetails &&
reset({
...projectDetails,
default_assignee: projectDetails.default_assignee?.id,
project_lead: projectDetails.project_lead?.id,
workspace: (projectDetails.workspace as IWorkspace).id,
});
}, [projectDetails, reset]);
const onSubmit = async (formData: IProject) => {
if (!activeWorkspace) return;
const payload: Partial<IProject> = {
name: formData.name,
network: formData.network,
identifier: formData.identifier,
description: formData.description,
default_assignee: formData.default_assignee,
project_lead: formData.project_lead,
};
await projectServices
.updateProject(activeWorkspace.slug, projectId as string, payload)
.then((res) => {
mutate<IProject>(PROJECT_DETAILS, (prevData) => ({ ...prevData, ...res }), false);
mutate<IProject[]>(
PROJECTS_LIST(activeWorkspace.slug),
(prevData) => {
const newData = prevData?.map((item) => {
if (item.id === res.id) {
return res;
}
return item;
});
return newData;
},
false
);
setToastAlert({
title: "Success",
type: "success",
message: "Project updated successfully",
});
})
.catch((err) => {
console.log(err);
});
};
const checkIdentifier = (slug: string, value: string) => {
projectServices.checkProjectIdentifierAvailability(slug, value).then((response) => {
console.log(response);
if (response.exists) setError("identifier", { message: "Identifier already exists" });
});
};
// eslint-disable-next-line react-hooks/exhaustive-deps
const checkIdentifierAvailability = useCallback(debounce(checkIdentifier, 1500), []);
return (
<AdminLayout>
<div className="space-y-5">
<CreateUpdateStateModal
isOpen={isCreateStateModalOpen || Boolean(selectedState)}
handleClose={() => {
setSelectedState(undefined);
setIsCreateStateModalOpen(false);
}}
projectId={projectId as string}
data={selectedState ? states?.find((state) => state.id === selectedState) : undefined}
/>
<Breadcrumbs>
<BreadcrumbItem title="Projects" link="/projects" />
<BreadcrumbItem title={`${activeProject?.name} Settings`} />
</Breadcrumbs>
<div className="space-y-3">
{projectDetails ? (
<div>
<form onSubmit={handleSubmit(onSubmit)} className="mt-3">
<div className="space-y-8">
<section className="space-y-5">
<div>
<h3 className="text-lg font-medium leading-6 text-gray-900">General</h3>
<p className="mt-1 text-sm text-gray-500">
This information will be displayed to every member of the project.
</p>
</div>
<div className="grid grid-cols-4 gap-3">
<div className="col-span-2">
<Input
id="name"
name="name"
error={errors.name}
register={register}
placeholder="Project Name"
label="Name"
validations={{
required: "Name is required",
}}
/>
</div>
<div>
<Select
name="network"
id="network"
options={Object.keys(NETWORK_CHOICES).map((key) => ({
value: key,
label: NETWORK_CHOICES[key as keyof typeof NETWORK_CHOICES],
}))}
label="Network"
register={register}
validations={{
required: "Network is required",
}}
/>
</div>
<div>
<Input
id="identifier"
name="identifier"
error={errors.identifier}
register={register}
placeholder="Enter identifier"
label="Identifier"
onChange={(e: any) => {
if (!activeWorkspace || !e.target.value) return;
checkIdentifierAvailability(activeWorkspace.slug, e.target.value);
}}
validations={{
required: "Identifier is required",
minLength: {
value: 1,
message: "Identifier must at least be of 1 character",
},
maxLength: {
value: 9,
message: "Identifier must at most be of 9 characters",
},
}}
/>
</div>
</div>
<div>
<TextArea
id="description"
name="description"
error={errors.description}
register={register}
label="Description"
placeholder="Enter project description"
validations={{
required: "Description is required",
}}
/>
</div>
</section>
<section className="space-y-5">
<div>
<h3 className="text-lg font-medium leading-6 text-gray-900">Control</h3>
<p className="mt-1 text-sm text-gray-500">Set the control for the project.</p>
</div>
<div className="flex justify-between gap-3">
<div className="w-full md:w-1/2">
<Controller
control={control}
name="project_lead"
render={({ field: { onChange, value } }) => (
<Listbox value={value} onChange={onChange}>
{({ open }) => (
<>
<Listbox.Label>
<div className="text-gray-500 mb-2">Project Lead</div>
</Listbox.Label>
<div className="relative">
<Listbox.Button className="bg-white relative w-full border border-gray-300 rounded-md shadow-sm pl-3 pr-10 py-2 text-left cursor-default focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">
<span className="block truncate">
{people?.find((person) => person.member.id === value)
?.member.first_name ?? "Select Lead"}
</span>
<span className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<ChevronDownIcon
className="h-5 w-5 text-gray-400"
aria-hidden="true"
/>
</span>
</Listbox.Button>
<Transition
show={open}
as={React.Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Listbox.Options className="absolute z-10 mt-1 w-full bg-white shadow-lg max-h-60 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm">
{people?.map((person) => (
<Listbox.Option
key={person.id}
className={({ active }) =>
`${
active ? "text-white bg-theme" : "text-gray-900"
} cursor-default select-none relative py-2 pl-3 pr-9`
}
value={person.member.id}
>
{({ selected, active }) => (
<>
<span
className={`${
selected ? "font-semibold" : "font-normal"
} block truncate`}
>
{person.member.first_name}
</span>
{selected ? (
<span
className={`absolute inset-y-0 right-0 flex items-center pr-4 ${
active ? "text-white" : "text-indigo-600"
}`}
>
<CheckIcon
className="h-5 w-5"
aria-hidden="true"
/>
</span>
) : null}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
</>
)}
</Listbox>
)}
/>
</div>
<div className="w-full md:w-1/2">
<Controller
control={control}
name="default_assignee"
render={({ field: { value, onChange } }) => (
<Listbox value={value} onChange={onChange}>
{({ open }) => (
<>
<Listbox.Label>
<div className="text-gray-500 mb-2">Default Assignee</div>
</Listbox.Label>
<div className="relative">
<Listbox.Button className="bg-white relative w-full border border-gray-300 rounded-md shadow-sm pl-3 pr-10 py-2 text-left cursor-default focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">
<span className="block truncate">
{people?.find((p) => p.member.id === value)?.member
.first_name ?? "Select Default Assignee"}
</span>
<span className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<ChevronDownIcon
className="h-5 w-5 text-gray-400"
aria-hidden="true"
/>
</span>
</Listbox.Button>
<Transition
show={open}
as={React.Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Listbox.Options className="absolute z-10 mt-1 w-full bg-white shadow-lg max-h-60 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm">
{people?.map((person) => (
<Listbox.Option
key={person.id}
className={({ active }) =>
`${
active ? "text-white bg-theme" : "text-gray-900"
} cursor-default select-none relative py-2 pl-3 pr-9`
}
value={person.member.id}
>
{({ selected, active }) => (
<>
<span
className={`${
selected ? "font-semibold" : "font-normal"
} block truncate`}
>
{person.member.first_name}
</span>
{selected ? (
<span
className={`absolute inset-y-0 right-0 flex items-center pr-4 ${
active ? "text-white" : "text-indigo-600"
}`}
>
<CheckIcon
className="h-5 w-5"
aria-hidden="true"
/>
</span>
) : null}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
</>
)}
</Listbox>
)}
/>
</div>
</div>
<div className="flex justify-end">
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Updating Project..." : "Update Project"}
</Button>
</div>
</section>
<section className="space-y-5">
<div>
<h3 className="text-lg font-medium leading-6 text-gray-900">State</h3>
<p className="mt-1 text-sm text-gray-500">
Manage the state of this project.
</p>
</div>
<div className="flex justify-between gap-3">
<div className="w-full space-y-5">
{states?.map((state) => (
<div
className="border p-1 px-4 rounded flex justify-between items-center"
key={state.id}
>
<div className="flex items-center gap-x-2">
<div
className="w-3 h-3 rounded-full"
style={{
backgroundColor: state.color,
}}
></div>
<h4>{addSpaceIfCamelCase(state.name)}</h4>
</div>
<div>
<button type="button" onClick={() => setSelectedState(state.id)}>
<PencilSquareIcon className="h-5 w-5 text-gray-400" />
</button>
</div>
</div>
))}
<button
type="button"
className="flex items-center gap-x-1"
onClick={() => setIsCreateStateModalOpen(true)}
>
<PlusIcon className="h-4 w-4 text-gray-400" />
<span>Add State</span>
</button>
</div>
</div>
</section>
<section className="space-y-5">
<div>
<h3 className="text-lg font-medium leading-6 text-gray-900">Labels</h3>
<p className="mt-1 text-sm text-gray-500">
Manage the labels of this project.
</p>
</div>
<div></div>
</section>
</div>
</form>
</div>
) : (
<div className="w-full h-full flex justify-center items-center">
<Spinner />
</div>
)}
</div>
</div>
</AdminLayout>
);
};
export default ProjectSettings;

View file

@ -0,0 +1,132 @@
import React, { useEffect, useState } from "react";
// next
import type { NextPage } from "next";
// hooks
import useUser from "lib/hooks/useUser";
// layouts
import AdminLayout from "layouts/AdminLayout";
// components
import CreateProjectModal from "components/project/CreateProjectModal";
import ConfirmProjectDeletion from "components/project/ConfirmProjectDeletion";
// ui
import { Button, Spinner } from "ui";
// types
import { IProject } from "types";
// services
import projectService from "lib/services/project.service";
import ProjectMemberInvitations from "components/project/memberInvitations";
import { ClipboardDocumentListIcon, PlusIcon } from "@heroicons/react/24/outline";
import { BreadcrumbItem, Breadcrumbs } from "ui/Breadcrumbs";
import { EmptySpace, EmptySpaceItem } from "ui/EmptySpace";
import HeaderButton from "ui/HeaderButton";
const Projects: NextPage = () => {
const [isOpen, setIsOpen] = useState(false);
const [deleteProject, setDeleteProject] = useState<IProject | undefined>();
const [invitationsRespond, setInvitationsRespond] = useState<string[]>([]);
const { projects, activeWorkspace, mutateProjects } = useUser();
const handleInvitation = (project_invitation: any, action: "accepted" | "withdraw") => {
if (action === "accepted") {
setInvitationsRespond((prevData) => {
return [...prevData, project_invitation.id];
});
} else if (action === "withdraw") {
setInvitationsRespond((prevData) => {
return prevData.filter((item: string) => item !== project_invitation.id);
});
}
};
const submitInvitations = () => {
projectService
.joinProject((activeWorkspace as any)?.slug, { project_ids: invitationsRespond })
.then(async (res: any) => {
console.log(res);
setInvitationsRespond([]);
await mutateProjects();
})
.catch((err: any) => {
console.log(err);
});
};
useEffect(() => {
if (isOpen) return;
const timer = setTimeout(() => {
setDeleteProject(undefined);
clearTimeout(timer);
}, 300);
}, [isOpen]);
return (
<AdminLayout>
<CreateProjectModal isOpen={isOpen && !deleteProject} setIsOpen={setIsOpen} />
<ConfirmProjectDeletion
isOpen={isOpen && !!deleteProject}
setIsOpen={setIsOpen}
data={deleteProject}
/>
{projects ? (
<>
{projects.length === 0 ? (
<div className="h-full w-full grid place-items-center px-4 sm:px-0">
<EmptySpace
title="You don't have any project yet."
description="Projects are a collection of issues. They can be used to represent the development work for a product, project, or service."
Icon={ClipboardDocumentListIcon}
>
<EmptySpaceItem
title="Create a new project"
description={
<span>
Use{" "}
<pre className="inline bg-gray-100 px-2 py-1 rounded">Ctrl/Command + P</pre>{" "}
shortcut to create a new project
</span>
}
Icon={PlusIcon}
action={() => setIsOpen(true)}
/>
</EmptySpace>
</div>
) : (
<div className="h-full w-full space-y-5">
<Breadcrumbs>
<BreadcrumbItem title={`${activeWorkspace?.name ?? "Workspace"} Projects`} />
</Breadcrumbs>
<div className="flex items-center justify-between cursor-pointer w-full">
<h2 className="text-2xl font-medium">Projects</h2>
<HeaderButton Icon={PlusIcon} label="Add Project" onClick={() => setIsOpen(true)} />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{projects.map((item) => (
<ProjectMemberInvitations
key={item.id}
project={item}
slug={(activeWorkspace as any).slug}
invitationsRespond={invitationsRespond}
handleInvitation={handleInvitation}
setDeleteProject={setDeleteProject}
/>
))}
</div>
{invitationsRespond.length > 0 && (
<div className="flex justify-between mt-4">
<Button onClick={submitInvitations}>Submit</Button>
</div>
)}
</div>
)}
</>
) : (
<div className="w-full h-full flex justify-center items-center">
<Spinner />
</div>
)}
</AdminLayout>
);
};
export default Projects;

181
apps/app/pages/signin.tsx Normal file
View file

@ -0,0 +1,181 @@
import React, { useCallback, useState, useEffect } from "react";
// next
import type { NextPage } from "next";
import Link from "next/link";
import { useRouter } from "next/router";
import Image from "next/image";
// hooks
import useUser from "lib/hooks/useUser";
// services
import authenticationService from "lib/services/authentication.service";
// layouts
import DefaultLayout from "layouts/DefaultLayout";
// social button
import { GoogleLoginButton } from "components/socialbuttons/google-login";
import EmailCodeForm from "components/forms/EmailCodeForm";
import EmailPasswordForm from "components/forms/EmailPasswordForm";
// logos
import Logo from "public/logo.png";
import GitHubLogo from "public/logos/github.png";
import { KeyIcon } from "@heroicons/react/24/outline";
// types
type SignIn = {
email: string;
password?: string;
medium?: string;
key?: string;
token?: string;
};
const SignIn: NextPage = () => {
const [useCode, setUseCode] = useState(true);
const router = useRouter();
const { mutateUser, mutateWorkspaces } = useUser();
const [githubToken, setGithubToken] = useState(undefined);
const [loginCallBackURL, setLoginCallBackURL] = useState(undefined);
const [isGoogleAuthenticationLoading, setIsGoogleAuthenticationLoading] = useState(false);
const onSignInSuccess = useCallback(
async (res: any) => {
await mutateUser();
await mutateWorkspaces();
if (res.user.is_onboarded) router.push("/");
else router.push("/invitations");
},
[mutateUser, mutateWorkspaces, router]
);
const githubTokenMemo = React.useMemo(() => {
return githubToken;
}, [githubToken]);
useEffect(() => {
const {
query: { code },
} = router;
if (code && !githubTokenMemo) {
setGithubToken(code as any);
}
}, [router, githubTokenMemo]);
useEffect(() => {
if (githubToken) {
authenticationService
.socialAuth({
medium: "github",
credential: githubToken,
clientId: process.env.NEXT_PUBLIC_GITHUB_ID,
})
.then(async (response) => {
await onSignInSuccess(response);
})
.catch((err) => {
console.log(err);
});
}
}, [githubToken, mutateUser, mutateWorkspaces, router, onSignInSuccess]);
useEffect(() => {
const origin =
typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
setLoginCallBackURL(`${origin}/signin` as any);
return () => setIsGoogleAuthenticationLoading(false);
}, []);
return (
<DefaultLayout
meta={{
title: "Plane - Sign In",
}}
>
{isGoogleAuthenticationLoading && (
<div className="absolute top-0 left-0 w-full h-full bg-white z-50 flex items-center justify-center">
<h2 className="text-2xl text-black">Sign in with Google. Please wait...</h2>
</div>
)}
<div className="w-full h-screen flex justify-center items-center bg-gray-50 overflow-auto">
<div className="min-h-full w-full flex flex-col justify-center py-12 px-6 lg:px-8">
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<div className="text-center">
<Image src={Logo} height={40} width={179} alt="Plane Web Logo" />
</div>
<h2 className="mt-3 text-center text-3xl font-bold text-gray-900">
Sign in to your account
</h2>
<div className="bg-white mt-16 py-8 px-4 sm:rounded-lg sm:px-10">
{useCode ? (
<EmailCodeForm onSuccess={onSignInSuccess} />
) : (
<EmailPasswordForm onSuccess={onSignInSuccess} />
)}
<div className="mt-6">
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300" />
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-white text-gray-500">or</span>
</div>
</div>
<div className="mt-6 w-full flex flex-col items-stretch gap-y-2">
<button
type="button"
className="w-full border border-gray-300 hover:bg-gray-100 px-3 py-2 rounded text-sm flex items-center duration-300"
onClick={() => setUseCode((prev) => !prev)}
>
<KeyIcon className="h-[25px] w-[25px]" />
<span className="text-center w-full font-medium">
{useCode ? "Continue with Password" : "Continue with Code"}
</span>
</button>
<GoogleLoginButton
onSuccess={({ clientId, credential }) => {
setIsGoogleAuthenticationLoading(true);
authenticationService
.socialAuth({
medium: "google",
credential,
clientId,
})
.then(async (response) => {
await onSignInSuccess(response);
})
.catch((err) => {
console.log(err);
setIsGoogleAuthenticationLoading(false);
});
}}
onFailure={(err) => {
console.log(err);
}}
/>
<Link
href={`https://github.com/login/oauth/authorize?client_id=${process.env.NEXT_PUBLIC_GITHUB_ID}&redirect_uri=${loginCallBackURL}`}
>
<button className="w-full bg-black opacity-90 hover:opacity-100 text-white text-sm flex items-center px-3 py-2 rounded duration-300">
<Image
src={GitHubLogo}
height={25}
width={25}
className="flex-shrink-0"
alt="GitHub Logo"
/>
<span className="text-center w-full font-medium">Continue with GitHub</span>
</button>
</Link>
</div>
</div>
</div>
</div>
</div>
</div>
</DefaultLayout>
);
};
export default SignIn;

View file

@ -0,0 +1,150 @@
import React from "react";
// next
import type { NextPage } from "next";
import Link from "next/link";
import { useRouter } from "next/router";
// swr
import useSWR from "swr";
// services
import workspaceService from "lib/services/workspace.service";
// constants
import { WORKSPACE_INVITATION } from "constants/fetch-keys";
// hooks
import useUser from "lib/hooks/useUser";
// layouts
import DefaultLayout from "layouts/DefaultLayout";
// ui
import { Button } from "ui";
// icons
import {
ChartBarIcon,
ChevronRightIcon,
CubeIcon,
ShareIcon,
StarIcon,
UserIcon,
} from "@heroicons/react/24/outline";
import { EmptySpace, EmptySpaceItem } from "ui/EmptySpace";
const WorkspaceInvitation: NextPage = () => {
const router = useRouter();
const { invitationId, email } = router.query;
const { user } = useUser();
const { data: invitationDetail, error } = useSWR(WORKSPACE_INVITATION, () =>
workspaceService.getWorkspaceInvitation(invitationId as string)
);
const handleAccept = () => {
workspaceService
.joinWorkspace(invitationDetail.workspace.slug, invitationDetail.id, {
accepted: true,
email: invitationDetail.email,
})
.then((res) => {
router.push("/signin");
})
.catch((err) => console.error(err));
};
return (
<DefaultLayout>
<div className="w-full h-full flex flex-col justify-center items-center px-3">
{invitationDetail ? (
<>
{error ? (
<div className="bg-gray-50 rounded shadow-2xl border px-4 py-8 w-full md:w-1/3 space-y-4 flex flex-col text-center">
<h2 className="text-xl uppercase">INVITATION NOT FOUND</h2>
</div>
) : (
<>
<div className="bg-gray-50 rounded shadow-2xl border px-4 py-8 w-full md:w-1/3 space-y-4 flex flex-col justify-between">
{invitationDetail.accepted ? (
<>
<h2 className="text-2xl">
You are already a member of {invitationDetail.workspace.name}
</h2>
<div className="w-full flex gap-x-4">
<Link href="/signin">
<a className="w-full">
<Button className="w-full">Go To Login Page</Button>
</a>
</Link>
</div>
</>
) : (
<>
<h2 className="text-2xl">
You have been invited to{" "}
<span className="font-semibold italic">
{invitationDetail.workspace.name}
</span>
</h2>
<div className="w-full flex gap-x-4">
<Link href="/">
<a className="w-full">
<Button theme="secondary" className="w-full">
Ignore
</Button>
</a>
</Link>
<Button className="w-full" onClick={handleAccept}>
Accept
</Button>
</div>
</>
)}
</div>
</>
)}
</>
) : (
<>
<EmptySpace
title="This invitation link is not active anymore."
description="Your workspace is where you'll create projects, collaborate on your issues, and organize different streams of work in your Plane account."
link={{ text: "Or start from an empty project", href: "/" }}
>
{!user ? (
<EmptySpaceItem
Icon={UserIcon}
title="Sign in to continue"
action={() => {
router.push("/signin");
}}
/>
) : (
<EmptySpaceItem
Icon={CubeIcon}
title="Continue to Dashboard"
action={() => {
router.push("/");
}}
/>
)}
<EmptySpaceItem
Icon={StarIcon}
title="Star us on GitHub"
action={() => {
router.push("https://github.com/makeplane");
}}
/>
<EmptySpaceItem
Icon={ShareIcon}
title="Join our community of active creators"
action={() => {
router.push("https://discord.com/invite/8SR2N9PAcJ");
}}
/>
</EmptySpace>
</>
)}
</div>
</DefaultLayout>
);
};
export default WorkspaceInvitation;

View file

@ -0,0 +1,172 @@
// next
import type { NextPage } from "next";
import Link from "next/link";
// react
import React from "react";
// layouts
import AdminLayout from "layouts/AdminLayout";
// swr
import useSWR from "swr";
// hooks
import useUser from "lib/hooks/useUser";
// hoc
import withAuthWrapper from "lib/hoc/withAuthWrapper";
// fetch keys
import { USER_ISSUE } from "constants/fetch-keys";
// services
import userService from "lib/services/user.service";
// ui
import { Spinner } from "ui";
// icons
import { ArrowRightIcon } from "@heroicons/react/24/outline";
// types
import type { IIssue } from "types";
const Workspace: NextPage = () => {
const { user, activeWorkspace, projects } = useUser();
const { data: myIssues } = useSWR<IIssue[]>(
user ? USER_ISSUE : null,
user ? () => userService.userIssues() : null
);
const cards = [
{
id: 1,
numbers: projects?.length ?? 0,
title: "Projects",
},
{
id: 3,
numbers: myIssues?.length ?? 0,
title: "Issues",
},
];
const hours = new Date().getHours();
return (
<AdminLayout>
<div className="h-full w-full space-y-5">
{user ? (
<div className="font-medium text-2xl">
Good{" "}
{hours >= 4 && hours < 12
? "Morning"
: hours >= 12 && hours < 17
? "Afternoon"
: "Evening"}
, {user.first_name}!!
</div>
) : (
<div className="animate-pulse" role="status">
<div className="font-semibold text-2xl h-8 bg-gray-200 rounded dark:bg-gray-700 w-60"></div>
</div>
)}
{/* dashboard */}
<div className="flex flex-col gap-8">
<div className="grid grid-cols-2 gap-5">
{cards.map(({ id, title, numbers }) => (
<div className="py-6 px-6 min-w-[150px] flex-1 bg-white rounded-lg shadow" key={id}>
<p className="text-gray-500 mt-2 uppercase">#{title}</p>
<h2 className="text-2xl font-semibold">{numbers}</h2>
</div>
))}
</div>
<div className="grid grid-cols-3 gap-5">
<div className="max-h-[30rem] overflow-y-auto w-full border border-gray-200 bg-white rounded-lg shadow-sm col-span-2">
{myIssues ? (
myIssues.length > 0 ? (
<table className="h-full w-full overflow-y-auto">
<thead className="border-b bg-gray-50 text-sm">
<tr>
<th scope="col" className="px-3 py-4 text-left">
ISSUE
</th>
<th scope="col" className="px-3 py-4 text-left">
KEY
</th>
<th scope="col" className="px-3 py-4 text-left">
STATUS
</th>
</tr>
</thead>
<tbody>
{myIssues?.map((issue, index) => (
<tr
className="border-t transition duration-300 ease-in-out hover:bg-gray-100 text-gray-900 gap-3 text-sm"
key={index}
>
<td className="px-3 py-4 font-medium">
<Link href={`/projects/${issue.project}/issues/${issue.id}`}>
<a className="hover:text-theme duration-300">{issue.name}</a>
</Link>
</td>
<td className="px-3 py-4">{issue.sequence_id}</td>
<td className="px-3 py-4">
<span
className="rounded px-2 py-1 text-xs font-medium"
style={{
border: `2px solid ${issue.state_detail.color}`,
backgroundColor: `${issue.state_detail.color}20`,
}}
>
{issue.state_detail.name ?? "None"}
</span>
</td>
</tr>
))}
</tbody>
</table>
) : (
<div className="m-10">
<p className="text-gray-500 text-center">No Issues Found</p>
</div>
)
) : (
<div className="flex justify-center items-center p-10">
<Spinner />
</div>
)}
</div>
<div className="py-6 px-6 min-w-[150px] flex-1 bg-white rounded-lg shadow">
<h3 className="text-lg font-semibold mb-5">PROJECTS</h3>
<div className="space-y-3">
{projects && activeWorkspace ? (
projects.length > 0 ? (
projects
.sort((a, b) => Date.parse(`${a.updated_at}`) - Date.parse(`${b.updated_at}`))
.map(
(project, index) =>
index < 3 && (
<Link href={`/projects/${project.id}/issues`} key={project.id}>
<a className="flex justify-between">
<div>
<h3>{project.name}</h3>
</div>
<div className="text-gray-400">
<ArrowRightIcon className="w-5" />
</div>
</a>
</Link>
)
)
) : (
<p className="text-gray-500">No projects has been create for this workspace.</p>
)
) : (
<div className="w-full h-full flex items-center justify-center">
<Spinner />
</div>
)}
</div>
</div>
</div>
</div>
</div>
</AdminLayout>
);
};
export default withAuthWrapper(Workspace);

View file

@ -0,0 +1,210 @@
import React, { useState } from "react";
// next
import type { NextPage } from "next";
// swr
import useSWR from "swr";
// headless ui
import { Menu } from "@headlessui/react";
// hooks
import useUser from "lib/hooks/useUser";
// services
import workspaceService from "lib/services/workspace.service";
// constants
import { WORKSPACE_INVITATIONS, WORKSPACE_MEMBERS } from "constants/fetch-keys";
// hoc
import withAuthWrapper from "lib/hoc/withAuthWrapper";
// layouts
import AdminLayout from "layouts/AdminLayout";
// components
import SendWorkspaceInvitationModal from "components/workspace/SendWorkspaceInvitationModal";
// ui
import { Spinner, Button } from "ui";
// icons
import { PlusIcon, EllipsisHorizontalIcon } from "@heroicons/react/20/solid";
import HeaderButton from "ui/HeaderButton";
import { BreadcrumbItem, Breadcrumbs } from "ui/Breadcrumbs";
// types
const ROLE = {
5: "Guest",
10: "Viewer",
15: "Member",
20: "Admin",
};
const WorkspaceInvite: NextPage = () => {
const [isOpen, setIsOpen] = useState(false);
const { activeWorkspace } = useUser();
const { data: workspaceMembers, mutate: mutateMembers } = useSWR<any[]>(
activeWorkspace ? WORKSPACE_MEMBERS : null,
activeWorkspace ? () => workspaceService.workspaceMembers(activeWorkspace.slug) : null
);
const { data: workspaceInvitations, mutate: mutateInvitations } = useSWR<any[]>(
activeWorkspace ? WORKSPACE_INVITATIONS : null,
activeWorkspace ? () => workspaceService.workspaceInvitations(activeWorkspace.slug) : null
);
const members = [
...(workspaceMembers?.map((item) => ({
id: item.id,
email: item.member?.email,
role: item.role,
status: true,
member: true,
})) || []),
...(workspaceInvitations?.map((item) => ({
id: item.id,
email: item.email,
role: item.role,
status: item.accepted,
member: false,
})) || []),
];
return (
<AdminLayout
meta={{
title: "Plane - Workspace Invite",
}}
>
<SendWorkspaceInvitationModal
isOpen={isOpen}
setIsOpen={setIsOpen}
workspace_slug={activeWorkspace?.slug as string}
members={members}
/>
{!workspaceMembers || !workspaceInvitations ? (
<div className="h-full w-full grid place-items-center px-4 sm:px-0">
<Spinner />
</div>
) : (
<div className="w-full space-y-5">
<Breadcrumbs>
<BreadcrumbItem title={`${activeWorkspace?.name ?? "Workspace"} Members`} />
</Breadcrumbs>
<div className="flex items-center justify-between cursor-pointer w-full">
<h2 className="text-2xl font-medium">Invite Members</h2>
<HeaderButton Icon={PlusIcon} label="Add Member" onClick={() => setIsOpen(true)} />
</div>
{members && members.length === 0 ? null : (
<>
<table className="min-w-full table-fixed border border-gray-300 md:rounded-lg divide-y divide-gray-300">
<thead className="bg-gray-50">
<tr>
<th
scope="col"
className="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6"
>
Name
</th>
<th
scope="col"
className="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6"
>
Role
</th>
<th
scope="col"
className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 sm:pl-6"
>
Status
</th>
<th scope="col" className="relative py-3.5 pl-3 pr-4 sm:pr-6 w-10">
<span className="sr-only">Edit</span>
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 bg-white">
{members?.map((member: any) => (
<tr key={member.id}>
<td className="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-6">
{member.email ?? "No email has been added."}
</td>
<td className="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-6">
{ROLE[member.role as keyof typeof ROLE] ?? "None"}
</td>
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500 sm:pl-6">
{member?.member ? (
<span className="p-0.5 px-2 text-sm bg-green-700 text-white rounded-full">
Accepted
</span>
) : member.status ? (
<span className="p-0.5 px-2 text-sm bg-green-700 text-white rounded-full">
Accepted
</span>
) : (
<span className="p-0.5 px-2 text-sm bg-yellow-400 text-black rounded-full">
Pending
</span>
)}
</td>
<td className="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6">
<Menu>
<Menu.Button>
<EllipsisHorizontalIcon
width="16"
height="16"
className="inline text-gray-500"
/>
</Menu.Button>
<Menu.Items className="absolute z-50 w-28 bg-white rounded border cursor-pointer -left-20 top-9">
<Menu.Item>
<div className="hover:bg-gray-100 border-b last:border-0">
<button
className="w-full text-left py-2 pl-2"
type="button"
onClick={() => {}}
>
Edit
</button>
</div>
</Menu.Item>
<Menu.Item>
<div className="hover:bg-gray-100 border-b last:border-0">
<button
className="w-full text-left py-2 pl-2"
type="button"
onClick={async () => {
member.member
? (await workspaceService.deleteWorkspaceMember(
activeWorkspace?.slug as string,
member.id
),
await mutateMembers((prevData) => [
...(prevData ?? [])?.filter(
(m: any) => m.id !== member.id
),
]),
false)
: (await workspaceService.deleteWorkspaceInvitations(
activeWorkspace?.slug as string,
member.id
),
await mutateInvitations((prevData) => [
...(prevData ?? []).filter((m) => m.id !== member.id),
false,
]));
}}
>
Remove
</button>
</div>
</Menu.Item>
</Menu.Items>
</Menu>
</td>
</tr>
))}
</tbody>
</table>
</>
)}
</div>
)}
</AdminLayout>
);
};
export default withAuthWrapper(WorkspaceInvite);

View file

@ -0,0 +1,235 @@
import React, { useEffect, useState } from "react";
// next
import Image from "next/image";
// react hook form
import { useForm } from "react-hook-form";
// react dropzone
import Dropzone from "react-dropzone";
// services
import workspaceService from "lib/services/workspace.service";
import fileServices from "lib/services/file.services";
// layouts
import AdminLayout from "layouts/AdminLayout";
// hooks
import useUser from "lib/hooks/useUser";
import useToast from "lib/hooks/useToast";
// components
import ConfirmWorkspaceDeletion from "components/workspace/ConfirmWorkspaceDeletion";
// ui
import { Spinner, Button, Input, Select } from "ui";
import { BreadcrumbItem, Breadcrumbs } from "ui/Breadcrumbs";
// types
import type { IWorkspace } from "types";
import { Tab } from "@headlessui/react";
const defaultValues: Partial<IWorkspace> = {
name: "",
};
const WorkspaceSettings = () => {
const { activeWorkspace, mutateWorkspaces } = useUser();
const { setToastAlert } = useToast();
const [isOpen, setIsOpen] = useState(false);
const [image, setImage] = useState<File | null>(null);
const [isImageUploading, setIsImageUploading] = useState(false);
const {
register,
handleSubmit,
reset,
watch,
setValue,
formState: { errors, isSubmitting },
} = useForm<IWorkspace>({
defaultValues: { ...defaultValues, ...activeWorkspace },
});
useEffect(() => {
activeWorkspace && reset({ ...activeWorkspace });
}, [activeWorkspace, reset]);
const onSubmit = async (formData: IWorkspace) => {
if (!activeWorkspace) return;
const payload: Partial<IWorkspace> = {
logo: formData.logo,
name: formData.name,
company_size: formData.company_size,
};
await workspaceService
.updateWorkspace(activeWorkspace.slug, payload)
.then(async (res) => {
await mutateWorkspaces((workspaces) => {
return (workspaces ?? []).map((workspace) => {
if (workspace.slug === activeWorkspace.slug) {
return {
...workspace,
...res,
};
}
return workspace;
});
}, false);
setToastAlert({
title: "Success",
type: "success",
message: "Workspace updated successfully",
});
})
.catch((err) => console.log(err));
};
return (
<AdminLayout
meta={{
title: "Plane - Workspace Settings",
}}
>
<ConfirmWorkspaceDeletion isOpen={isOpen} setIsOpen={setIsOpen} />
<div className="space-y-5">
<Breadcrumbs>
<BreadcrumbItem title={`${activeWorkspace?.name ?? "Workspace"} Settings`} />
</Breadcrumbs>
{activeWorkspace ? (
<div className="space-y-8">
<Tab.Group>
<Tab.List className="flex items-center gap-3">
{["General", "Actions"].map((tab, index) => (
<Tab
key={index}
className={({ selected }) =>
`text-md leading-6 text-gray-900 px-4 py-1 rounded outline-none ${
selected ? "bg-gray-700 text-white" : "hover:bg-gray-200"
} duration-300`
}
>
{tab}
</Tab>
))}
</Tab.List>
<Tab.Panels>
<Tab.Panel>
<div className="grid grid-cols-2 gap-6">
<div className="w-full space-y-3">
<Dropzone
multiple={false}
accept={{
"image/*": [],
}}
onDrop={(files) => {
setImage(files[0]);
}}
>
{({ getRootProps, getInputProps }) => (
<div>
<input {...getInputProps()} />
<div className="text-gray-500 mb-2">Logo</div>
<div>
<div className="h-60 bg-blue-50" {...getRootProps()}>
{((watch("logo") &&
watch("logo") !== null &&
watch("logo") !== "") ||
(image && image !== null)) && (
<div className="relative flex mx-auto h-60">
<Image
src={image ? URL.createObjectURL(image) : watch("logo") ?? ""}
alt="Workspace Logo"
objectFit="cover"
layout="fill"
priority
/>
</div>
)}
</div>
<p className="text-sm text-gray-500 mt-2">
Max file size is 500kb. Supported file types are .jpg and .png.
</p>
</div>
</div>
)}
</Dropzone>
<div>
<Button
onClick={() => {
if (image === null) return;
setIsImageUploading(true);
const formData = new FormData();
formData.append("asset", image);
formData.append("attributes", JSON.stringify({}));
fileServices
.uploadFile(formData)
.then((response) => {
const imageUrl = response.asset;
setValue("logo", imageUrl);
handleSubmit(onSubmit)();
setIsImageUploading(false);
})
.catch((err) => {
setIsImageUploading(false);
});
}}
>
{isImageUploading ? "Uploading..." : "Upload"}
</Button>
</div>
</div>
<div className="space-y-3">
<div>
<Input
id="name"
name="name"
label="Name"
placeholder="Name"
autoComplete="off"
register={register}
error={errors.name}
validations={{
required: "Name is required",
}}
/>
</div>
<div>
<Select
id="company_size"
name="company_size"
label="How large is your company?"
options={[
{ value: 5, label: "5" },
{ value: 10, label: "10" },
{ value: 25, label: "25" },
{ value: 50, label: "50" },
]}
/>
</div>
<div className="text-right">
<Button onClick={handleSubmit(onSubmit)} disabled={isSubmitting}>
{isSubmitting ? "Updating..." : "Update"}
</Button>
</div>
</div>
</div>
</Tab.Panel>
<Tab.Panel>
<div>
<Button theme="danger" onClick={() => setIsOpen(true)}>
Delete the workspace
</Button>
</div>
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
</div>
) : (
<div className="h-full w-full grid place-items-center px-4 sm:px-0">
<Spinner />
</div>
)}
</div>
</AdminLayout>
);
};
export default WorkspaceSettings;