dev: hello world
This commit is contained in:
commit
6037fed3f4
145 changed files with 16848 additions and 0 deletions
20
pages/_app.tsx
Normal file
20
pages/_app.tsx
Normal 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;
|
||||
13
pages/api/hello.ts
Normal file
13
pages/api/hello.ts
Normal 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' })
|
||||
}
|
||||
147
pages/create-workspace.tsx
Normal file
147
pages/create-workspace.tsx
Normal 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
pages/editor.tsx
Normal file
41
pages/editor.tsx
Normal 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;
|
||||
42
pages/index.tsx
Normal file
42
pages/index.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import React, { useEffect } from "react";
|
||||
// next
|
||||
import type { NextPage } from "next";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
// swr
|
||||
import useSWR from "swr";
|
||||
// hooks
|
||||
import useUser from "lib/hooks/useUser";
|
||||
// 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";
|
||||
import ProjectLayout from "layouts/ProjectLayout";
|
||||
|
||||
const Home: NextPage = () => {
|
||||
const router = useRouter();
|
||||
|
||||
const { user, isUserLoading, activeWorkspace, projects, workspaces } = useUser();
|
||||
|
||||
const { data: myIssues } = useSWR<IIssue[]>(
|
||||
user ? USER_ISSUE : null,
|
||||
user ? () => userService.userIssues() : null
|
||||
);
|
||||
|
||||
if (!isUserLoading && !user) router.push("/signin");
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeWorkspace && workspaces?.length === 0) router.push("/invitations");
|
||||
else if (activeWorkspace) router.push(`/workspace/`);
|
||||
}, [activeWorkspace, router, workspaces]);
|
||||
|
||||
return <></>;
|
||||
};
|
||||
|
||||
export default Home;
|
||||
202
pages/invitations.tsx
Normal file
202
pages/invitations.tsx
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
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";
|
||||
// headless ui
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
// 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";
|
||||
// hoc
|
||||
import withAuthWrapper from "lib/hoc/withAuthWrapper";
|
||||
// layouts
|
||||
import DefaultLayout from "layouts/DefaultLayout";
|
||||
// ui
|
||||
import { Button, Spinner } from "ui";
|
||||
// types
|
||||
import type { IWorkspaceInvitation } from "types";
|
||||
import { ChartBarIcon, ChevronRightIcon, CubeIcon, PlusIcon } from "@heroicons/react/24/outline";
|
||||
import { EmptySpace, EmptySpaceItem } from "ui/EmptySpace";
|
||||
|
||||
const OnBoard: NextPage = () => {
|
||||
const [canRedirect, setCanRedirect] = useState(true);
|
||||
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();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
});
|
||||
};
|
||||
|
||||
// useEffect(() => {
|
||||
// if (!invitations) return;
|
||||
// else
|
||||
// invitations.forEach((invite) => {
|
||||
// if (invite.accepted)
|
||||
// setInvitationsRespond((prevData) => {
|
||||
// return [...prevData, invite.workspace.id];
|
||||
// });
|
||||
// });
|
||||
// }, [invitations, router, workspaces]);
|
||||
|
||||
useEffect(() => {
|
||||
if (workspaces && workspaces.length === 0) {
|
||||
setCanRedirect(false);
|
||||
}
|
||||
}, [workspaces]);
|
||||
|
||||
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 ? (
|
||||
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 className="h-full flex items-center gap-x-1">
|
||||
<input
|
||||
id={`${item.id}`}
|
||||
aria-describedby="workspaces"
|
||||
name={`${item.id}`}
|
||||
checked={invitationsRespond.includes(item.workspace.id)}
|
||||
value={item.workspace.name}
|
||||
onChange={() => {
|
||||
handleInvitation(item, 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"
|
||||
/>
|
||||
<label htmlFor={item.id} className="text-sm">
|
||||
Reject
|
||||
</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>
|
||||
) : (
|
||||
<EmptySpace
|
||||
title={`${
|
||||
canRedirect
|
||||
? `You haven't been invited to any workspace yet.`
|
||||
: "You don't have any workspaces, Start by creating one."
|
||||
}`}
|
||||
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={canRedirect ? CubeIcon : PlusIcon}
|
||||
title={canRedirect ? "Continue to Dashboard" : "Create your Workspace"}
|
||||
action={() => {
|
||||
userService.updateUserOnBoard().then((response) => {
|
||||
console.log(response);
|
||||
});
|
||||
router.push(canRedirect ? "/" : "/create-workspace");
|
||||
}}
|
||||
/>
|
||||
</EmptySpace>
|
||||
)
|
||||
) : (
|
||||
<div className="w-full h-full flex justify-center items-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DefaultLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default withAuthWrapper(OnBoard);
|
||||
169
pages/me/my-issues.tsx
Normal file
169
pages/me/my-issues.tsx
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
// react
|
||||
import React, { useState } from "react";
|
||||
// next
|
||||
import type { NextPage } from "next";
|
||||
// swr
|
||||
import useSWR from "swr";
|
||||
// layouts
|
||||
import ProjectLayout from "layouts/ProjectLayout";
|
||||
// hooks
|
||||
import useUser from "lib/hooks/useUser";
|
||||
// components
|
||||
import CreateUpdateIssuesModal from "components/project/issues/CreateUpdateIssueModal";
|
||||
// ui
|
||||
import { Spinner } from "ui";
|
||||
import { BreadcrumbItem, Breadcrumbs } from "ui/Breadcrumbs";
|
||||
import { EmptySpace, EmptySpaceItem } from "ui/EmptySpace";
|
||||
import HeaderButton from "ui/HeaderButton";
|
||||
// icons
|
||||
import { PlusIcon, RectangleStackIcon } from "@heroicons/react/24/outline";
|
||||
// services
|
||||
import userService from "lib/services/user.service";
|
||||
// types
|
||||
import { IIssue } from "types";
|
||||
// constants
|
||||
import ChangeStateDropdown from "components/project/issues/my-issues/ChangeStateDropdown";
|
||||
import { USER_ISSUE } from "constants/fetch-keys";
|
||||
import { classNames } from "constants/common";
|
||||
|
||||
const MyIssues: NextPage = () => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const { user } = useUser();
|
||||
|
||||
const { data: myIssues } = useSWR<IIssue[]>(
|
||||
user ? USER_ISSUE : null,
|
||||
user ? () => userService.userIssues() : null
|
||||
);
|
||||
|
||||
return (
|
||||
<ProjectLayout>
|
||||
<CreateUpdateIssuesModal isOpen={isOpen} setIsOpen={setIsOpen} />
|
||||
<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"
|
||||
action={() => {
|
||||
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 max-w-[15rem]">
|
||||
{myIssue.name}
|
||||
</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} />
|
||||
</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={() => setIsOpen(true)}
|
||||
/>
|
||||
</EmptySpace>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="w-full h-full flex justify-center items-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ProjectLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default MyIssues;
|
||||
226
pages/me/profile.tsx
Normal file
226
pages/me/profile.tsx
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
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 from "react-dropzone";
|
||||
// hooks
|
||||
import useUser from "lib/hooks/useUser";
|
||||
// layouts
|
||||
import ProjectLayout from "layouts/ProjectLayout";
|
||||
// services
|
||||
import userService from "lib/services/user.service";
|
||||
import fileServices from "lib/services/file.services";
|
||||
// ui
|
||||
import { Button, Input, Spinner } from "ui";
|
||||
// types
|
||||
import type { IUser } from "types";
|
||||
import { UserIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
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 { user: myProfile, mutateUser } = useUser();
|
||||
|
||||
const onSubmit = (formData: IUser) => {
|
||||
userService
|
||||
.updateUser(formData)
|
||||
.then((response) => {
|
||||
console.log(response);
|
||||
mutateUser(response, false);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
});
|
||||
};
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
watch,
|
||||
setValue,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<IUser>({ defaultValues });
|
||||
|
||||
useEffect(() => {
|
||||
reset({ ...defaultValues, ...myProfile });
|
||||
}, [myProfile, reset]);
|
||||
|
||||
return (
|
||||
<ProjectLayout
|
||||
meta={{
|
||||
title: "Plane - My Profile",
|
||||
}}
|
||||
>
|
||||
<div className="w-full h-full md:px-20 p-8 flex flex-wrap overflow-auto gap-y-10 justify-center items-center">
|
||||
{myProfile ? (
|
||||
<>
|
||||
<div className="w-2/5">
|
||||
<Dropzone
|
||||
multiple={false}
|
||||
accept={{
|
||||
"image/*": [],
|
||||
}}
|
||||
onDrop={(files) => {
|
||||
setImage(files[0]);
|
||||
}}
|
||||
>
|
||||
{({ getRootProps, getInputProps }) => (
|
||||
<div className="space-y-4">
|
||||
<input {...getInputProps()} />
|
||||
<h2 className="font-semibold text-xl">Profile Picture</h2>
|
||||
<div className="relative">
|
||||
<span
|
||||
className="inline-block h-24 w-24 rounded-full 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-24 w-24 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. Supported file types are .jpg and .png.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Dropzone>
|
||||
<Button
|
||||
type="button"
|
||||
className="mt-4"
|
||||
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("avatar", imageUrl);
|
||||
handleSubmit(onSubmit)();
|
||||
setIsImageUploading(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
setIsImageUploading(false);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{isImageUploading ? "Uploading..." : "Upload"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-5 w-3/5">
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="space-y-4">
|
||||
<h2 className="font-semibold text-xl">Details</h2>
|
||||
<div className="flex gap-x-4">
|
||||
<div className="flex-grow">
|
||||
<Input
|
||||
name="first_name"
|
||||
id="first_name"
|
||||
register={register}
|
||||
error={errors.first_name}
|
||||
label="First Name"
|
||||
placeholder="Enter your first name"
|
||||
autoComplete="off"
|
||||
validations={{
|
||||
required: "This field is required.",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-grow">
|
||||
<Input
|
||||
name="last_name"
|
||||
register={register}
|
||||
error={errors.last_name}
|
||||
id="last_name"
|
||||
label="Last Name"
|
||||
placeholder="Enter your last name"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
register={register}
|
||||
error={errors.email}
|
||||
name="email"
|
||||
validations={{
|
||||
required: "Email is required",
|
||||
}}
|
||||
label="Email"
|
||||
placeholder="Enter email"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Button disabled={isSubmitting} type="submit">
|
||||
{isSubmitting ? "Updating Profile..." : "Update Profile"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* <div>
|
||||
<Button type="submit" onClick={handleSubmit(onSubmit)} disabled={isSubmitting}>
|
||||
{isSubmitting ? "Submitting..." : "Update"}
|
||||
</Button>
|
||||
{myProfile.is_email_verified || (
|
||||
<button
|
||||
type="button"
|
||||
className="ml-2 text-indigo-600"
|
||||
onClick={() => {
|
||||
requestEmailVerification()
|
||||
.then(() => {
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Verification email sent.",
|
||||
message: "Please check your email.",
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
});
|
||||
}}
|
||||
>
|
||||
Verify Your Email
|
||||
</button>
|
||||
)}
|
||||
</div> */}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="w-full mx-auto h-full flex justify-center items-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ProjectLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Profile;
|
||||
154
pages/me/workspace-invites.tsx
Normal file
154
pages/me/workspace-invites.tsx
Normal 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);
|
||||
199
pages/projects/[projectId]/cycles.tsx
Normal file
199
pages/projects/[projectId]/cycles.tsx
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
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 ProjectLayout from "layouts/ProjectLayout";
|
||||
// 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 (
|
||||
<ProjectLayout
|
||||
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}
|
||||
/>
|
||||
<div className="w-full h-full flex flex-col space-y-5">
|
||||
{sprints ? (
|
||||
sprints.length > 0 ? (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full px-2">
|
||||
<div className="w-full h-full flex flex-col space-y-5">
|
||||
<Breadcrumbs>
|
||||
<BreadcrumbItem title="Projects" link="/projects" />
|
||||
<BreadcrumbItem title={`${activeProject?.name} 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" action={() => setIsOpen(true)} />
|
||||
</div>
|
||||
<div className="w-full h-full pr-2 overflow-auto">
|
||||
{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>
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</ProjectLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectSprints;
|
||||
375
pages/projects/[projectId]/issues/[issueId].tsx
Normal file
375
pages/projects/[projectId]/issues/[issueId].tsx
Normal file
|
|
@ -0,0 +1,375 @@
|
|||
// next
|
||||
import Link from "next/link";
|
||||
import type { NextPage } from "next";
|
||||
import { useRouter } from "next/router";
|
||||
import Image from "next/image";
|
||||
// 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";
|
||||
// fetch keys
|
||||
import { PROJECT_ISSUES_ACTIVITY, PROJECT_ISSUES_COMMENTS, STATE_LIST } from "constants/fetch-keys";
|
||||
// hooks
|
||||
import useUser from "lib/hooks/useUser";
|
||||
// layouts
|
||||
import ProjectLayout from "layouts/ProjectLayout";
|
||||
// components
|
||||
import CreateUpdateIssuesModal from "components/project/issues/CreateUpdateIssueModal";
|
||||
import IssueCommentSection from "components/project/issues/comment/IssueCommentSection";
|
||||
// common
|
||||
import { timeAgo, debounce, addSpaceIfCamelCase } from "constants/common";
|
||||
// components
|
||||
import IssueDetailSidebar from "components/project/issues/issue-detail/IssueDetailSidebar";
|
||||
// ui
|
||||
import { Spinner, TextArea } from "ui";
|
||||
// types
|
||||
import { IIssue, IIssueComment, IssueResponse, IState } from "types";
|
||||
import { BreadcrumbItem, Breadcrumbs } from "ui/Breadcrumbs";
|
||||
import stateServices from "lib/services/state.services";
|
||||
|
||||
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 (
|
||||
<ProjectLayout>
|
||||
<CreateUpdateIssuesModal
|
||||
isOpen={isOpen}
|
||||
setIsOpen={setIsOpen}
|
||||
projectId={projectId as string}
|
||||
data={isOpen ? issueDetail : undefined}
|
||||
isUpdatingSingleIssue
|
||||
/>
|
||||
|
||||
<div className="space-y-5">
|
||||
<Breadcrumbs>
|
||||
<BreadcrumbItem
|
||||
title={`${activeProject?.name} Issues`}
|
||||
link={`/projects/${activeProject?.id}/issues`}
|
||||
/>
|
||||
<BreadcrumbItem
|
||||
title={`Issue ${activeProject?.identifier}-${issueDetail?.sequence_id} Details`}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<div className="bg-gray-50 rounded-xl overflow-hidden">
|
||||
{issueDetail && activeProject ? (
|
||||
<>
|
||||
<div className="w-full py-4 px-10 bg-gray-200 flex justify-between items-center">
|
||||
<p className="text-gray-500">
|
||||
<Link href={`/projects/${activeProject.id}/issues`}>{activeProject.name}</Link>/
|
||||
{activeProject.identifier}-{issueDetail.sequence_id}
|
||||
</p>
|
||||
<div className="flex gap-x-2">
|
||||
<button
|
||||
type="button"
|
||||
className={`px-4 py-1.5 bg-white rounded-lg ${
|
||||
prevIssue ? "hover:bg-gray-100" : "bg-gray-100"
|
||||
}`}
|
||||
disabled={prevIssue ? false : true}
|
||||
onClick={() => {
|
||||
if (!prevIssue) return;
|
||||
router.push(`/projects/${prevIssue.project}/issues/${prevIssue.id}`);
|
||||
}}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`px-4 py-1.5 bg-white rounded-lg ${
|
||||
nextIssue ? "hover:bg-gray-100" : "bg-gray-100"
|
||||
}`}
|
||||
disabled={nextIssue ? false : true}
|
||||
onClick={() => {
|
||||
if (!nextIssue) return;
|
||||
router.push(`/projects/${nextIssue.project}/issues/${nextIssue?.id}`);
|
||||
}}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex flex-wrap">
|
||||
<div className="w-full lg:w-3/4 h-full px-2 md:px-10 py-10 overflow-auto">
|
||||
<div className="w-full h-full space-y-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 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-gray-50 px-2 text-sm text-gray-500">
|
||||
Activity/Comments
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<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 ${
|
||||
selected ? "bg-gray-800 text-white" : ""
|
||||
}`
|
||||
}
|
||||
>
|
||||
{item}
|
||||
</Tab>
|
||||
))}
|
||||
</Tab.List>
|
||||
<Tab.Panels className="mt-5">
|
||||
<Tab.Panel>
|
||||
<IssueCommentSection
|
||||
comments={issueComments}
|
||||
workspaceSlug={activeWorkspace?.slug as string}
|
||||
projectId={projectId as string}
|
||||
issueId={issueId as string}
|
||||
/>
|
||||
</Tab.Panel>
|
||||
<Tab.Panel>
|
||||
{issueActivities ? (
|
||||
<div className="space-y-3">
|
||||
{issueActivities.map((activity) => {
|
||||
if (activity.field !== "updated_by")
|
||||
return (
|
||||
<div
|
||||
key={activity.id}
|
||||
className="relative flex gap-x-2 w-full"
|
||||
>
|
||||
{/* <span
|
||||
className="absolute top-5 left-5 -ml-1 h-full w-0.5 bg-gray-200"
|
||||
aria-hidden="true"
|
||||
/> */}
|
||||
<div className="flex-shrink-0 -ml-1.5">
|
||||
{activity.actor_detail.avatar &&
|
||||
activity.actor_detail.avatar !== "" ? (
|
||||
<Image
|
||||
src={activity.actor_detail.avatar}
|
||||
alt={activity.actor_detail.name}
|
||||
height={30}
|
||||
width={30}
|
||||
className="rounded-full"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={`h-8 w-8 bg-gray-500 text-white border-2 border-white grid place-items-center rounded-full`}
|
||||
>
|
||||
{activity.actor_detail.first_name.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<p>
|
||||
{activity.actor_detail.first_name}{" "}
|
||||
{activity.actor_detail.last_name}{" "}
|
||||
<span>{activity.verb}</span>{" "}
|
||||
{activity.verb !== "created" ? (
|
||||
<span>{activity.field ?? "commented"}</span>
|
||||
) : (
|
||||
" this issue"
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{timeAgo(activity.created_at)}
|
||||
</p>
|
||||
<div className="w-full mt-2">
|
||||
{activity.verb !== "created" && (
|
||||
<div className="text-sm">
|
||||
<div>
|
||||
From:{" "}
|
||||
<span className="text-gray-500">
|
||||
{activity.field === "state"
|
||||
? activity.old_value
|
||||
? addSpaceIfCamelCase(
|
||||
states?.find(
|
||||
(s) => s.id === activity.old_value
|
||||
)?.name ?? ""
|
||||
)
|
||||
: "None"
|
||||
: activity.old_value}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
To:{" "}
|
||||
<span className="text-gray-500">
|
||||
{activity.field === "state"
|
||||
? activity.new_value
|
||||
? addSpaceIfCamelCase(
|
||||
states?.find(
|
||||
(s) => s.id === activity.new_value
|
||||
)?.name ?? ""
|
||||
)
|
||||
: "None"
|
||||
: activity.new_value}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full h-full flex justify-center items-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
)}
|
||||
</Tab.Panel>
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full lg:w-1/4 h-full border-l px-2 md:px-10 py-10">
|
||||
<IssueDetailSidebar control={control} submitChanges={submitChanges} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ProjectLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default IssueDetail;
|
||||
329
pages/projects/[projectId]/issues/index.tsx
Normal file
329
pages/projects/[projectId]/issues/index.tsx
Normal file
|
|
@ -0,0 +1,329 @@
|
|||
// 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 ProjectLayout from "layouts/ProjectLayout";
|
||||
// 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/Breadcrumbs";
|
||||
// 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 } from "types";
|
||||
|
||||
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
|
||||
);
|
||||
|
||||
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" },
|
||||
];
|
||||
|
||||
return (
|
||||
<ProjectLayout>
|
||||
<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)}
|
||||
/>
|
||||
<div className="w-full h-full flex flex-col space-y-5 pb-6 mb-10">
|
||||
{!projectIssues ? (
|
||||
<div className="w-full h-full flex justify-center items-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : projectIssues.count > 0 ? (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full px-2 pb-8">
|
||||
<div className="w-full h-full flex flex-col space-y-5">
|
||||
<Breadcrumbs>
|
||||
<BreadcrumbItem title="Projects" link="/projects" />
|
||||
<BreadcrumbItem title={`${activeProject?.name} Issues`} />
|
||||
</Breadcrumbs>
|
||||
<div className="flex items-center justify-between cursor-pointer 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 bg-white border border-gray-300 text-xs font-semibold text-gray-700 hover:bg-gray-50 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 bg-white border border-gray-300 text-xs font-semibold text-gray-700 hover:bg-gray-50 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"
|
||||
action={() => {
|
||||
const e = new KeyboardEvent("keydown", {
|
||||
key: "i",
|
||||
ctrlKey: true,
|
||||
});
|
||||
document.dispatchEvent(e);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-full">
|
||||
{issueView === "list" ? (
|
||||
<ListView
|
||||
properties={properties}
|
||||
groupedByIssues={groupedByIssues}
|
||||
selectedGroup={groupByProperty}
|
||||
setSelectedIssue={setSelectedIssue}
|
||||
handleDeleteIssue={setDeleteIssue}
|
||||
/>
|
||||
) : (
|
||||
<BoardView
|
||||
properties={properties}
|
||||
selectedGroup={groupByProperty}
|
||||
groupedByIssues={groupedByIssues}
|
||||
/>
|
||||
)}
|
||||
</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 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>
|
||||
)}
|
||||
</div>
|
||||
</ProjectLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectIssues;
|
||||
203
pages/projects/[projectId]/members.tsx
Normal file
203
pages/projects/[projectId]/members.tsx
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
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 ProjectLayout from "layouts/ProjectLayout";
|
||||
// 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 (
|
||||
<ProjectLayout>
|
||||
<SendProjectInvitationModal isOpen={isOpen} setIsOpen={setIsOpen} members={members} />
|
||||
<div className="w-full h-full flex flex-col space-y-5">
|
||||
{!projectMembers || !projectInvitations ? (
|
||||
<div className="w-full h-full flex justify-center items-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full px-2">
|
||||
<div className="w-full h-full flex flex-col space-y-5 pb-10 overflow-auto">
|
||||
<Breadcrumbs>
|
||||
<BreadcrumbItem title="Projects" link="/projects" />
|
||||
<BreadcrumbItem title={`${activeProject?.name} 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" action={() => 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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ProjectLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectMembers;
|
||||
375
pages/projects/[projectId]/settings.tsx
Normal file
375
pages/projects/[projectId]/settings.tsx
Normal file
|
|
@ -0,0 +1,375 @@
|
|||
import React, { useEffect } 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 ProjectLayout from "layouts/ProjectLayout";
|
||||
// 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";
|
||||
// ui
|
||||
import { Spinner, Button, Input, TextArea, Select } from "ui";
|
||||
import { Breadcrumbs, BreadcrumbItem } from "ui/Breadcrumbs";
|
||||
// icons
|
||||
import { ChevronDownIcon, CheckIcon } from "@heroicons/react/24/outline";
|
||||
// types
|
||||
import type { IProject, 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,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<IProject>({
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const { projectId } = router.query;
|
||||
|
||||
const { activeWorkspace, activeProject } = 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,
|
||||
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);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<ProjectLayout>
|
||||
<div className="w-full h-full space-y-5">
|
||||
<Breadcrumbs>
|
||||
<BreadcrumbItem title="Projects" link="/projects" />
|
||||
<BreadcrumbItem title={`${activeProject?.name} Settings`} />
|
||||
</Breadcrumbs>
|
||||
<div className="w-full h-full flex flex-col 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"
|
||||
validations={{
|
||||
required: "Identifier is required",
|
||||
}}
|
||||
/>
|
||||
</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>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full h-full flex justify-center items-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ProjectLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectSettings;
|
||||
144
pages/projects/index.tsx
Normal file
144
pages/projects/index.tsx
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
// next
|
||||
import type { NextPage } from "next";
|
||||
// hooks
|
||||
import useUser from "lib/hooks/useUser";
|
||||
// layouts
|
||||
import ProjectLayout from "layouts/ProjectLayout";
|
||||
// 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 (
|
||||
<ProjectLayout>
|
||||
<CreateProjectModal isOpen={isOpen && !deleteProject} setIsOpen={setIsOpen} />
|
||||
<ConfirmProjectDeletion
|
||||
isOpen={isOpen && !!deleteProject}
|
||||
setIsOpen={setIsOpen}
|
||||
data={deleteProject}
|
||||
/>
|
||||
{projects ? (
|
||||
<>
|
||||
<div className="flex flex-col items-center justify-center w-full h-full px-2">
|
||||
<div className="w-full h-full flex flex-col space-y-5 pb-10">
|
||||
{projects.length === 0 ? (
|
||||
<div className="w-full h-full flex flex-col justify-center items-center px-4">
|
||||
<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>
|
||||
) : (
|
||||
<>
|
||||
<Breadcrumbs>
|
||||
<BreadcrumbItem title={`${activeWorkspace?.name} 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"
|
||||
action={() => 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>
|
||||
</>
|
||||
) : (
|
||||
<div className="w-full h-full flex justify-center items-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
)}
|
||||
</ProjectLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Projects;
|
||||
173
pages/signin.tsx
Normal file
173
pages/signin.tsx
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
// next
|
||||
import type { NextPage } from "next";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import Image from "next/image";
|
||||
// react
|
||||
import React, { useCallback, useState } from "react";
|
||||
// hooks
|
||||
import useUser from "lib/hooks/useUser";
|
||||
// services
|
||||
import authenticationService from "lib/services/authentication.service";
|
||||
// hoc
|
||||
import withAuthWrapper from "lib/hoc/withAuthWrapper";
|
||||
// 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] = React.useState(undefined);
|
||||
const [loginCallBackURL, setLoginCallBackURL] = React.useState(undefined);
|
||||
|
||||
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]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const {
|
||||
query: { code },
|
||||
} = router;
|
||||
if (code && !githubTokenMemo) {
|
||||
setGithubToken(code as any);
|
||||
}
|
||||
}, [router, githubTokenMemo]);
|
||||
|
||||
React.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]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const origin =
|
||||
typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
|
||||
setLoginCallBackURL(`${origin}/signin` as any);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<DefaultLayout
|
||||
meta={{
|
||||
title: "Plane - Sign In",
|
||||
}}
|
||||
>
|
||||
<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 }) => {
|
||||
authenticationService
|
||||
.socialAuth({
|
||||
medium: "google",
|
||||
credential,
|
||||
clientId,
|
||||
})
|
||||
.then(async (response) => {
|
||||
await onSignInSuccess(response);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
});
|
||||
}}
|
||||
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 withAuthWrapper(SignIn);
|
||||
31
pages/testing.tsx
Normal file
31
pages/testing.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import React, { useState } from "react";
|
||||
|
||||
const assignees = [
|
||||
{
|
||||
name: "Wade Cooper",
|
||||
value: "wade-cooper",
|
||||
},
|
||||
{ name: "Unassigned", value: "null" },
|
||||
];
|
||||
|
||||
import { SearchListbox } from "ui";
|
||||
|
||||
const Page = () => {
|
||||
const [assigned, setAssigned] = useState(assignees[0]);
|
||||
|
||||
return (
|
||||
<div className="flex justify-center items-center h-screen w-full">
|
||||
<SearchListbox
|
||||
display="Assign"
|
||||
name="assignee"
|
||||
options={assignees}
|
||||
onChange={(value) => {
|
||||
setAssigned(assignees.find((assignee) => assignee.value === value) ?? assignees[0]);
|
||||
}}
|
||||
value={assigned.value}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
152
pages/workspace-member-invitation/[invitationId].tsx
Normal file
152
pages/workspace-member-invitation/[invitationId].tsx
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
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";
|
||||
// hoc
|
||||
import withAuthWrapper from "lib/hoc/withAuthWrapper";
|
||||
// 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 withAuthWrapper(WorkspaceInvitation);
|
||||
162
pages/workspace/index.tsx
Normal file
162
pages/workspace/index.tsx
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
// next
|
||||
import type { NextPage } from "next";
|
||||
import Link from "next/link";
|
||||
// react
|
||||
import React from "react";
|
||||
// layouts
|
||||
import ProjectLayout from "layouts/ProjectLayout";
|
||||
// swr
|
||||
import useSWR from "swr";
|
||||
// hooks
|
||||
import useUser from "lib/hooks/useUser";
|
||||
// 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",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<ProjectLayout>
|
||||
<div className="h-full w-full px-2 space-y-5">
|
||||
<div>
|
||||
{user ? (
|
||||
<div className="font-medium text-2xl">Good Morning, {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>
|
||||
)}
|
||||
</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>
|
||||
</ProjectLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Workspace;
|
||||
211
pages/workspace/members.tsx
Normal file
211
pages/workspace/members.tsx
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
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";
|
||||
import ProjectLayout from "layouts/ProjectLayout";
|
||||
// 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 (
|
||||
<ProjectLayout
|
||||
meta={{
|
||||
title: "Plane - Workspace Invite",
|
||||
}}
|
||||
>
|
||||
<SendWorkspaceInvitationModal
|
||||
isOpen={isOpen}
|
||||
setIsOpen={setIsOpen}
|
||||
workspace_slug={activeWorkspace?.slug as string}
|
||||
members={members}
|
||||
/>
|
||||
{!workspaceMembers || !workspaceInvitations ? (
|
||||
<div className="w-full h-full flex justify-center items-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full px-2">
|
||||
<div className="w-full h-full flex flex-col space-y-5 pb-10">
|
||||
<Breadcrumbs>
|
||||
<BreadcrumbItem title={`${activeWorkspace?.name} 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" action={() => 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 ? (
|
||||
"Accepted"
|
||||
) : 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>
|
||||
</div>
|
||||
)}
|
||||
</ProjectLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default withAuthWrapper(WorkspaceInvite);
|
||||
226
pages/workspace/settings.tsx
Normal file
226
pages/workspace/settings.tsx
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
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 ProjectLayout from "layouts/ProjectLayout";
|
||||
|
||||
// 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";
|
||||
|
||||
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 res;
|
||||
}
|
||||
return workspace;
|
||||
});
|
||||
}, false);
|
||||
setToastAlert({
|
||||
title: "Success",
|
||||
type: "success",
|
||||
message: "Workspace updated successfully",
|
||||
});
|
||||
})
|
||||
.catch((err) => console.log(err));
|
||||
};
|
||||
|
||||
return (
|
||||
<ProjectLayout
|
||||
meta={{
|
||||
title: "Plane - Workspace Settings",
|
||||
}}
|
||||
>
|
||||
<ConfirmWorkspaceDeletion isOpen={isOpen} setIsOpen={setIsOpen} />
|
||||
|
||||
<div className="w-full h-full space-y-5">
|
||||
<Breadcrumbs>
|
||||
<BreadcrumbItem title={`${activeWorkspace?.name} Settings`} />
|
||||
</Breadcrumbs>
|
||||
<div className="w-full h-full flex flex-col space-y-3">
|
||||
{activeWorkspace ? (
|
||||
<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 workspace.
|
||||
</p>
|
||||
</div>
|
||||
<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>
|
||||
</section>
|
||||
<section className="space-y-5">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium leading-6 text-gray-900">Actions</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Once deleted, it will be gone forever. Please be certain.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Button theme="danger" onClick={() => setIsOpen(true)}>
|
||||
Delete the workspace
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full h-full flex justify-center items-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ProjectLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkspaceSettings;
|
||||
Loading…
Add table
Add a link
Reference in a new issue